forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc01396cd | ||
|
|
c7e8163790 | ||
|
|
c3f8a69b51 |
No files matched your search
@@ -4,6 +4,30 @@ 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-09-14 Ability hover tooltips
|
||||
|
||||
- Entered Stormwind Stockade with the existing offline level-17 Priest through
|
||||
the normal roster, dungeon, and role screens. Hovering Smite on the controller
|
||||
bar displayed its description, Mana cost, cast time, cooldown, range, and
|
||||
target. Leaving the button removed the tooltip.
|
||||
- Opened Ability controls and its ability picker. Hovering Shadow Word: Pain
|
||||
displayed its own description without assigning it. Flash Heal was readable
|
||||
by hover and keyboard focus despite its level-20 lock. Switching from keyboard
|
||||
inspection to another hovered ability left exactly one tooltip visible.
|
||||
- Checked the picker at the default 650 x 490 viewport and at 1280 x 800.
|
||||
Tooltip DOM bounds remained inside the viewport. The in-app screenshot
|
||||
scales unusually with a viewport override; reset the override afterward.
|
||||
- Enabled desktop bars in an unsaved UI-layout draft. Hovering the leftmost
|
||||
Smite button showed the tooltip above the bar, clamped to the left edge and
|
||||
outside the HUD's transformed layout container. Discarded the layout draft.
|
||||
- A temporary Flash Heal assignment during input testing was restored to the
|
||||
original Smite assignment. No character or template was created or deleted.
|
||||
- Screenshot evidence: `playtest-artifacts/ability-tooltips/desktop-bar.png`
|
||||
and `playtest-artifacts/ability-tooltips/locked-picker.png`.
|
||||
- Automated verification: all 64 UI tests passed, including nine tooltip
|
||||
content, unavailable-control, and viewport-placement regression tests.
|
||||
TypeScript, the production build, and the release candidate check passed.
|
||||
|
||||
## 2026-09-10 Personal UI templates and desktop action bars
|
||||
|
||||
- Opened Wailing Caverns through the normal offline roster, dungeon, and role
|
||||
@@ -3028,3 +3052,263 @@ Vite's existing large-chunk advisory remains informational.
|
||||
`artifacts/boss-difficulty/wow-coa-review.md`. Coverage is a canonical build
|
||||
per role with starter gear, rather than every specialization or later gear
|
||||
combination. `npm run release:check` is required for handoff.
|
||||
|
||||
## CoA healer rotation pass — September 13, 2026
|
||||
|
||||
- Corrected base-mana percentage costs, Tinker's secondary mana maximum,
|
||||
imported heal power/AP/spirit scaling, self-healing riders, and party heal
|
||||
recipients. Removed invented instant heals from conditional procs and
|
||||
invented hostile damage from healing/mitigation tooltip text. Regenerated
|
||||
the CoA runtime catalog and all class shards.
|
||||
- Companion support AI maintains existing HoTs, covers other injured allies,
|
||||
and avoids redundant shields. The benchmark player policy now prefers
|
||||
effective emergency/area healing, avoids wasteful self-healing, positions
|
||||
ground heals on the injured ally, and funds rage heals through weapon attacks
|
||||
and actual resource generators. Normal gameplay still uses player input.
|
||||
- Tested nine canonical CoA healer builds at levels 8, 20, 40, and 80 against
|
||||
final-boss attacks for 60 seconds with three deterministic WoW companion
|
||||
compositions each: 108 samples. Every player survived and every sample
|
||||
delivered effective ally healing. Entire-party survival was 24/27 at eight,
|
||||
25/27 at twenty, and 27/27 at both forty and eighty.
|
||||
- The remaining low-level losses are Primalist companions. Its imported Geode
|
||||
Barrage lacks the server-scripted stone/rage payload; that mechanic still
|
||||
needs implementation. Survival at the time cap does not establish a boss
|
||||
clear, indefinite mana sustainability, or complete CoA mechanic parity.
|
||||
- Reran the full WoW/CoA durability and healer-attack suite: all six assertions
|
||||
passed without reporter errors. Fastest sampled ordinary/final kills remain
|
||||
26.2/48.6 seconds. WoW Druid's comparable eight/eighty attack samples now keep
|
||||
the party alive with the revised HoT policy.
|
||||
- Validation: 167 combat/catalog/runtime regressions, four healer matrix
|
||||
assertions, 47 avatar checks, TypeScript, the production build, and release
|
||||
bundle validation passed. The final release hygiene check covers untracked
|
||||
regression tests as well as tracked edits.
|
||||
- Live production-preview smoke check at 1280x720 used an offline Human Witch
|
||||
Doctor (Brewing). Roster and Wailing Caverns entrance captures show the
|
||||
player and companion bodies and starter weapon/shield silhouettes. This
|
||||
visual check did not perform a live boss clear or validate manual rotations.
|
||||
Evidence: `artifacts/boss-difficulty/coa-healer-smoke/roster.png` and
|
||||
`artifacts/boss-difficulty/coa-healer-smoke/dungeon.png`.
|
||||
- Numerical evidence and remaining limitations:
|
||||
`artifacts/boss-difficulty/coa-healer-balance.json`,
|
||||
`artifacts/boss-difficulty/wow-coa-balance.json`, and
|
||||
`artifacts/boss-difficulty/coa-healer-review.md`.
|
||||
|
||||
## Primalist rage generation follow-up — September 13, 2026
|
||||
|
||||
- Restored Geode Barrage's three physical stone pulses at cast completion,
|
||||
+500ms, and +1000ms, using the ranked parent amount, 23% nature/spell power,
|
||||
and 9% attack power. Each pulse dealing effective damage grants its caster
|
||||
5 rage, for 15 from a full Barrage. Removed the unscheduled hidden placeholder
|
||||
proc so another periodic event cannot substitute for or duplicate the stones.
|
||||
- The 5-rage stone amount is HealerMan's normalization using the related
|
||||
imported Geode spell 804002 payload. Barrage's imported DBC does not expose
|
||||
an exact rage amount; this does not establish exact server-script parity.
|
||||
Dead targets and fully absorbed stones grant no rage. Player and companion
|
||||
grants cap at the source pool maximum and synchronize the actor's pools.
|
||||
- Seismic Crash grants 20 rage once per cast and executes its initial damage
|
||||
pulse. Seismic Spike grants 4 per target actually damaged. Primalist energize
|
||||
effects retain their power type and convert internal rage units to displayed
|
||||
points: Primal Rush and passive Protective Roar grant 20 rather than 200.
|
||||
Protective Roar retains beneficial party recipients when its payload fires.
|
||||
- Scoped imported power-unit conversion to Primalist. A global conversion
|
||||
caused a Bloodmage survival regression; other classes retain their existing
|
||||
script-payload interpretation pending their own power-record review.
|
||||
- The benchmark policy considers all usable rage generators, including Crash
|
||||
and Barrage, before rage heals. Normal gameplay still uses player input.
|
||||
All 108 attacking-final-boss CoA healer samples now keep the entire party
|
||||
alive, including the five previously failing low-level Primalist samples.
|
||||
The regression requires zero companion deaths at every tested level.
|
||||
- Validation: 207 combat/catalog/runtime/avatar checks passed, including eight
|
||||
focused Primalist checks for timing, coefficients, capped gains, absorbed and
|
||||
dead targets, companion attribution, passive payloads, and Seismic gains.
|
||||
All four strict healer matrix assertions passed. All six WoW/CoA boss suite
|
||||
assertions passed across 1,464 durability runs and 26 attacking-boss samples;
|
||||
fastest sampled ordinary/final kills remain 26.2/48.6 seconds. TypeScript,
|
||||
the production build, release bundle validation, and release hygiene passed.
|
||||
- Live production-preview smoke check at 1280x720 used an offline female
|
||||
Tauren Primalist (Life). Roster and Wailing Caverns entrance captures show
|
||||
a character body with a staff and companion bodies with weapon silhouettes.
|
||||
The Tauren identity rendered a human-looking fallback; the optional content
|
||||
service was unavailable in this static preview. These captures establish
|
||||
body/equipment visibility, not correct Tauren appearance. Entrance trash
|
||||
reached level two with the full party alive. Barrage remained locked until
|
||||
level six, so pulse/rage behavior was checked by deterministic runtime tests,
|
||||
rather than a live manual Barrage cast. No live boss clear was performed.
|
||||
- Evidence: `artifacts/boss-difficulty/primalist-smoke/roster.png`,
|
||||
`artifacts/boss-difficulty/primalist-smoke/dungeon.png`,
|
||||
`artifacts/boss-difficulty/coa-healer-balance.json`,
|
||||
`artifacts/boss-difficulty/wow-coa-balance.json`, and
|
||||
`artifacts/boss-difficulty/coa-healer-review.md`. Survival at the 60-second
|
||||
cap does not establish a boss clear or indefinite resource sustainability.
|
||||
|
||||
## WoW action bars in Ability controls — September 13, 2026
|
||||
|
||||
- Added Controller bars / WoW bars selection to Ability controls. On PC, the
|
||||
dialog opens on WoW bars when that UI layout is enabled. The existing three
|
||||
controller layers remain available through the same screen.
|
||||
- The WoW editor exposes all five bars and twelve buttons per bar, including
|
||||
inactive bars. Slots show their saved keyboard shortcuts and assigned
|
||||
abilities. Assigning or leaving a slot empty saves immediately through the
|
||||
existing profile UI persistence path and updates the gameplay action bar.
|
||||
- Restore Bar resets that bar's abilities to the current character's class
|
||||
defaults; Restore all WoW bars resets all sixty ability slots. Both use a
|
||||
confirmation with cancellation. Keyboard shortcuts, bar geometry, PC/mobile
|
||||
UI templates, and controller bindings remain independent of these resets.
|
||||
- Long WoW ability lists scroll inside the picker. Controller navigation keeps
|
||||
the selected control visible. Future abilities retain their unlock-level
|
||||
labels and can be assigned ahead of time; passive abilities are excluded.
|
||||
- Live 1280x720 production-preview QA used an isolated offline Human Priest
|
||||
profile at port 5192. Verified first-slot assignment, inactive fifth-bar
|
||||
button-twelve assignment and clearing, canceling a reset, restoring one bar
|
||||
while retaining the fifth-bar assignment, and restoring all WoW bars.
|
||||
Controller Cross retained Smite through the WoW edits and resets.
|
||||
- Enabled the WoW layout through Edit UI & templates, confirmed Ability controls
|
||||
opened directly on WoW bars, and changed the first button to Lesser Heal.
|
||||
The gameplay bar updated immediately. After a full page reload and dungeon
|
||||
reentry, both the gameplay bar and editor retained Lesser Heal and key 1.
|
||||
The final picker capture shows access to the late-level abilities and Leave
|
||||
empty while all bar controls stay visible. No browser UI errors were logged.
|
||||
- Validation: all 55 UI-layout, action-binding, keybind, profile-sync, input,
|
||||
HUD-layering, and panel-integration tests passed. The production build,
|
||||
including TypeScript with the project's configured 8 GB heap, prebuild asset
|
||||
audits, and postbuild release-bundle validation, passed. A separate manual
|
||||
TypeScript attempt using Node's default heap exhausted memory; the standard
|
||||
build completed successfully. Final release hygiene covers all changed paths.
|
||||
- Evidence: `artifacts/ability-controls/wow-bars.png`,
|
||||
`artifacts/ability-controls/controller-bars.png`,
|
||||
`artifacts/ability-controls/wow-picker.png`, and
|
||||
`artifacts/ability-controls/wow-picker-end.png`. Cloud upload and physical
|
||||
gamepad input were not exercised by this offline browser pass.
|
||||
|
||||
## Account-wide boss card collections — 2026-09-14
|
||||
|
||||
- Moved boss card counts and reward receipts out of character profiles into one
|
||||
collection per account owner. Gameplay starts with that account collection;
|
||||
menu and in-game collection screens show the same account totals.
|
||||
- Migrates existing character logs by summing their rarity counts, retaining the
|
||||
newest reward receipts, and removing the old character fields. Reloading or
|
||||
downloading an old cloud roster again does not repeat the migration.
|
||||
- Cloud uploads now include the account collection, including accounts with an
|
||||
empty roster. Migrated cloud logs upload without waiting for another boss kill.
|
||||
Higher cached counts and newer receipts survive an older cloud download.
|
||||
- Automated integration checks verified earning on a second character,
|
||||
switching back, account sign-out isolation, character deletion, and restoration
|
||||
into a separate device cache. Collection markup identifies the account scope.
|
||||
- Validation: 164 tests passed across 19 files, with six shelved-mode tests
|
||||
skipped. This persistence change did not alter character models or equipment.
|
||||
- TypeScript, prebuild asset audits, and release hygiene passed. The first
|
||||
shared-output bundle check encountered a missing staged asset; an isolated
|
||||
production build and complete bundle check passed with all 5,529 objects.
|
||||
Validation output is under `artifacts/boss-cards/account-build`.
|
||||
|
||||
## Main-menu talents and controls — 2026-09-14
|
||||
|
||||
- Added Talents, Keybinds, and Button assignments destinations using the existing
|
||||
editors. RoM characters show their automatically ranked Skills instead of
|
||||
a talent-point editor. Closing a panel or using Back returns to the main menu.
|
||||
- A menu-only character subscription initializes the selected character and
|
||||
saves talent ranks and controller ability assignments without mounting the
|
||||
dungeon simulation. Level, experience, inventory, and equipment are preserved.
|
||||
Keyboard bindings and WoW bar assignments retain the existing profile saving
|
||||
behavior; keybind drafts require Save changes.
|
||||
- Live offline browser QA at 1280x720 opened all three editors for the level-17
|
||||
Priest Rolecheck. The talent tree showed eight available points, controls
|
||||
showed the character's skill names, and the assignment picker showed current
|
||||
Smite and future unlock levels. Canceling a keybind edit and using Done returned
|
||||
correctly; Escape first canceled the ability picker and then closed the editor.
|
||||
This browser pass did not change the character's build or saved controls.
|
||||
- At a 390x844 viewport, the talent dialog's measured bounds fit the screen,
|
||||
with no horizontal content overflow and a visible main-menu Back button.
|
||||
Browser screenshot scaling was unreliable at this override; visual evidence
|
||||
below covers the default desktop viewport. Physical gamepads and cloud saves
|
||||
were not exercised.
|
||||
- Persistence tests cover allocation, reset, assignment, reopening, storage
|
||||
failure, and owner/selection/phase/close isolation. UI and profile regressions
|
||||
passed. TypeScript, production bundling, and complete release-bundle validation
|
||||
passed after retrying a shared-output staging race; release hygiene passed.
|
||||
- Evidence: `playtest-artifacts/main-menu-editors/main-menu.png`,
|
||||
`playtest-artifacts/main-menu-editors/talents.png`,
|
||||
`playtest-artifacts/main-menu-editors/keybinds.png`, and
|
||||
`playtest-artifacts/main-menu-editors/button-assignments.png`.
|
||||
|
||||
## AYN Thor physical-size UI review — 2026-09-14
|
||||
|
||||
- Browser profiles use the Thor's 6-inch 1920x1080 upper panel and 3.92-inch
|
||||
1240x1080 lower panel. Panel sizes come from
|
||||
[AYN's specifications](https://www.ayntec.com/products/ayn-thor); resolutions
|
||||
are also documented in the
|
||||
[Thor manual](https://ae-pic-a1.aliexpress-media.com/kf/S21d979b6c8d840ddaebfeb976094e0cfP.pdf).
|
||||
Derived physical rectangles are 132.8x74.7 mm and 75.1x65.4 mm. At a common
|
||||
160 CSS pixels per inch, browser viewports are 837x471 and 473x412, with
|
||||
independent device scale factors. A 44-pixel control represents about 7 mm
|
||||
on either panel. The combined preview uses these relative physical sizes.
|
||||
- Dungeon defaults put party/self health, targets, minimap, and encounter
|
||||
information upstairs. Controller actions, cast bar, buffs/debuffs, experience,
|
||||
and DPS meter appear downstairs. Shared HUD components render once on their
|
||||
assigned panel; the old duplicate tactical party-health panel is replaced.
|
||||
- Every HUD element has Top / Bottom / Hidden in the mobile UI editor. Touch
|
||||
dragging from the lower page updates the upper page's authoritative draft.
|
||||
Selection raises the widget above overlapping controls. Save, cancel,
|
||||
reset, rapid reopening, and a full reload/dungeon reentry were verified.
|
||||
Legacy templates without a screen field retain their defaults. Single-screen
|
||||
play keeps visible elements on its primary display.
|
||||
- Enlarged text, icons, and touch controls; corrected roster, creator, class
|
||||
hall, downloads, and dungeon/role layouts for the actual panel dimensions.
|
||||
Character creation uses the lower model preview. Editor controls scroll with
|
||||
Save and Cancel kept visible. Buff tooltips and the completion banner use
|
||||
readable text; a lower-screen completion banner wraps its buttons below it.
|
||||
- Edge browser QA uses two Android/touch-emulated pages in one isolated offline
|
||||
context. BroadcastChannel carries actual snapshots and touch commands between
|
||||
pages. Covered login, empty/populated roster, both creation steps, main menu,
|
||||
class hall, menu talents/keybinds/button assignments, reports, downloads,
|
||||
dungeon/role selection, gameplay, pause, map, party, boss guide, spellbook,
|
||||
talents, ability controls, inventory, boss cards, options, keybinds, issue
|
||||
reporting, UI editor, defeat/respawn, completion/results, and combined preview.
|
||||
- Verified lower action casting changes the upper combat cooldown; party frames
|
||||
can move downstairs, hide, save, reload, and select the player remotely.
|
||||
Buff inspection opens by touch. Results open automatically and can reopen
|
||||
from the banner; finishing through the lower banner returns both pages to
|
||||
the main menu. Completion uses an isolated presentation fixture, rather than
|
||||
a live boss clear. Dungeon captures include equipped fallback bodies; this
|
||||
review does not establish authentic race-model loading.
|
||||
- All 42 layout, keybind, HUD-layering, display, snapshot, and dual-screen
|
||||
regressions passed. A complete production build with asset audits and release
|
||||
bundle validation passed; final UI edits also passed production compilation.
|
||||
Browser captures have no unscrollable controls clipped outside their panel
|
||||
and no uncaught UI errors. Physical hardware, Android WebView installation,
|
||||
live online grouping, and shelved game modes were not exercised.
|
||||
- Reproduce with `node scripts/playtest-thor-ui.cjs` while Vite runs at port
|
||||
5176, or set `THOR_TEST_URL`. Evidence is in
|
||||
`playtest-artifacts/thor-ui/results.json` and paired PNGs, including
|
||||
`14-final-defaults`, `14d-results`, `14e-complete-bottom`, and
|
||||
`15-combined-preview.png`.
|
||||
|
||||
## Boss Testing (2026-09-14)
|
||||
|
||||
- Main menu now opens a searchable boss catalog with game and readiness
|
||||
filters, selected-boss fights, and random fights. Selecting a boss installs
|
||||
its missing dungeon content pack and dependencies automatically.
|
||||
- Testing uses an empty procedural room with one initial boss. Existing
|
||||
combat, encounter phases, summoned adds, and relevant mechanic objects are
|
||||
retained. Tests grant no experience, loot, or boss cards, and do not persist
|
||||
changes to the character's progression or inventory.
|
||||
- Pause and victory review offer Ready, Needs testing, and Not ready labels,
|
||||
boss-specific reports, retry, and return to the main menu. Labels persist
|
||||
through the server, with local pending saves retained while offline.
|
||||
- Regression checks passed: 98 focused client tests, 19 server tests, and a
|
||||
complete production build with asset audits and release bundle validation.
|
||||
- Production Edge playtest passed selection, active Lady Anacondra mechanics,
|
||||
all three readiness labels, report submission, retry, victory review,
|
||||
persistence, random selection, and a 390-pixel mobile layout without
|
||||
horizontal overflow. Victory uses deterministic damage through the combat
|
||||
store; this is not a natural full-fight clear. Experience and saved character
|
||||
data were unchanged, and no uncaught browser errors occurred.
|
||||
- Selecting Taragaman the Hungerer from an uninstalled Ragefire Chasm pack
|
||||
downloaded the pack and dependencies, then loaded the boss model and active
|
||||
mechanics in the test room. A second browser page received the testing room
|
||||
snapshot and opened boss review through the lower-display button. Physical
|
||||
dual-screen hardware was not exercised.
|
||||
- Reproduce with `node scripts/playtest-boss-testing.cjs`; the script starts
|
||||
an isolated production server and test database. Evidence is in
|
||||
`playtest-artifacts/boss-testing/results.json` and screenshots
|
||||
`01-main-menu.png` through `12-bottom-display-review.png`.
|
||||
@@ -139,6 +139,8 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
|
||||
if (forceEnabled) scheduleEnsurePresentation(300L);
|
||||
});
|
||||
presentation.show();
|
||||
bridge.getWebView().getSettings().setUseWideViewPort(true);
|
||||
bridge.getWebView().getSettings().setLoadWithOverviewMode(true);
|
||||
bridge.getWebView().loadUrl(currentUrl);
|
||||
}
|
||||
|
||||
@@ -279,6 +281,8 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setUseWideViewPort(true);
|
||||
settings.setLoadWithOverviewMode(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||
settings.setAllowFileAccess(true);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.43",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.43",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.43",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.40",
|
||||
"versionCode": 1040
|
||||
"versionName": "0.1.43",
|
||||
"versionCode": 1043
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const directory = path.resolve('playtest-artifacts/boss-testing');
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir: path.join(directory, 'test-database'), enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(4187, '127.0.0.1', resolve));
|
||||
console.log('Started isolated production test server');
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(60000);
|
||||
const errors = [];
|
||||
const assetFiles = fs.readdirSync(path.resolve('dist/assets'));
|
||||
const mainModule = `/assets/${assetFiles.find((file) => /^index-.*\.js$/.test(file))}`;
|
||||
const combatModule = `/assets/${assetFiles.find((file) => /^combatStore-.*\.js$/.test(file))}`;
|
||||
await page.addInitScript(({ mainModule, combatModule }) => {
|
||||
window.bossTestingStores = async () => {
|
||||
const main = Object.values(await import(mainModule));
|
||||
const combat = Object.values(await import(combatModule));
|
||||
return {
|
||||
useGameStore: main.find((value) => value?.getState && 'bossTest' in value.getState()),
|
||||
useShellStore: main.find((value) => value?.getState && 'characters' in value.getState()),
|
||||
useCombatStore: combat.find((value) => value?.getState && 'damageMob' in value.getState()),
|
||||
};
|
||||
};
|
||||
}, { mainModule, combatModule });
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const capture = async (name) => {
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
console.log(`Captured ${name}`);
|
||||
};
|
||||
try {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
console.log('Production menu loaded');
|
||||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('Bosstester');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue|Enter world/ }).first().click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await capture('01-main-menu');
|
||||
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
||||
await page.getByRole('heading', { name: 'Boss Testing', exact: true }).waitFor();
|
||||
const picker = page.getByLabel(/Choose a boss/);
|
||||
const option = await picker.locator('option').evaluateAll((options) => options.find((entry) => /Lady Anacondra.*Wailing Caverns/.test(entry.textContent))?.value);
|
||||
assert(option, 'Lady Anacondra is selectable');
|
||||
await picker.selectOption(option);
|
||||
await page.getByRole('button', { name: 'Test selected boss', exact: true }).waitFor({ state: 'visible' });
|
||||
await capture('02-boss-selection');
|
||||
await page.getByRole('button', { name: 'Test selected boss', exact: true }).click();
|
||||
if (await page.getByRole('alert').count()) throw new Error(await page.getByRole('alert').innerText());
|
||||
await page.locator('canvas').waitFor();
|
||||
await page.waitForFunction(async () => {
|
||||
const { useGameStore, useCombatStore } = await window.bossTestingStores();
|
||||
const game = useGameStore.getState();
|
||||
return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
|
||||
}, null, { timeout: 90000 });
|
||||
await page.waitForTimeout(4000);
|
||||
await capture('03-empty-room');
|
||||
const before = await page.evaluate(async () => {
|
||||
const { useGameStore, useCombatStore, useShellStore } = await window.bossTestingStores();
|
||||
const game = useGameStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
const boss = game.bossTest;
|
||||
return { boss, xp: combat.xp, saved: JSON.stringify(useShellStore.getState().characters), count: Object.keys(combat.mobs).length };
|
||||
});
|
||||
assert.equal(before.count, 1, 'Only the selected boss spawns initially');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Boss readiness & report', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Mark not ready', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Marked not ready.' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Mark needs testing', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Marked needs testing.' }).waitFor();
|
||||
await capture('04-pause-review');
|
||||
await page.getByRole('button', { name: 'Submit a boss report', exact: true }).click();
|
||||
await page.getByLabel('Describe the issue', { exact: true }).fill('Boss Testing playtest: verify mechanic telegraphs.');
|
||||
await page.getByRole('button', { name: 'Submit report', exact: true }).click();
|
||||
await page.getByText('Report submitted.', { exact: false }).waitFor();
|
||||
await capture('05-boss-report');
|
||||
await page.getByRole('button', { name: 'Back to boss review', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Fight this boss again', exact: true }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate(async () => {
|
||||
const { useGameStore, useCombatStore } = await window.bossTestingStores();
|
||||
const game = useGameStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
combat.selectMob(game.bossTest.runtimeIds[0]);
|
||||
combat.damageMob(game.bossTest.runtimeIds[0], 1000000, Date.now());
|
||||
});
|
||||
await page.getByText('Boss defeated', { exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Mark ready', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Marked ready.' }).waitFor();
|
||||
await capture('06-victory-review');
|
||||
const after = await page.evaluate(async () => {
|
||||
const { useCombatStore, useShellStore } = await window.bossTestingStores();
|
||||
return { xp: useCombatStore.getState().xp, saved: JSON.stringify(useShellStore.getState().characters) };
|
||||
});
|
||||
assert.equal(after.xp, before.xp);
|
||||
assert.equal(after.saved, before.saved);
|
||||
const reports = runtime.database.listDungeonReports().reports;
|
||||
assert.equal(reports[0].bossKey, before.boss.key);
|
||||
assert.equal(runtime.database.listBossTestingReviews().reviews[0].status, 'ready');
|
||||
await page.getByRole('button', { name: 'Return to main menu', exact: true }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
||||
await picker.selectOption(option);
|
||||
await page.getByRole('article').getByText('Ready', { exact: true }).waitFor();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await capture('07-mobile-selection');
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'No mobile horizontal overflow');
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.getByLabel('Search bosses').fill('Lady Anacondra');
|
||||
await page.getByRole('button', { name: 'Fight a random boss', exact: true }).click();
|
||||
await page.locator('canvas').waitFor();
|
||||
await page.waitForTimeout(3000);
|
||||
await capture('08-random-fight');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Return to main menu', exact: true }).click();
|
||||
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
||||
const missing = await picker.locator('option').evaluateAll((options) => options.find((entry) => /Taragaman the Hungerer.*Ragefire Chasm/.test(entry.textContent))?.value);
|
||||
assert(missing, 'An uninstalled boss is selectable');
|
||||
await picker.selectOption(missing);
|
||||
await capture('09-automatic-download');
|
||||
await page.waitForFunction(() => {
|
||||
const button = [...document.querySelectorAll('button')].find((entry) => entry.textContent === 'Test selected boss');
|
||||
const error = document.querySelector('[role="alert"]');
|
||||
if (error) throw new Error(error.textContent);
|
||||
return button && !button.disabled;
|
||||
}, null, { timeout: 120000 });
|
||||
const installed = await page.evaluate(() => JSON.parse(localStorage.getItem('healer-man.content-state.v2') || '{}'));
|
||||
assert(installed.packs['dungeon:ragefire-chasm'], 'The selected boss pack downloads automatically');
|
||||
await page.getByRole('button', { name: 'Test selected boss', exact: true }).click();
|
||||
await page.waitForFunction(async () => {
|
||||
const { useGameStore, useCombatStore } = await window.bossTestingStores();
|
||||
const game = useGameStore.getState();
|
||||
return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
|
||||
}, null, { timeout: 90000 });
|
||||
await page.waitForTimeout(1500);
|
||||
await capture('10-downloaded-boss-room');
|
||||
const bottom = await page.context().newPage();
|
||||
bottom.on('pageerror', (error) => errors.push(error.message));
|
||||
await bottom.setViewportSize({ width: 1240, height: 1080 });
|
||||
await bottom.goto(`${base}/?display=bottom&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
|
||||
await bottom.getByRole('button', { name: 'Boss review', exact: true }).waitFor();
|
||||
await bottom.screenshot({ path: path.join(directory, '11-bottom-display.png') });
|
||||
await bottom.getByRole('button', { name: 'Boss review', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Taragaman the Hungerer', exact: true }).waitFor();
|
||||
await capture('12-bottom-display-review');
|
||||
await bottom.close();
|
||||
assert.deepEqual(errors, [], 'No browser runtime errors');
|
||||
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, boss: before.boss, initialMobCount: before.count, xpUnchanged: true, profileUnchanged: true, automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
|
||||
console.log('Boss Testing live flow passed');
|
||||
} catch (error) {
|
||||
await capture('failure');
|
||||
console.log((await page.locator('body').innerText()).slice(-2000));
|
||||
console.log('Browser errors:', errors);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,249 @@
|
||||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const directory = path.resolve('playtest-artifacts/thor-ui');
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const base = process.env.THOR_TEST_URL || 'http://127.0.0.1:5176';
|
||||
const results = [];
|
||||
(async () => {
|
||||
console.log('Launching Thor UI browser');
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
console.log('Browser launched');
|
||||
const context = await browser.newContext({ hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13; AYN Thor) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' });
|
||||
await context.addInitScript(() => performance.setResourceTimingBufferSize(20000));
|
||||
const top = await context.newPage();
|
||||
const bottom = await context.newPage();
|
||||
console.log('Two pages ready');
|
||||
const errors = [];
|
||||
for (const [page, width, height, diagonal] of [[top, 1920, 1080, 6], [bottom, 1240, 1080, 3.92]]) {
|
||||
const ppi = Math.hypot(width, height) / diagonal;
|
||||
const viewport = { width: Math.round(width / ppi * 160), height: Math.round(height / ppi * 160) };
|
||||
await page.setViewportSize(viewport);
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { ...viewport, deviceScaleFactor: ppi / 160, mobile: false });
|
||||
console.log('Configured panel', viewport);
|
||||
page.setDefaultTimeout(60000);
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
}
|
||||
const capture = async (name) => {
|
||||
for (const [surface, page] of [['top', top], ['bottom', bottom]]) {
|
||||
await page.screenshot({ path: path.join(directory, `${name}-${surface}.png`) });
|
||||
const overflow = await page.evaluate(() => {
|
||||
const root = document.querySelector('#root');
|
||||
const buttons = [...document.querySelectorAll('button')].filter((node) => node.getBoundingClientRect().width && getComputedStyle(node).visibility !== 'hidden');
|
||||
const clipped = buttons.filter((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.right <= innerWidth + 1 && rect.left >= -1 && rect.bottom <= innerHeight + 1 && rect.top >= -1) return false;
|
||||
for (let parent = node.parentElement; parent && parent !== root; parent = parent.parentElement) {
|
||||
const css = getComputedStyle(parent);
|
||||
if (/(auto|scroll)/.test(css.overflow + css.overflowY + css.overflowX)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { width: innerWidth, height: innerHeight, clipped: clipped.map((node) => node.textContent.trim().slice(0, 60)) };
|
||||
});
|
||||
results.push({ name, surface, ...overflow });
|
||||
}
|
||||
console.log(`Captured ${name}`);
|
||||
};
|
||||
try {
|
||||
console.log('Opening top');
|
||||
await top.goto(`${base}/?display=top&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
|
||||
console.log('Top DOM ready');
|
||||
await top.getByLabel('Username', { exact: true }).waitFor();
|
||||
await bottom.goto(`${base}/?display=bottom&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
|
||||
await bottom.locator('.companion-panel').waitFor();
|
||||
await capture('01-login');
|
||||
await top.getByRole('button', { name: /Play offline/ }).click();
|
||||
await top.locator('.character-select-screen').waitFor();
|
||||
await capture('02-roster-empty');
|
||||
await top.getByRole('button', { name: /Create character/ }).click();
|
||||
await top.locator('.character-create-screen').waitFor();
|
||||
await capture('03-character-create');
|
||||
await top.getByRole('button', { name: /Customize & name/ }).tap();
|
||||
await top.getByLabel('Character name').fill('Thortester');
|
||||
await capture('03b-appearance');
|
||||
await top.getByRole('button', { name: /Create character/ }).tap();
|
||||
await top.locator('.character-select-screen').waitFor();
|
||||
await capture('04-roster');
|
||||
await top.bringToFront();
|
||||
await top.waitForTimeout(2000);
|
||||
await capture('04b-roster-rendered');
|
||||
await top.getByRole('button', { name: /Continue|Enter world/ }).first().click();
|
||||
await top.locator('.main-menu-screen').waitFor();
|
||||
await capture('05-main-menu');
|
||||
await top.getByRole('button', { name: /Class Hall/ }).tap();
|
||||
await top.locator('.class-hall-screen').waitFor();
|
||||
await capture('05b-class-hall');
|
||||
await top.locator('.class-hall-header button').tap();
|
||||
for (const name of ['Talents', 'Keybinds', 'Button assignments']) {
|
||||
await top.getByRole('button', { name: new RegExp(name) }).tap();
|
||||
await top.locator('.main-menu-character-editor').waitFor();
|
||||
await top.locator('.main-menu-character-editor .game-panel').waitFor();
|
||||
await capture(`05-editor-${name.toLowerCase().replaceAll(' ', '-')}`);
|
||||
await top.getByRole('button', { name: 'Back to main menu', exact: true }).tap();
|
||||
}
|
||||
await top.getByRole('button', { name: /View Reports/ }).tap();
|
||||
await top.getByRole('heading', { name: 'View Reports' }).waitFor();
|
||||
await capture('05c-reports');
|
||||
await top.getByRole('button', { name: /Back/ }).first().tap();
|
||||
await top.getByRole('button', { name: /Downloads/ }).tap();
|
||||
await top.locator('.content-manager-screen').waitFor();
|
||||
await capture('05d-downloads');
|
||||
await top.getByRole('button', { name: 'Back', exact: true }).tap();
|
||||
await top.getByRole('button', { name: /^Dungeons/ }).click();
|
||||
await top.locator('.dungeon-select-screen').waitFor();
|
||||
await capture('06-dungeon-picker');
|
||||
await top.getByRole('button', { name: /Choose role/ }).click();
|
||||
await capture('07-role-picker');
|
||||
await top.locator('.dungeon-role-option').first().click();
|
||||
await top.locator('.game-shell').waitFor();
|
||||
await bottom.locator('.companion-hud-shell').waitFor();
|
||||
await top.waitForFunction(async () => { const { useGameStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/store.ts').name); return ['ready', 'proxy', 'error'].includes(useGameStore.getState().assetStatus); }, null, { timeout: 120000 });
|
||||
await top.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
|
||||
await top.waitForTimeout(1500);
|
||||
await capture('08-dungeon');
|
||||
assert.equal(await bottom.locator('[data-hud-element="party"]').count(), 0);
|
||||
assert.equal(await top.locator('[data-hud-element="actions"]').count(), 0);
|
||||
assert.equal(await top.locator('[data-hud-element="player"]').count(), 0);
|
||||
assert.equal(await bottom.locator('.action-bar').count(), 1);
|
||||
assert.equal(await bottom.locator('.player-status-panel').count(), 1);
|
||||
await bottom.locator('.aura--inspectable').first().tap();
|
||||
await bottom.getByRole('tooltip').waitFor();
|
||||
await capture('08b-buff-inspection');
|
||||
await bottom.getByRole('button', { name: /^Fade,/ }).tap();
|
||||
await top.waitForFunction(async () => {
|
||||
const { useCombatStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/combatStore.ts').name);
|
||||
return Object.values(useCombatStore.getState().cooldowns).some((endsAt) => endsAt > Date.now());
|
||||
});
|
||||
for (const overlay of ['pause', 'map', 'party', 'boss-guide', 'spellbook', 'talents', 'bindings', 'inventory', 'boss-cards', 'options', 'keybinds', 'report-issue']) {
|
||||
await top.evaluate(async (overlay) => { const { useGameStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/store.ts').name); useGameStore.getState().openOverlay(overlay); }, overlay);
|
||||
await top.waitForTimeout(500);
|
||||
await capture(`09-${overlay}`);
|
||||
}
|
||||
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
|
||||
await top.locator('.ui-layout-editor').waitFor();
|
||||
assert.equal(await top.getByLabel('Party frames screen').inputValue(), 'top');
|
||||
assert.equal(await top.getByLabel('Buffs and debuffs screen').inputValue(), 'bottom');
|
||||
await top.getByLabel('Party frames screen').selectOption('bottom');
|
||||
await bottom.getByRole('button', { name: 'Move Party frames', exact: true }).waitFor();
|
||||
assert.equal(await top.locator('[data-hud-element="party"]').count(), 0);
|
||||
await capture('10-editor-party-bottom');
|
||||
const handle = bottom.getByRole('button', { name: 'Move Party frames', exact: true });
|
||||
const rect = await handle.boundingBox();
|
||||
const cdp = await context.newCDPSession(bottom);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: rect.x + 10, y: rect.y + 10 }] });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: rect.x + 90, y: rect.y + 60 }] });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await top.waitForFunction(async () => {
|
||||
const { useUiLayoutStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/uiLayoutStore.ts').name);
|
||||
return useUiLayoutStore.getState().draft.elements.party.position !== null;
|
||||
});
|
||||
await top.getByLabel('Party frames screen').selectOption('hidden');
|
||||
await bottom.waitForFunction(() => !document.querySelector('[data-hud-element="party"]'));
|
||||
await top.getByLabel('Party frames screen').selectOption('bottom');
|
||||
await top.getByRole('button', { name: 'Cancel edits', exact: true }).tap();
|
||||
await top.locator('.ui-layout-editor').waitFor({ state: 'hidden' });
|
||||
await bottom.waitForFunction(() => !document.querySelector('[data-hud-element="party"]'));
|
||||
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
|
||||
await top.getByLabel('Party frames screen').selectOption('bottom');
|
||||
await capture('11-editor');
|
||||
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
|
||||
await top.waitForFunction(() => !document.querySelector('.ui-layout-editor'));
|
||||
await capture('12-saved');
|
||||
await top.reload({ waitUntil: 'domcontentloaded' });
|
||||
await top.locator('.character-select-screen').waitFor();
|
||||
await top.getByRole('button', { name: /Continue/ }).tap();
|
||||
await top.getByRole('button', { name: /^Dungeons/ }).tap();
|
||||
await top.getByRole('button', { name: /Choose role/ }).tap();
|
||||
await top.locator('.dungeon-role-option').first().tap();
|
||||
await bottom.locator('[data-hud-element="party"] .party-frame').first().waitFor();
|
||||
assert.equal(await top.locator('[data-hud-element="party"]').count(), 0);
|
||||
await bottom.locator('.party-frame--player').tap();
|
||||
await top.waitForFunction(async () => {
|
||||
const { usePartyStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/partyStore.ts').name);
|
||||
return usePartyStore.getState().selfSelected;
|
||||
});
|
||||
const partyBounds = await bottom.locator('.party-frames').boundingBox();
|
||||
const menuBounds = await bottom.locator('.companion-hud-menus').boundingBox();
|
||||
assert.ok(partyBounds.y + partyBounds.height <= menuBounds.y, 'Relocated party frames must fit above the bottom navigation');
|
||||
await capture('13-persisted-party-bottom');
|
||||
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
|
||||
await top.getByRole('button', { name: 'Reset all elements', exact: true }).tap();
|
||||
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
|
||||
await capture('14-final-defaults');
|
||||
await top.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
|
||||
await top.evaluate(async () => {
|
||||
const load = (pathname) => import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === pathname).name);
|
||||
const { useGameStore } = await load('/src/game/store.ts');
|
||||
const { useCombatStore } = await load('/src/game/combatStore.ts');
|
||||
useGameStore.setState({ paused: true, overlay: null });
|
||||
useCombatStore.setState({ health: 0 });
|
||||
});
|
||||
await top.locator('.defeated-prompt').waitFor();
|
||||
assert.equal(await bottom.locator('.defeated-prompt').count(), 0);
|
||||
await capture('14b-defeated');
|
||||
await top.getByRole('button', { name: 'Respawn at entrance', exact: true }).tap();
|
||||
await top.locator('.defeated-prompt').waitFor({ state: 'hidden' });
|
||||
// Isolated offline presentation fixture: exercise the completed-run UI without a live boss clear.
|
||||
await top.evaluate(async () => {
|
||||
const load = (pathname) => import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === pathname).name);
|
||||
const { useGameStore } = await load('/src/game/store.ts');
|
||||
const { useDungeonRunStore } = await load('/src/game/dungeonRunStore.ts');
|
||||
const { usePartyStore } = await load('/src/game/partyStore.ts');
|
||||
const { useCombatStore } = await load('/src/game/combatStore.ts');
|
||||
const progress = useDungeonRunStore.getState().progress;
|
||||
const now = performance.now();
|
||||
const party = [{ name: 'Thortester', classId: useCombatStore.getState().classId, level: 1, role: 'healer', npc: false },
|
||||
...usePartyStore.getState().members.map((member) => ({ name: member.name, classId: member.classId, level: member.level, role: member.role, npc: true }))];
|
||||
useGameStore.setState({ paused: true, overlay: null });
|
||||
useDungeonRunStore.setState({ progress: { ...progress, startedAt: now - 60000, finishedAt: now, durationMs: 60000, party } });
|
||||
});
|
||||
await top.locator('.dungeon-complete-banner').waitFor();
|
||||
assert.equal(await bottom.locator('.dungeon-complete-banner').count(), 0);
|
||||
await capture('14c-complete');
|
||||
await top.locator('.dungeon-results').waitFor();
|
||||
await capture('14d-results');
|
||||
await top.getByRole('button', { name: /Stay and loot/ }).tap();
|
||||
await top.getByRole('button', { name: 'Run results', exact: true }).tap();
|
||||
await top.locator('.dungeon-results').waitFor();
|
||||
await top.getByRole('button', { name: /Stay and loot/ }).tap();
|
||||
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
|
||||
await top.getByLabel('Dungeon completion screen').selectOption('bottom');
|
||||
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
|
||||
await bottom.locator('.dungeon-complete-banner').waitFor();
|
||||
assert.equal(await top.locator('.dungeon-complete-banner').count(), 0);
|
||||
await capture('14e-complete-bottom');
|
||||
await bottom.getByRole('button', { name: 'Finish dungeon', exact: true }).tap();
|
||||
await top.locator('.main-menu-screen').waitFor();
|
||||
await top.evaluate(async () => {
|
||||
const { useUiLayoutStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/uiLayoutStore.ts').name);
|
||||
const state = useUiLayoutStore.getState(); state.begin(); state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, completion: { visible: true, position: null, width: null } } })); state.finish(true);
|
||||
});
|
||||
const previewCdp = await context.newCDPSession(top);
|
||||
await top.setViewportSize({ width: 1200, height: 1100 });
|
||||
await previewCdp.send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 1100, deviceScaleFactor: 1, mobile: false });
|
||||
await top.goto(`${base}/?layout=thor-preview`, { waitUntil: 'domcontentloaded' });
|
||||
await top.getByRole('button', { name: /Continue/ }).first().tap();
|
||||
await top.locator('.thor-preview-top').getByRole('button', { name: /^Dungeons/ }).tap();
|
||||
await top.locator('.thor-preview-top').getByRole('button', { name: /Choose role/ }).tap();
|
||||
await top.locator('.thor-preview-top .dungeon-role-option').first().tap();
|
||||
await top.locator('.thor-preview-top .loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
|
||||
await top.waitForTimeout(1000);
|
||||
assert.equal((await top.locator('.thor-preview-top').boundingBox()).width, 837);
|
||||
assert.equal((await top.locator('.thor-preview-bottom').boundingBox()).width, 473);
|
||||
assert.equal(await top.locator('.thor-preview-bottom [data-hud-element="party"]').count(), 0);
|
||||
assert.equal(await top.locator('.thor-preview-top .action-bar').count(), 0);
|
||||
await top.screenshot({ path: path.join(directory, '15-combined-preview.png'), fullPage: true });
|
||||
results.push({ name: '15-combined-preview', surface: 'both', clipped: [] });
|
||||
assert.deepEqual(results.filter((result) => result.clipped.length), [], 'Controls must fit each screen or an intentional scroll region');
|
||||
assert.equal(errors.length, 0, errors.join('\n'));
|
||||
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ results, errors }, null, 2)}\n`);
|
||||
} finally {
|
||||
console.log('Browser errors', errors);
|
||||
await top.screenshot({ path: path.join(directory, 'last-top.png') });
|
||||
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ results, errors }, null, 2)}\n`);
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -19,11 +19,22 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
revision INTEGER NOT NULL DEFAULT 0
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status);
|
||||
CREATE TABLE IF NOT EXISTS boss_testing_reviews (
|
||||
boss_key TEXT PRIMARY KEY,
|
||||
dungeon_id TEXT NOT NULL,
|
||||
boss_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')),
|
||||
reviewed_by TEXT NOT NULL,
|
||||
reviewed_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
`);
|
||||
const columns = new Set(database.prepare("PRAGMA table_info(dungeon_reports)").all().map((column) => column.name));
|
||||
if (!columns.has("boss_key")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_key TEXT");
|
||||
if (!columns.has("boss_name")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_name TEXT");
|
||||
const insert = database.prepare(`
|
||||
INSERT INTO dungeon_reports (
|
||||
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?)
|
||||
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at, boss_key, boss_name
|
||||
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?");
|
||||
const list = database.prepare(`
|
||||
@@ -45,6 +56,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
function validateStatus(status) {
|
||||
if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status");
|
||||
}
|
||||
function validateBoss(input) {
|
||||
validateDungeon(input?.dungeonId);
|
||||
if (typeof input.bossKey !== "string" || !input.bossKey.startsWith(`${input.dungeonId}/`)
|
||||
|| input.bossKey.length <= input.dungeonId.length + 1 || input.bossKey.length > 256
|
||||
|| typeof input.bossName !== "string" || !input.bossName.trim() || input.bossName.length > 128) {
|
||||
throw new ErrorType("Choose a valid boss.", 400, "invalid_boss");
|
||||
}
|
||||
}
|
||||
function publicReport(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -56,10 +75,30 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
updatedAt: Number(row.updated_at),
|
||||
updatedBy: row.updated_by_username,
|
||||
revision: Number(row.revision),
|
||||
...(row.boss_key ? { bossKey: row.boss_key, bossName: row.boss_name } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
listBossTestingReviews() {
|
||||
return { reviews: database.prepare("SELECT * FROM boss_testing_reviews ORDER BY boss_key").all().map((row) => ({
|
||||
bossKey: row.boss_key, dungeonId: row.dungeon_id, bossName: row.boss_name,
|
||||
status: row.status, reviewedBy: row.reviewed_by, reviewedAt: Number(row.reviewed_at),
|
||||
})) };
|
||||
},
|
||||
saveBossTestingReview(account, input) {
|
||||
validateBoss(input);
|
||||
if (!["ready", "needs-testing", "not-ready"].includes(input.status)) {
|
||||
throw new ErrorType("Choose a valid boss readiness status.", 400, "invalid_boss_status");
|
||||
}
|
||||
const review = { bossKey: input.bossKey, dungeonId: input.dungeonId, bossName: input.bossName.trim(),
|
||||
status: input.status, reviewedBy: account?.username ?? "Guest player", reviewedAt: Date.now() };
|
||||
database.prepare(`INSERT INTO boss_testing_reviews VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(boss_key) DO UPDATE SET boss_name = excluded.boss_name, status = excluded.status,
|
||||
reviewed_by = excluded.reviewed_by, reviewed_at = excluded.reviewed_at`).run(
|
||||
review.bossKey, review.dungeonId, review.bossName, review.status, review.reviewedBy, review.reviewedAt);
|
||||
return review;
|
||||
},
|
||||
listDungeonReports({ status = null, dungeonId = null } = {}) {
|
||||
if (status !== null) validateStatus(status);
|
||||
if (dungeonId !== null) validateDungeon(dungeonId);
|
||||
@@ -70,13 +109,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
},
|
||||
submitDungeonReport(account, input) {
|
||||
validateDungeon(input?.dungeonId);
|
||||
if (input?.bossKey !== undefined || input?.bossName !== undefined) validateBoss(input);
|
||||
if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) {
|
||||
throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment");
|
||||
}
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null,
|
||||
account?.username ?? "Guest player", now, now);
|
||||
account?.username ?? "Guest player", now, now, input.bossKey ?? null, input.bossName?.trim() ?? null);
|
||||
return publicReport(byId.get(id));
|
||||
},
|
||||
updateDungeonReport(account, id, input) {
|
||||
|
||||
@@ -69,6 +69,30 @@ test("persists reports and status changes across database restarts", async () =>
|
||||
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("persists boss readiness and structured boss reports without changing ordinary reports", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-boss-feedback-"));
|
||||
let database;
|
||||
try {
|
||||
database = openGameDatabase({ dataDir: directory });
|
||||
const boss = { dungeonId: "wailing-caverns", bossKey: "wailing-caverns/lady-anacondra", bossName: "Lady Anacondra" };
|
||||
for (const status of ["ready", "needs-testing", "not-ready"]) {
|
||||
assert.equal(database.saveBossTestingReview(null, { ...boss, status }).status, status);
|
||||
}
|
||||
assert.throws(() => database.saveBossTestingReview(null, { ...boss, status: "Closed" }), { code: "invalid_boss_status" });
|
||||
assert.throws(() => database.saveBossTestingReview(null, { ...boss, bossKey: "other/boss", status: "ready" }), { code: "invalid_boss" });
|
||||
const report = database.submitDungeonReport(null, { ...boss, comment: "x".repeat(500) });
|
||||
assert.equal(report.bossKey, boss.bossKey);
|
||||
assert.equal(report.bossName, boss.bossName);
|
||||
const reviews = database.listBossTestingReviews();
|
||||
database.close();
|
||||
database = openGameDatabase({ dataDir: directory });
|
||||
assert.deepEqual(database.listBossTestingReviews(), reviews);
|
||||
assert.deepEqual(database.listDungeonReports().reports, [report]);
|
||||
const ordinary = database.submitDungeonReport(null, { dungeonId: boss.dungeonId, comment: "Door issue" });
|
||||
assert.equal(ordinary.bossKey, undefined);
|
||||
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("serves public reports and GM-only status API with shelved modes disabled", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
|
||||
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
|
||||
@@ -80,6 +104,11 @@ test("serves public reports and GM-only status API with shelved modes disabled",
|
||||
});
|
||||
try {
|
||||
const player = await runtime.database.register("ApiPlayer", "report-player");
|
||||
const boss = { dungeonId: "wailing-caverns", bossKey: "wailing-caverns/lady-anacondra", bossName: "Lady Anacondra", status: "ready" };
|
||||
const saved = await fetch(`${origin}/api/boss-testing/reviews`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(boss) });
|
||||
assert.equal(saved.status, 200);
|
||||
const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json();
|
||||
assert.equal(labels.reviews[0].status, "ready");
|
||||
const gm = await runtime.database.register("Phenom", "report-admin");
|
||||
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
|
||||
assert.equal(created.status, 201);
|
||||
|
||||
@@ -261,6 +261,19 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/boss-testing/reviews" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listBossTestingReviews());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/boss-testing/reviews" && request.method === "POST") {
|
||||
const token = bearerToken(request);
|
||||
const account = token ? gameDatabase.authenticate(token) : null;
|
||||
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
|
||||
throw new GameDatabaseError("Too many updates. Please wait a minute and try again.", 429, "rate_limited");
|
||||
}
|
||||
json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonReports({
|
||||
status: url.searchParams.get("status"),
|
||||
|
||||
+11
-5
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useShellStore } from "./app/shellStore";
|
||||
import "./ui/uiLayout.css";
|
||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||
@@ -7,7 +8,8 @@ import { connectDpsMeter } from "./game/dpsMeterStore";
|
||||
import { useCombatStore } from "./game/combatStore";
|
||||
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
||||
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
||||
import { requireDungeonDefinition } from "./game/dungeonRegistry";
|
||||
import { bossTestingSourceDungeonId, clearBossTestingDungeons, requireDungeonDefinition } from "./game/dungeonRegistry";
|
||||
import { BossTestingBridge } from "./game/BossTestingBridge";
|
||||
import { PartyBridge } from "./game/PartyBridge";
|
||||
import { usePartyStore } from "./game/partyStore";
|
||||
import { useGameStore } from "./game/store";
|
||||
@@ -24,9 +26,7 @@ import { DungeonRunBridge } from "./game/DungeonRunBridge";
|
||||
import { DungeonRunResults } from "./ui/DungeonRunResults";
|
||||
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
||||
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
||||
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
||||
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
|
||||
import { EditableHudElement } from "./ui/EditableHudElement";
|
||||
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
|
||||
import { useUiLayoutStore } from "./game/uiLayoutStore";
|
||||
import { profileInputBindings } from "./game/keybindPreferences";
|
||||
@@ -39,7 +39,7 @@ function InputBridge() {
|
||||
|
||||
useEffect(() => installInput({
|
||||
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""),
|
||||
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
|
||||
getBindings: (device) => {
|
||||
const preferences = useUiLayoutStore.getState().preferences;
|
||||
const bindings = profileInputBindings(preferences, device);
|
||||
@@ -103,6 +103,12 @@ export default function GameRuntime() {
|
||||
clearDungeonRuntimeCaches();
|
||||
releaseDungeonGltfCache(activeDungeon);
|
||||
releaseShelvedGameRuntime();
|
||||
const sourceDungeonId = bossTestingSourceDungeonId(activeDungeon.id);
|
||||
if (sourceDungeonId) {
|
||||
useGameStore.setState({ activeDungeonId: sourceDungeonId, bossTest: null, gmTestMode: false });
|
||||
useShellStore.setState({ activeDungeonId: sourceDungeonId });
|
||||
}
|
||||
clearBossTestingDungeons();
|
||||
}, []);
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
@@ -118,10 +124,10 @@ export default function GameRuntime() {
|
||||
<CombatBridge />
|
||||
<PartyBridge />
|
||||
<DungeonRunBridge />
|
||||
<BossTestingBridge />
|
||||
<ShelvedGameBridges />
|
||||
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
||||
<Hud />
|
||||
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
|
||||
<MapPanel />
|
||||
<GameplayPanels />
|
||||
<PauseMenu />
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
createLocalAccount,
|
||||
createOfflineSession,
|
||||
listCharacters,
|
||||
loadAccountBossCards,
|
||||
saveAccountBossCards,
|
||||
replaceCharactersForOwner,
|
||||
deleteCharacter,
|
||||
loadCombatPresentationSettings,
|
||||
loginLocalAccount,
|
||||
restoreSession,
|
||||
@@ -12,7 +16,6 @@ import {
|
||||
updateCharacterEquipment,
|
||||
updateCharacterDungeonRunCheckpoint,
|
||||
updateCharacterInventory,
|
||||
updateCharacterBossCards,
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
@@ -213,7 +216,7 @@ describe("local profile repository", () => {
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
@@ -382,18 +385,58 @@ describe("local profile repository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists card copies and claim receipts per character across roster reloads", () => {
|
||||
it("persists account card copies independently of character creation and deletion", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
|
||||
const character = createCharacter(draft, storage).character!;
|
||||
const other = createCharacter({ ...draft, name: "Other" }, storage).character!;
|
||||
const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } };
|
||||
expect(updateCharacterBossCards(offline.ownerId, character.id, bossCards, storage)?.bossCards).toEqual(bossCards);
|
||||
expect(saveAccountBossCards(offline.ownerId, bossCards, storage)).toEqual(bossCards);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
expect(loadAccountBossCards("different-account", storage)).toEqual({ counts: {}, lastClaims: {} });
|
||||
const roster = listCharacters(offline.ownerId, storage);
|
||||
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards);
|
||||
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} });
|
||||
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined();
|
||||
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined();
|
||||
expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]);
|
||||
deleteCharacter(offline.ownerId, character.id, storage);
|
||||
deleteCharacter(offline.ownerId, other.id, storage);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
createCharacter({ ...draft, name: "Newcomer" }, storage);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
});
|
||||
|
||||
it("migrates and sums all legacy character logs once while retaining the newest receipts", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const draft = { ownerId: "one", name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
|
||||
const first = createCharacter(draft, storage).character!;
|
||||
const second = createCharacter({ ...draft, name: "Other" }, storage).character!;
|
||||
const separate = createCharacter({ ...draft, ownerId: "two" }, storage).character!;
|
||||
const legacyCharacters = [
|
||||
{ ...first, bossCards: { counts: { boss: { normal: 7, epic: 1 } }, lastClaims: { spawn: "100:1:spawn" } } },
|
||||
{ ...second, bossCards: { counts: { boss: { normal: 3, rare: 2 } }, lastClaims: { spawn: "200:2:spawn" } } },
|
||||
];
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version: 6, accounts: [], rosters: { one: legacyCharacters, two: [{ ...separate, bossCards: { counts: { boss: { normal: 4 } }, lastClaims: {} } }] },
|
||||
}));
|
||||
const expected = { counts: { boss: { normal: 10, rare: 2, epic: 1 } }, lastClaims: { spawn: "200:2:spawn" } };
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
|
||||
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
|
||||
expect(database.version).toBe(7);
|
||||
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
|
||||
// An old cloud roster can be downloaded again without repeating the migration.
|
||||
replaceCharactersForOwner("one", legacyCharacters, storage);
|
||||
replaceCharactersForOwner("one", legacyCharacters, storage);
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("restores account logs from cloud without overwriting higher cached counts", () => {
|
||||
const storage = new MemoryStorage();
|
||||
saveAccountBossCards("one", { counts: { boss: { normal: 8 } }, lastClaims: { spawn: "200:2:spawn" } }, storage);
|
||||
replaceCharactersForOwner("one", [], storage, { counts: { boss: { normal: 7, rare: 1 } }, lastClaims: { spawn: "100:1:spawn" } });
|
||||
expect(loadAccountBossCards("one", storage)).toEqual({ counts: { boss: { normal: 8, rare: 1 } }, lastClaims: { spawn: "200:2:spawn" } });
|
||||
});
|
||||
|
||||
it("persists threat-meter visibility and position per character", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import { normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||
import {
|
||||
contentCategoryForClass,
|
||||
@@ -62,9 +62,10 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 6;
|
||||
version: 7;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
bossCardsByOwner: Record<string, BossCardCollection>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
uiPreferencesByOwner?: Record<string, UiPreferences>;
|
||||
}
|
||||
@@ -105,7 +106,7 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -121,6 +122,9 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
}
|
||||
|
||||
function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
const profile = { ...value };
|
||||
// Cards now belong to the owner; legacy fields are consumed before normalization.
|
||||
delete profile.bossCards;
|
||||
const legacy = value as CharacterProfile & {
|
||||
actionBindings?: unknown;
|
||||
experience?: unknown;
|
||||
@@ -180,7 +184,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
: undefined;
|
||||
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
|
||||
return {
|
||||
...value,
|
||||
...profile,
|
||||
level,
|
||||
experience,
|
||||
talentRanks,
|
||||
@@ -191,7 +195,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
actionLoadouts,
|
||||
inventory,
|
||||
equipment,
|
||||
bossCards: normalizeBossCardCollection(value.bossCards),
|
||||
manastormProgress,
|
||||
dungeonRunCheckpoint,
|
||||
settings,
|
||||
@@ -205,10 +208,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
version?: number;
|
||||
accounts?: unknown;
|
||||
rosters?: unknown;
|
||||
bossCardsByOwner?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
uiPreferencesByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
}
|
||||
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
||||
@@ -220,12 +224,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
&& typeof account.verifier.salt === "string"
|
||||
&& typeof account.verifier.digest === "string",
|
||||
));
|
||||
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => [
|
||||
ownerId,
|
||||
Array.isArray(roster)
|
||||
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS)
|
||||
: [],
|
||||
]));
|
||||
const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner)
|
||||
? parsed.bossCardsByOwner : {};
|
||||
const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
|
||||
let cardsMigrated = false;
|
||||
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => {
|
||||
const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
|
||||
if (characters.some((character) => character.bossCards !== undefined)) {
|
||||
const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum");
|
||||
bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]);
|
||||
cardsMigrated = true;
|
||||
}
|
||||
return [ownerId, characters.map(normalizeCharacterProfile)];
|
||||
}));
|
||||
const rawPresentationSettings = parsed.presentationSettingsByOwner
|
||||
&& typeof parsed.presentationSettingsByOwner === "object"
|
||||
&& !Array.isArray(parsed.presentationSettingsByOwner)
|
||||
@@ -259,8 +270,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
);
|
||||
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
|
||||
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
|
||||
const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
@@ -270,7 +281,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
|
||||
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
|
||||
if (!storage || !rosterChangeListener) return;
|
||||
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.bossCardsByOwner), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -452,15 +463,19 @@ export function replaceCharactersForOwner(
|
||||
ownerId: string,
|
||||
characters: unknown,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
accountBossCards?: unknown,
|
||||
): CharacterProfile[] {
|
||||
if (!storage) return [];
|
||||
const database = readDatabase(storage);
|
||||
database.rosters[ownerId] = Array.isArray(characters)
|
||||
? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
}))
|
||||
: [];
|
||||
const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
|
||||
const importedCards = accountBossCards === undefined
|
||||
? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
|
||||
: normalizeBossCardCollection(accountBossCards);
|
||||
database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
|
||||
database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
}));
|
||||
writeDatabase(database, storage);
|
||||
return listCharacters(ownerId, storage);
|
||||
}
|
||||
@@ -730,22 +745,24 @@ export function updateCharacterEquipment(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateCharacterBossCards(
|
||||
export function loadAccountBossCards(
|
||||
ownerId: string,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): BossCardCollection {
|
||||
return normalizeBossCardCollection(readDatabase(storage).bossCardsByOwner[ownerId]);
|
||||
}
|
||||
|
||||
export function saveAccountBossCards(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
bossCards: BossCardCollection,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
): BossCardCollection {
|
||||
const normalized = normalizeBossCardCollection(bossCards);
|
||||
if (!storage) return normalized;
|
||||
const database = readDatabase(storage);
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
updated = normalizeCharacterProfile({ ...character, bossCards });
|
||||
return updated;
|
||||
});
|
||||
if (updated) writeDatabase(database, storage);
|
||||
return updated;
|
||||
database.bossCardsByOwner[ownerId] = normalized;
|
||||
writeDatabase(database, storage);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function updateCharacterSettings(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PlayerSession } from "./types";
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value); },
|
||||
removeItem: (key: string) => { values.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
const draft = {
|
||||
ownerId: "account-one", name: "Collector", raceId: "human" as const,
|
||||
classId: "priest" as const, specializationId: "priest:holy", gender: "female" as const,
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
};
|
||||
const session: PlayerSession = { kind: "account", ownerId: draft.ownerId, displayName: "Collector", accessToken: "test-token" };
|
||||
const cards = { counts: { "wow:lady anacondra": { normal: 7, rare: 1 } }, lastClaims: { spawn: "200:1:spawn" } };
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe("account boss card integration", () => {
|
||||
it("keeps earned cards when switching characters and isolates account sign-in", async () => {
|
||||
vi.stubGlobal("window", { localStorage: storage(), sessionStorage: storage() });
|
||||
const repository = await import("./accountRepository");
|
||||
const { useShellStore } = await import("./shellStore");
|
||||
const { useCombatStore } = await import("../game/combatStore");
|
||||
const first = repository.createCharacter(draft).character!;
|
||||
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
|
||||
const offlineSession: PlayerSession = { ...session, accessToken: undefined };
|
||||
const shell = useShellStore.getState();
|
||||
shell.beginSession(offlineSession);
|
||||
useShellStore.setState({ phase: "game", activeCharacter: first });
|
||||
shell.saveAccountBossCards(cards);
|
||||
shell.returnToCharacters();
|
||||
shell.selectCharacter(second.id);
|
||||
expect(useShellStore.getState().bossCards).toEqual(cards);
|
||||
useCombatStore.getState().initializeCharacter({ ...second, bossCards: useShellStore.getState().bossCards });
|
||||
useCombatStore.getState().collectSharedBossCards({ bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "300:1:spawn", rarities: ["normal"] }, "Lady Anacondra", 300);
|
||||
shell.saveAccountBossCards(useCombatStore.getState().bossCards);
|
||||
shell.selectCharacter(first.id);
|
||||
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
|
||||
expect(repository.listCharacters(session.ownerId).every((character) => character.bossCards === undefined)).toBe(true);
|
||||
shell.signOut();
|
||||
expect(useShellStore.getState().bossCards.counts).toEqual({});
|
||||
shell.beginSession({ ...offlineSession, ownerId: "account-two" });
|
||||
expect(useShellStore.getState().bossCards.counts).toEqual({});
|
||||
shell.beginSession(offlineSession);
|
||||
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
|
||||
});
|
||||
|
||||
it("uploads the account collection without characters and restores it on another device", async () => {
|
||||
const firstDevice = storage();
|
||||
const secondDevice = storage();
|
||||
vi.stubGlobal("window", { localStorage: firstDevice });
|
||||
let cloudData: unknown = { version: 1, characters: [] };
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const repository = await import("./accountRepository");
|
||||
const client = await import("./onlineAccountClient");
|
||||
client.activateOnlineSession(session);
|
||||
repository.saveAccountBossCards(session.ownerId, cards);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(cloudData).toMatchObject({ version: 2, characters: [], bossCards: cards });
|
||||
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
|
||||
vi.stubGlobal("window", { localStorage: secondDevice });
|
||||
await client.hydrateOnlineSession(session);
|
||||
expect(repository.loadAccountBossCards(session.ownerId)).toEqual(cards);
|
||||
expect(repository.loadAccountBossCards("different-account").counts).toEqual({});
|
||||
const { collectBossCardDrop } = await import("../game/bossCards");
|
||||
const restored = repository.loadAccountBossCards(session.ownerId);
|
||||
expect(collectBossCardDrop(restored, { bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "200:1:spawn", rarities: ["normal", "rare"] })).toBe(restored);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
|
||||
it("publishes legacy cloud migration once and preserves it through repeated downloads", async () => {
|
||||
vi.stubGlobal("window", { localStorage: storage() });
|
||||
const repository = await import("./accountRepository");
|
||||
const first = repository.createCharacter(draft).character!;
|
||||
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
|
||||
let cloudData: unknown = { version: 1, characters: [
|
||||
{ ...first, bossCards: cards },
|
||||
{ ...second, bossCards: { counts: { "wow:lady anacondra": { normal: 3 } }, lastClaims: {} } },
|
||||
] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
|
||||
}));
|
||||
const client = await import("./onlineAccountClient");
|
||||
await client.hydrateOnlineSession(session);
|
||||
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(cloudData).toMatchObject({ version: 2, bossCards: { counts: { "wow:lady anacondra": { normal: 10, rare: 1 } } } });
|
||||
expect((cloudData as { characters: { bossCards?: unknown }[] }).characters.every((character) => character.bossCards === undefined)).toBe(true);
|
||||
await client.hydrateOnlineSession(session);
|
||||
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useBossTestingReviews } from "./bossTestingReviews";
|
||||
import { submitDungeonReport } from "./dungeonReports";
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); useBossTestingReviews.setState({ reviews: {} }); });
|
||||
|
||||
describe("boss feedback", () => {
|
||||
const boss = { key: "wailing-caverns/lady-anacondra", sourceDungeonId: "wailing-caverns", name: "Lady Anacondra" };
|
||||
it("retains offline readiness and syncs it before refreshing server labels", async () => {
|
||||
const storage = { setItem: vi.fn() };
|
||||
vi.stubGlobal("window", { localStorage: storage });
|
||||
const fetch = vi.fn().mockRejectedValueOnce(new Error("offline"));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
expect(await useBossTestingReviews.getState().save(boss, "not-ready", null)).toBe(false);
|
||||
expect(useBossTestingReviews.getState().reviews[boss.key]).toMatchObject({ status: "not-ready", pending: true });
|
||||
expect(storage.setItem).toHaveBeenCalled();
|
||||
const review = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status: "not-ready", reviewedAt: 1, reviewedBy: "Guest player" };
|
||||
fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ review }) }).mockResolvedValueOnce({ ok: true, json: async () => ({ reviews: [review] }) });
|
||||
await useBossTestingReviews.getState().refresh(null);
|
||||
expect(useBossTestingReviews.getState().reviews[boss.key]).toEqual(review);
|
||||
expect(fetch.mock.calls[1][1].method).toBe("POST");
|
||||
expect(fetch.mock.calls[2][0]).toBe("/api/boss-testing/reviews");
|
||||
});
|
||||
it("submits boss identity as structured metadata without consuming report text", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: {} }) });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await submitDungeonReport(null, boss.sourceDungeonId, "x".repeat(500), { bossKey: boss.key, bossName: boss.name });
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ dungeonId: boss.sourceDungeonId, comment: "x".repeat(500), bossKey: boss.key, bossName: boss.name });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { create } from "zustand";
|
||||
import type { PlayerSession } from "./types";
|
||||
import { requestOnlineJson } from "./onlineAccountClient";
|
||||
|
||||
export const BOSS_TESTING_STATUSES = ["ready", "needs-testing", "not-ready"] as const;
|
||||
export type BossTestingStatus = (typeof BOSS_TESTING_STATUSES)[number];
|
||||
export const BOSS_TESTING_STATUS_LABELS: Record<BossTestingStatus, string> = {
|
||||
ready: "Ready", "needs-testing": "Needs testing", "not-ready": "Not ready",
|
||||
};
|
||||
export interface BossTestingReview {
|
||||
readonly bossKey: string;
|
||||
readonly dungeonId: string;
|
||||
readonly bossName: string;
|
||||
readonly status: BossTestingStatus;
|
||||
readonly reviewedBy: string;
|
||||
readonly reviewedAt: number;
|
||||
readonly pending?: boolean;
|
||||
}
|
||||
const CACHE_KEY = "healer-man.boss-testing-reviews.v1";
|
||||
function readReviews(): Record<string, BossTestingReview> {
|
||||
try {
|
||||
const reviews = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "{}");
|
||||
if (!reviews || typeof reviews !== "object" || Array.isArray(reviews)) return {};
|
||||
return Object.fromEntries(Object.entries(reviews).filter(([key, value]) => {
|
||||
const review = value as BossTestingReview;
|
||||
return review?.bossKey === key && typeof review.dungeonId === "string" && typeof review.bossName === "string"
|
||||
&& BOSS_TESTING_STATUSES.includes(review.status) && Number.isFinite(review.reviewedAt);
|
||||
})) as Record<string, BossTestingReview>;
|
||||
} catch { return {}; }
|
||||
}
|
||||
function persist(reviews: Record<string, BossTestingReview>) {
|
||||
try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(reviews)); } catch { /* Storage may be disabled. */ }
|
||||
}
|
||||
function sendReview(review: BossTestingReview, session: PlayerSession | null) {
|
||||
return requestOnlineJson<{ review: BossTestingReview }>("/api/boss-testing/reviews", {
|
||||
method: "POST", body: JSON.stringify({ bossKey: review.bossKey, dungeonId: review.dungeonId, bossName: review.bossName, status: review.status }),
|
||||
}, session?.kind === "account" ? session.accessToken : undefined);
|
||||
}
|
||||
interface ReviewState {
|
||||
reviews: Record<string, BossTestingReview>;
|
||||
refresh: (session: PlayerSession | null) => Promise<void>;
|
||||
save: (boss: { readonly key: string; readonly sourceDungeonId: string; readonly name: string }, status: BossTestingStatus,
|
||||
session: PlayerSession | null) => Promise<boolean>;
|
||||
}
|
||||
export const useBossTestingReviews = create<ReviewState>((set, get) => ({
|
||||
reviews: readReviews(),
|
||||
refresh: async (session) => {
|
||||
try {
|
||||
for (const pending of Object.values(get().reviews).filter((review) => review.pending)) {
|
||||
const { review } = await sendReview(pending, session);
|
||||
if (get().reviews[pending.bossKey] !== pending) continue;
|
||||
const reviews = { ...get().reviews, [review.bossKey]: review };
|
||||
persist(reviews); set({ reviews });
|
||||
}
|
||||
const payload = await requestOnlineJson<{ reviews: BossTestingReview[] }>("/api/boss-testing/reviews");
|
||||
const reviews = { ...get().reviews, ...Object.fromEntries(payload.reviews
|
||||
.filter((review) => !get().reviews[review.bossKey]?.pending
|
||||
&& review.reviewedAt >= (get().reviews[review.bossKey]?.reviewedAt ?? 0)).map((review) => [review.bossKey, review])) };
|
||||
persist(reviews); set({ reviews });
|
||||
} catch { /* Keep cached labels and sync pending reviews when the connection returns. */ }
|
||||
},
|
||||
save: async (boss, status, session) => {
|
||||
const pending: BossTestingReview = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status,
|
||||
reviewedAt: Date.now(), reviewedBy: session?.displayName ?? "Guest player", pending: true };
|
||||
let reviews = { ...get().reviews, [boss.key]: pending };
|
||||
persist(reviews); set({ reviews });
|
||||
try {
|
||||
const { review } = await sendReview(pending, session);
|
||||
if (get().reviews[boss.key] === pending) {
|
||||
reviews = { ...get().reviews, [boss.key]: review };
|
||||
persist(reviews); set({ reviews });
|
||||
}
|
||||
return true;
|
||||
} catch { return false; }
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { prepareBossTesting } from "./bossTestingRuntime";
|
||||
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
|
||||
import * as content from "../content/contentManager";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("boss asset preparation", () => {
|
||||
const boss = BOSS_TESTING_CATALOG[0];
|
||||
it("automatically downloads and verifies only the selected boss's dungeon pack", async () => {
|
||||
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValueOnce(false).mockReturnValueOnce(false).mockReturnValue(true);
|
||||
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
|
||||
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
|
||||
await prepareBossTesting(boss);
|
||||
expect(install).toHaveBeenCalledExactlyOnceWith(`dungeon:${boss.dungeonId}`);
|
||||
expect(usable).toHaveBeenCalledWith(boss.dungeonId);
|
||||
});
|
||||
it("reuses installed content and stops entry after failed verification", async () => {
|
||||
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValue(true);
|
||||
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
|
||||
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
|
||||
await prepareBossTesting(boss);
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
usable.mockReturnValue(false);
|
||||
await expect(prepareBossTesting(boss)).rejects.toThrow(/verified/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { defaultPartyRoleForClass, type PartyRole } from "../game/partyRoles";
|
||||
import { createBossTestingRoom, type TestableBoss } from "../game/bossTesting";
|
||||
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { activateDungeonSession } from "../game/dungeonSession";
|
||||
import { dungeonContentIsUsable, initializeContentSystem, installContentPack } from "../content/contentManager";
|
||||
|
||||
let preparationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export async function prepareBossTesting(boss: TestableBoss): Promise<void> {
|
||||
if (dungeonContentIsUsable(boss.dungeonId)) return;
|
||||
const preparation = preparationQueue.then(async () => {
|
||||
await initializeContentSystem();
|
||||
if (!dungeonContentIsUsable(boss.dungeonId)) await installContentPack(`dungeon:${boss.dungeonId}`);
|
||||
if (!dungeonContentIsUsable(boss.dungeonId)) throw new Error("The boss assets could not be verified. Please retry the download.");
|
||||
});
|
||||
preparationQueue = preparation.catch(() => undefined);
|
||||
await preparation;
|
||||
}
|
||||
|
||||
export function enterBossTesting(boss: TestableBoss, requestedRole?: PartyRole): boolean {
|
||||
const shell = useShellStore.getState();
|
||||
const character = shell.characters.find((entry) => entry.id === shell.selectedCharacterId);
|
||||
if (!shell.session || !character) throw new Error("Select a character first.");
|
||||
const specialization = specializationById(character.specializationId);
|
||||
if (character.categoryId !== "rom" && !specialization) throw new Error("Choose a specialization in the Class Hall first.");
|
||||
const { definition, session } = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
|
||||
registerBossTestingDungeon(definition, boss.dungeonId);
|
||||
if (!activateDungeonSession(definition.id, undefined, { gmTestMode: true, bossTest: session })) {
|
||||
clearBossTestingDungeons();
|
||||
throw new Error("The boss testing room could not be started. Check the downloaded assets and retry.");
|
||||
}
|
||||
useShellStore.setState({ phase: "game", activeCharacter: character, activeDungeonId: definition.id,
|
||||
activeDungeonRole: specialization?.role ?? requestedRole ?? defaultPartyRoleForClass(character.classId), notice: "" });
|
||||
return true;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export interface DungeonReport {
|
||||
readonly updatedAt: number;
|
||||
readonly updatedBy: string | null;
|
||||
readonly revision: number;
|
||||
readonly bossKey?: string;
|
||||
readonly bossName?: string;
|
||||
}
|
||||
|
||||
export interface DungeonReportSnapshot {
|
||||
@@ -40,10 +42,11 @@ export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
|
||||
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
|
||||
}
|
||||
|
||||
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
|
||||
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string,
|
||||
boss?: { readonly bossKey: string; readonly bossName: string }): Promise<DungeonReport> {
|
||||
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dungeonId, comment }),
|
||||
body: JSON.stringify({ dungeonId, comment, ...boss }),
|
||||
}, session?.kind === "account" ? session.accessToken : undefined);
|
||||
return response.report;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDefaultAppearance } from "./characterCatalog";
|
||||
import {
|
||||
createCharacter,
|
||||
listCharacters,
|
||||
updateCharacterProgression,
|
||||
type StorageLike,
|
||||
} from "./accountRepository";
|
||||
import { beginMainMenuCharacterEditing } from "./mainMenuCharacterEditing";
|
||||
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { talentNodesForTree } from "../game/talentCatalog";
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
values = new Map<string, string>();
|
||||
getItem(key: string) { return this.values.get(key) ?? null; }
|
||||
setItem(key: string, value: string) { this.values.set(key, value); }
|
||||
removeItem(key: string) { this.values.delete(key); }
|
||||
}
|
||||
|
||||
describe("main-menu character editing", () => {
|
||||
let storage: MemoryStorage;
|
||||
let stop: (() => void) | null;
|
||||
const holyTalent = talentNodesForTree("priest-holy")[0];
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage();
|
||||
stop = null;
|
||||
const created = createCharacter({
|
||||
ownerId: "offline-roster", name: "Menuhealer", classId: "priest",
|
||||
raceId: "human", gender: "female", appearance: createDefaultAppearance(),
|
||||
specializationId: "priest:holy",
|
||||
}, storage).character!;
|
||||
const character = updateCharacterProgression(created.ownerId, created.id, {
|
||||
level: 17, experience: 123, talentRanks: {},
|
||||
}, storage)!;
|
||||
useShellStore.setState({
|
||||
phase: "main-menu", activeCharacter: null, selectedCharacterId: character.id,
|
||||
session: { kind: "offline", ownerId: character.ownerId, displayName: "Offline" },
|
||||
characters: [character], notice: "",
|
||||
});
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||
});
|
||||
afterEach(() => { stop?.(); });
|
||||
|
||||
it("loads the selected character without rewriting their saved profile or starting a dungeon", () => {
|
||||
const before = [...storage.values];
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(stop).not.toBeNull();
|
||||
expect(useCombatStore.getState()).toMatchObject({ classId: "priest", level: 17, xp: 123 });
|
||||
expect([...storage.values]).toEqual(before);
|
||||
expect(useShellStore.getState()).toMatchObject({ phase: "main-menu", activeCharacter: null });
|
||||
});
|
||||
|
||||
it("persists talent allocation and reset, and reloads the saved build when reopened", () => {
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().allocateTalent(holyTalent.id)).toBe(true);
|
||||
expect(listCharacters("offline-roster", storage)[0].talentRanks[holyTalent.id]).toBe(1);
|
||||
expect(selectedCharacter(useShellStore.getState())?.talentRanks[holyTalent.id]).toBe(1);
|
||||
stop!();
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().talentRanks[holyTalent.id]).toBe(1);
|
||||
useCombatStore.getState().resetTalents();
|
||||
expect(listCharacters("offline-roster", storage)[0].talentRanks).toEqual({});
|
||||
expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 17, experience: 123 });
|
||||
});
|
||||
|
||||
it("persists controller assignments and reloads them without touching progression or equipment", () => {
|
||||
const profile = selectedCharacter(useShellStore.getState())!;
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal")).toBe(true);
|
||||
const saved = listCharacters("offline-roster", storage)[0];
|
||||
expect(saved.actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
|
||||
expect(saved).toMatchObject({ level: profile.level, experience: profile.experience,
|
||||
talentRanks: profile.talentRanks, inventory: profile.inventory, equipment: profile.equipment });
|
||||
stop!();
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
|
||||
});
|
||||
|
||||
it.each(["phase", "owner", "selection", "closed"])("stops saving after the editing context changes: %s", (change) => {
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
const before = [...storage.values];
|
||||
if (change === "phase") useShellStore.setState({ phase: "game" });
|
||||
if (change === "owner") useShellStore.setState({ session: { kind: "offline", ownerId: "another-owner", displayName: "Other" } });
|
||||
if (change === "selection") useShellStore.setState({ selectedCharacterId: null });
|
||||
if (change === "closed") stop!();
|
||||
useCombatStore.getState().allocateTalent(holyTalent.id);
|
||||
useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal");
|
||||
expect([...storage.values]).toEqual(before);
|
||||
});
|
||||
|
||||
it("never saves combat XP as a side effect of a talent edit", () => {
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
useCombatStore.setState({ xp: 999 });
|
||||
useCombatStore.getState().allocateTalent(holyTalent.id);
|
||||
expect(listCharacters("offline-roster", storage)[0].experience).toBe(123);
|
||||
});
|
||||
|
||||
it("rejects editing without a selected, owned main-menu character", () => {
|
||||
useShellStore.setState({ selectedCharacterId: null });
|
||||
expect(beginMainMenuCharacterEditing(storage)).toBeNull();
|
||||
expect(useCombatStore.getState().classId).toBe("mage");
|
||||
});
|
||||
|
||||
it("reports storage failures instead of updating the roster as though the save succeeded", () => {
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
storage.setItem = () => { throw new Error("Storage full"); };
|
||||
useCombatStore.getState().allocateTalent(holyTalent.id);
|
||||
expect(useShellStore.getState().notice).toMatch(/could not save/i);
|
||||
expect(selectedCharacter(useShellStore.getState())?.talentRanks).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
updateCharacterActionBindings,
|
||||
updateCharacterProgression,
|
||||
type StorageLike,
|
||||
} from "./accountRepository";
|
||||
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
|
||||
// Reuse the character editors without mounting the dungeon's simulation bridge.
|
||||
export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => void) | null {
|
||||
const shell = useShellStore.getState();
|
||||
const character = selectedCharacter(shell);
|
||||
if (shell.phase !== "main-menu" || !shell.session || !character
|
||||
|| character.ownerId !== shell.session.ownerId) return null;
|
||||
|
||||
const ownerId = shell.session.ownerId;
|
||||
const characterId = character.id;
|
||||
useCombatStore.getState().initializeCharacter(character);
|
||||
|
||||
return useCombatStore.subscribe((combat, previous) => {
|
||||
const current = useShellStore.getState();
|
||||
const profile = selectedCharacter(current);
|
||||
if (current.phase !== "main-menu" || current.session?.ownerId !== ownerId
|
||||
|| profile?.id !== characterId || profile.ownerId !== ownerId) return;
|
||||
if (combat.talentRanks === previous.talentRanks && combat.actionBindings === previous.actionBindings) return;
|
||||
|
||||
try {
|
||||
let updated = profile;
|
||||
if (combat.talentRanks !== previous.talentRanks) {
|
||||
const saved = updateCharacterProgression(ownerId, characterId, {
|
||||
// Menu edits cannot award experience or alter the character's level.
|
||||
level: profile.level,
|
||||
experience: profile.experience,
|
||||
talentRanks: combat.talentRanks,
|
||||
}, storage);
|
||||
if (!saved) throw new Error("Character storage is unavailable.");
|
||||
updated = saved;
|
||||
}
|
||||
if (combat.actionBindings !== previous.actionBindings) {
|
||||
const saved = updateCharacterActionBindings(ownerId, characterId, combat.actionBindings, storage);
|
||||
if (!saved) throw new Error("Character storage is unavailable.");
|
||||
updated = saved;
|
||||
}
|
||||
useShellStore.setState((state) => ({
|
||||
characters: state.characters.map((entry) => entry.id === characterId ? updated : entry),
|
||||
notice: "",
|
||||
}));
|
||||
} catch {
|
||||
useShellStore.getState().setNotice("Could not save character changes. Check that profile storage is available and try again.");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
replaceCharactersForOwner,
|
||||
setRosterChangeListener,
|
||||
loadUiPreferences,
|
||||
loadAccountBossCards,
|
||||
saveUiPreferences,
|
||||
type AccountResult,
|
||||
} from "./accountRepository";
|
||||
@@ -23,7 +24,7 @@ interface AuthResponse {
|
||||
interface CloudSaveResponse {
|
||||
revision: number;
|
||||
updatedAt: number | null;
|
||||
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
|
||||
data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
@@ -106,11 +107,12 @@ function flushCloudSave(): void {
|
||||
pendingCharacters = null;
|
||||
if (!session?.accessToken || !characters) return;
|
||||
const uiPreferences = loadUiPreferences(session.ownerId);
|
||||
const bossCards = loadAccountBossCards(session.ownerId);
|
||||
const sequence = saveSequence;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
|
||||
body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }),
|
||||
}, session.accessToken);
|
||||
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
|
||||
}).catch((error) => {
|
||||
@@ -149,12 +151,18 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
||||
activateOnlineSession(session);
|
||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
suppressUpload = true;
|
||||
let characters: CharacterProfile[];
|
||||
try {
|
||||
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
|
||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||
characters = replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? [], undefined, cloud.data?.bossCards);
|
||||
} finally {
|
||||
suppressUpload = false;
|
||||
}
|
||||
// Publish migrated logs even if the player has not earned another card yet.
|
||||
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
|
||||
queueCloudSave(session.ownerId, characters);
|
||||
}
|
||||
return characters;
|
||||
}
|
||||
|
||||
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
||||
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
+19
-15
@@ -5,13 +5,14 @@ import {
|
||||
createCharacter as persistCharacter,
|
||||
deleteCharacter as removeCharacter,
|
||||
listCharacters,
|
||||
loadAccountBossCards,
|
||||
loadCombatPresentationSettings,
|
||||
restoreSession as restoreStoredSession,
|
||||
saveSession,
|
||||
updateCharacterActionBindings,
|
||||
updateCharacterEquipment,
|
||||
updateCharacterInventory,
|
||||
updateCharacterBossCards,
|
||||
saveAccountBossCards as persistAccountBossCards,
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterDungeonRunCheckpoint,
|
||||
updateCharacterProgression,
|
||||
@@ -39,7 +40,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from
|
||||
import type { DungeonId } from "../game/dungeonRegistry";
|
||||
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { BossCardCollection } from "../game/bossCards";
|
||||
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import type { EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
session: PlayerSession | null;
|
||||
characters: CharacterProfile[];
|
||||
bossCards: BossCardCollection;
|
||||
selectedCharacterId: string | null;
|
||||
activeCharacter: CharacterProfile | null;
|
||||
draft: CharacterDraft;
|
||||
@@ -111,7 +113,7 @@ export interface ShellState {
|
||||
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
|
||||
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
|
||||
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
|
||||
saveActiveBossCards: (bossCards: BossCardCollection) => void;
|
||||
saveAccountBossCards: (bossCards: BossCardCollection) => void;
|
||||
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
|
||||
saveActiveSettings: (settings: GameplaySettings) => void;
|
||||
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
|
||||
@@ -138,12 +140,13 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> {
|
||||
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "bossCards" | "selectedCharacterId" | "combatPresentationSettings"> {
|
||||
const session = restoreStoredSession();
|
||||
if (!session) return {
|
||||
phase: "login",
|
||||
session: null,
|
||||
characters: [],
|
||||
bossCards: emptyBossCardCollection(),
|
||||
selectedCharacterId: null,
|
||||
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
};
|
||||
@@ -152,6 +155,7 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
|
||||
phase: "characters",
|
||||
session,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters[0]?.id ?? null,
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
};
|
||||
@@ -174,6 +178,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
phase: "characters",
|
||||
session,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters[0]?.id ?? null,
|
||||
activeCharacter: null,
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
@@ -189,6 +194,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
phase: "login",
|
||||
session: null,
|
||||
characters: [],
|
||||
bossCards: emptyBossCardCollection(),
|
||||
selectedCharacterId: null,
|
||||
activeCharacter: null,
|
||||
draft: defaultDraft(),
|
||||
@@ -276,6 +282,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({
|
||||
phase: "characters",
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: result.character.id,
|
||||
draft: defaultDraft(),
|
||||
notice: `${result.character.name} is ready for the expedition.`,
|
||||
@@ -439,7 +446,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
returnToMainMenu: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
|
||||
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
|
||||
const { session, characters, activeCharacter, selectedCharacterId } = get();
|
||||
if (!session) {
|
||||
@@ -477,7 +484,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
},
|
||||
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
|
||||
returnToCharacters: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
|
||||
const session = get().session;
|
||||
if (!session) {
|
||||
set({ phase: "login", activeCharacter: null });
|
||||
@@ -488,6 +495,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({
|
||||
phase: "characters",
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters.some((character) => character.id === currentId)
|
||||
? currentId
|
||||
: characters[0]?.id ?? null,
|
||||
@@ -538,15 +546,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
|
||||
}));
|
||||
},
|
||||
saveActiveBossCards: (bossCards) => {
|
||||
const { session, activeCharacter } = get();
|
||||
if (!session || !activeCharacter) return;
|
||||
const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards);
|
||||
if (!updated) return;
|
||||
set((state) => ({
|
||||
activeCharacter: updated,
|
||||
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
|
||||
}));
|
||||
saveAccountBossCards: (bossCards) => {
|
||||
const { session } = get();
|
||||
if (!session) return;
|
||||
set({ bossCards: persistAccountBossCards(session.ownerId, bossCards) });
|
||||
},
|
||||
saveActiveEquipment: (equipment) => {
|
||||
const { session, activeCharacter } = get();
|
||||
@@ -651,6 +654,7 @@ if (restored.session?.accessToken) {
|
||||
useShellStore.setState({
|
||||
session: refreshedSession,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(refreshedSession.ownerId),
|
||||
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
||||
? useShellStore.getState().selectedCharacterId
|
||||
: characters[0]?.id ?? null,
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface CharacterProfile {
|
||||
actionLoadouts?: Record<string, Record<string, string | null>>;
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
/** Legacy character log, read only during migration to the account collection. */
|
||||
bossCards?: BossCardCollection;
|
||||
/** Present on normalized and newly created profiles; optional for legacy callers. */
|
||||
manastormProgress?: ManastormProgress;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
|
||||
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { resolveDisplayLayout } from "../platform/displayLayout";
|
||||
import { THOR_DISPLAYS } from "../platform/thorDisplayProfile";
|
||||
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
|
||||
interface DualDisplayFrameProps {
|
||||
@@ -136,12 +137,12 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="thor-preview-frame">
|
||||
<div className="thor-preview-label"><b>Main display</b><span>960 x 540 CSS / 1920 x 1080</span></div>
|
||||
<div className="thor-preview-top">{top}</div>
|
||||
<div className="thor-preview-frame" style={{ width: THOR_DISPLAYS.top.cssWidth + 80 }}>
|
||||
<div className="thor-preview-label"><b>Top · 6 inches</b><span>1920 × 1080 · {THOR_DISPLAYS.top.widthMm.toFixed(1)} × {THOR_DISPLAYS.top.heightMm.toFixed(1)} mm</span></div>
|
||||
<div className="thor-preview-top" style={{ width: THOR_DISPLAYS.top.cssWidth, height: THOR_DISPLAYS.top.cssHeight }}>{top}</div>
|
||||
<div className="thor-preview-hinge"><i /><b>AYN THOR</b><i /></div>
|
||||
<div className="thor-preview-label"><b>Companion display</b><span>620 x 540 CSS / 1240 x 1080</span></div>
|
||||
<div className="thor-preview-bottom">{bottom}</div>
|
||||
<div className="thor-preview-label"><b>Bottom · 3.92 inches</b><span>1240 × 1080 · {THOR_DISPLAYS.bottom.widthMm.toFixed(1)} × {THOR_DISPLAYS.bottom.heightMm.toFixed(1)} mm</span></div>
|
||||
<div className="thor-preview-bottom" style={{ width: THOR_DISPLAYS.bottom.cssWidth, height: THOR_DISPLAYS.bottom.cssHeight }}>{bottom}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
export function bossTestIsDefeated(): boolean {
|
||||
const test = useGameStore.getState().bossTest;
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
return Boolean(test?.runtimeIds.length && test.runtimeIds.every((id) => mobs[id]?.dead));
|
||||
}
|
||||
|
||||
export function BossTestingBridge() {
|
||||
useEffect(() => {
|
||||
let defeated = false;
|
||||
const observe = () => {
|
||||
const next = bossTestIsDefeated();
|
||||
if (next && !defeated) useGameStore.getState().openOverlay("boss-test-review");
|
||||
defeated = next;
|
||||
};
|
||||
const unsubscribeCombat = useCombatStore.subscribe(observe);
|
||||
const unsubscribeGame = useGameStore.subscribe((state, previous) => {
|
||||
if (state.encounterResetRevision !== previous.encounterResetRevision || state.bossTest !== previous.bossTest) defeated = false;
|
||||
});
|
||||
observe();
|
||||
return () => { unsubscribeCombat(); unsubscribeGame(); };
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function CombatBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
|
||||
useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (gameMode === "manastorm") {
|
||||
activateManastormActiveSpellLoadout(
|
||||
@@ -66,12 +66,14 @@ export function CombatBridge() {
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
const characterId = character.id;
|
||||
const ownerId = useShellStore.getState().session?.ownerId;
|
||||
const unsubscribe = useCombatStore.subscribe((state, previousState) => {
|
||||
// Save APIs intentionally target the active character. Avoid persisting a
|
||||
// final combat notification during the render-to-effect-cleanup window
|
||||
// after Character Select has already replaced the active character.
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.activeCharacter?.id !== characterId) return;
|
||||
if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
|
||||
if (useGameStore.getState().bossTest) return;
|
||||
|
||||
// Persisted collections are replaced immutably by combat actions. A
|
||||
// reference/primitive comparison therefore detects the same changes as
|
||||
@@ -101,7 +103,7 @@ export function CombatBridge() {
|
||||
shell.saveActiveInventory(state.inventory);
|
||||
}
|
||||
if (state.bossCards !== previousState.bossCards) {
|
||||
shell.saveActiveBossCards(state.bossCards);
|
||||
shell.saveAccountBossCards(state.bossCards);
|
||||
}
|
||||
if (state.equipment !== previousState.equipment) {
|
||||
shell.saveActiveEquipment(state.equipment);
|
||||
|
||||
@@ -246,6 +246,7 @@ function applyRelayEvent(
|
||||
}
|
||||
|
||||
export function OnlineSessionBridge() {
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const group = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
@@ -256,7 +257,7 @@ export function OnlineSessionBridge() {
|
||||
const applyingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.accessToken || !activeActivity || !group) {
|
||||
if (bossTest || !session?.accessToken || !activeActivity || !group) {
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
reset();
|
||||
return undefined;
|
||||
@@ -362,7 +363,7 @@ export function OnlineSessionBridge() {
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
reset();
|
||||
};
|
||||
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
|
||||
}, [accept, activeActivity?.id, bossTest, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -128,17 +128,33 @@ export interface AbilityAmountScaling {
|
||||
/** Additional power coefficient gained per source skill level. */
|
||||
readonly coefficientPerLevel?: number;
|
||||
readonly bonusPowerCoefficient?: number;
|
||||
readonly attackPowerCoefficient?: number;
|
||||
readonly spiritCoefficient?: number;
|
||||
readonly sourceLevel?: number;
|
||||
readonly maxScalingLevel?: number;
|
||||
}
|
||||
|
||||
export interface AbilityHealingRecipients {
|
||||
readonly recipient?: "caster" | "ability-target";
|
||||
readonly scope?: "party";
|
||||
readonly center?: "caster" | "ability-target";
|
||||
readonly radius?: number;
|
||||
readonly maximumTargets?: number;
|
||||
readonly selection?: "random";
|
||||
}
|
||||
|
||||
export interface AbilityDamageResource {
|
||||
/** Restore the caster's resource only when this hit deals effective damage. */
|
||||
readonly onDamageResource?: { readonly resource: ResourceType; readonly amount: number };
|
||||
}
|
||||
|
||||
export type AbilityEffect =
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number } & AbilityAmountScaling)
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| { readonly kind: "finisher-damage"; readonly baseCoefficient: number; readonly perComboCoefficient: number }
|
||||
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling)
|
||||
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling)
|
||||
| ({ readonly kind: "dot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly tag?: string } & AbilityAmountScaling)
|
||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling)
|
||||
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| ({ readonly kind: "dot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
||||
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
||||
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean }
|
||||
@@ -226,6 +242,7 @@ export interface AbilityCost {
|
||||
readonly resource: ResourceType;
|
||||
readonly amount: number;
|
||||
readonly percentage?: boolean;
|
||||
readonly percentageBasis?: "base-mana";
|
||||
}
|
||||
|
||||
export interface AbilityRankDefinition {
|
||||
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import type { CastFailureReason, CombatState } from "../combatStore";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { equipmentAdjustedCombatAmount, resolvedAbilityCosts, useCombatStore } from "../combatStore";
|
||||
import { getMobPosition } from "../mobRuntimeRegistry";
|
||||
import { getPartyRuntimePosition } from "../partyRuntimeRegistry";
|
||||
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingAbilityIsArea } from "../healingAbilityRules";
|
||||
import type { PartyState } from "../partyStore";
|
||||
import { usePartyStore } from "../partyStore";
|
||||
import type { PartyRole } from "../partyRoles";
|
||||
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
|
||||
export const BENCHMARK_POLICY_ID = "baseline-v1";
|
||||
export const BENCHMARK_POLICY_ID = "baseline-v3";
|
||||
|
||||
export interface BenchmarkPolicyDecision {
|
||||
readonly made: boolean;
|
||||
@@ -116,14 +118,35 @@ function scoreAbility(
|
||||
const canHeal = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal");
|
||||
const canShield = categories.has("mitigation") && ability.effects.some((effect) => effect.kind === "shield");
|
||||
if ((!canHeal && !canShield) || !lowest || injured.length === 0) return null;
|
||||
friendlyTarget = ability.target === "self"
|
||||
friendlyTarget = !healingAbilityCanAidOthers(ability)
|
||||
? targets.find((target) => target.id === PLAYER_AGGRO_ID) ?? lowest
|
||||
: lowest;
|
||||
if (friendlyTarget.missingPercent <= 0.005) return null;
|
||||
const pendingHot = healingAbilityHasPendingHot(ability, PLAYER_AGGRO_ID, friendlyTarget.id, combat.effects);
|
||||
const directHeal = ability.effects.some(effect => effect.kind === "heal");
|
||||
if (pendingHot && !directHeal) return null;
|
||||
score += canHeal ? 100 : 70;
|
||||
score += friendlyTarget.missingPercent * 180;
|
||||
const healingAmount = ability.effects.reduce((total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
? (equipmentAdjustedCombatAmount(effect, combat.level, combat.gearStats, combat.classId, ability)
|
||||
+ (effect.kind === "heal" ? friendlyTarget!.maxHealth * (effect.percentMaxHealth ?? 0) : 0))
|
||||
* (effect.kind === "hot" ? Math.min(3, effect.ticks) : 1) : 0), 0);
|
||||
score += Math.min(70, healingAmount / Math.max(1, friendlyTarget.maxHealth) * 100);
|
||||
if (ability.effects.some(effect => effect.kind === "heal" && (effect.percentMaxHealth ?? 0) >= 0.5)) {
|
||||
score += friendlyTarget.missingPercent >= 0.4 ? 100 : -100;
|
||||
}
|
||||
const healthCost = resolvedAbilityCosts(ability, combat.talentModifiers, combat).find(cost => cost.resource === "health")?.amount ?? 0;
|
||||
if (healthCost > 0) {
|
||||
if (combat.health / combat.maxHealth < 0.5) return null;
|
||||
if (friendlyTarget.id === PLAYER_AGGRO_ID && healingAbilityCanAidOthers(ability)) return null;
|
||||
score -= healthCost / combat.maxHealth * 500;
|
||||
}
|
||||
if (pendingHot && friendlyTarget.missingPercent < 0.5) score -= 120;
|
||||
if (categories.has("emergency-heal")) score += friendlyTarget.missingPercent >= 0.5 ? 100 : -15;
|
||||
if (categories.has("area-heal") || area) score += injured.length >= 2 ? 35 * injured.length : -35;
|
||||
if (healingAbilityIsArea(ability)) score += injured.length >= 2 ? 35 * injured.length : -35;
|
||||
if (!directHeal && friendlyTarget.missingPercent >= 0.5) score -= 60;
|
||||
if (canShield) score += friendlyTarget.missingPercent >= 0.2 ? 45 : -20;
|
||||
if (canShield && (combat.actors[friendlyTarget.id]?.absorb ?? 0) > 0) return null;
|
||||
} else {
|
||||
const hostileDamage = categories.has("damage") || categories.has("execute") || categories.has("debuff");
|
||||
if (role === "damage" && !hostileDamage) return null;
|
||||
@@ -176,6 +199,31 @@ export class BenchmarkController {
|
||||
.filter((candidate): candidate is ScoredAbility => candidate !== null)
|
||||
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
|
||||
|
||||
// Rage healers must begin weapon attacks to fund their heals before allies are injured.
|
||||
if (role === "healer" && combat.selectedTargetId
|
||||
&& combat.abilities.some(ability => ability.effects.some(effect => effect.kind === "heal") && ability.cost.resource === "rage")) {
|
||||
const starter = combat.abilities.map(ability => learnedAbility(ability, combat)).find(ability => ability
|
||||
&& ability.target === "hostile" && ability.range.max <= 5
|
||||
&& ability.effects.some(effect => effect.kind === "damage")
|
||||
&& resolvedAbilityCosts(ability, combat.talentModifiers, combat).every(cost => cost.amount === 0));
|
||||
if (starter && !combat.autoAttackTargetId) candidates.push({ ability: starter, score: 0, friendlyTarget: null });
|
||||
if (combat.resource < 35) {
|
||||
const generators = combat.abilities.map(ability => learnedAbility(ability, combat)).filter(ability => ability
|
||||
&& ability.target === "hostile" && ability.effects.some(effect => (effect.kind === "resource"
|
||||
&& (!effect.resource || effect.resource === "rage") && effect.amount > 0)
|
||||
|| ((effect.kind === "damage" || effect.kind === "dot")
|
||||
&& effect.onDamageResource?.resource === "rage" && effect.onDamageResource.amount > 0))
|
||||
&& resolvedAbilityCosts(ability, combat.talentModifiers, combat).every(cost => cost.resource !== "rage"
|
||||
&& (cost.resource !== "health" || (combat.health / combat.maxHealth > 0.65 && cost.amount / combat.maxHealth <= 0.1))));
|
||||
for (const generator of generators) {
|
||||
if (!generator) continue;
|
||||
const needsRageHeal = targets.some(target => target.id !== PLAYER_AGGRO_ID && target.missingPercent > 0.1);
|
||||
candidates.push({ ability: generator, score: needsRageHeal && combat.resource < 20 ? 400 : -10, friendlyTarget: null });
|
||||
candidates.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resourceBlocked = false;
|
||||
let invalidFailures = 0;
|
||||
for (const candidate of candidates) {
|
||||
@@ -203,7 +251,8 @@ export class BenchmarkController {
|
||||
...(targeting.shape === "ground"
|
||||
? { groundPosition: ability.target === "hostile" || ability.target === "any"
|
||||
? hostilePosition ?? combat.playerPosition
|
||||
: combat.playerPosition }
|
||||
: ability.target === "self" || friendlyTarget?.id === PLAYER_AGGRO_ID ? combat.playerPosition
|
||||
: getPartyRuntimePosition(friendlyTarget?.id ?? "") ?? combat.playerPosition }
|
||||
: {}),
|
||||
});
|
||||
if (result.ok) {
|
||||
|
||||
@@ -35,6 +35,8 @@ export async function runDungeonBossBenchmark(
|
||||
let now = 1_000_000;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
let playerDamage = 0; let partyDamage = 0; let incomingDamage = 0; let healing = 0; let casts = 0;
|
||||
let selfHealing = 0; let allyHealing = 0;
|
||||
const healingByAbility: Record<string, number> = {};
|
||||
const damageByAbility: Record<string, number> = {};
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.kind === "damage" && event.targetActorId === bossId) {
|
||||
@@ -44,7 +46,13 @@ export async function runDungeonBossBenchmark(
|
||||
damageByAbility[key] = (damageByAbility[key] ?? 0) + event.effectiveAmount;
|
||||
}
|
||||
if (event.kind === "damage" && event.sourceActorId === bossId) incomingDamage += event.effectiveAmount;
|
||||
if (event.kind === "healing") healing += event.effectiveAmount;
|
||||
if (event.kind === "healing") {
|
||||
healing += event.effectiveAmount;
|
||||
if (event.targetActorId === PLAYER_AGGRO_ID) selfHealing += event.effectiveAmount;
|
||||
else allyHealing += event.effectiveAmount;
|
||||
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}:${event.targetActorId}`;
|
||||
healingByAbility[key] = (healingByAbility[key] ?? 0) + event.effectiveAmount;
|
||||
}
|
||||
});
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
@@ -78,7 +86,7 @@ export async function runDungeonBossBenchmark(
|
||||
if (mob.dead || (mechanics && state.health <= 0)) {
|
||||
return { seconds: mob.dead ? ((mob.diedAt ?? now) - 1_000_000) / 1000 : null,
|
||||
elapsedSeconds: elapsed / 1000, outcome: mob.dead ? "defeated" : "player-death",
|
||||
maxHealth: scaling.maxHealth, playerDamage, partyDamage, incomingDamage, healing, casts, damageByAbility,
|
||||
maxHealth: scaling.maxHealth, playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
playerAlive: state.health > 0, deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
|
||||
attacks: mob.attackRevision, specialization: specialization?.displayName,
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
|
||||
@@ -86,7 +94,7 @@ export async function runDungeonBossBenchmark(
|
||||
}
|
||||
return { seconds: null, elapsedSeconds: scenario.durationMs / 1000, outcome: "timeout",
|
||||
maxHealth: scaling.maxHealth, remainingHealth: useCombatStore.getState().mobs[bossId].health,
|
||||
playerDamage, partyDamage, incomingDamage, healing, casts, damageByAbility,
|
||||
playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
playerAlive: useCombatStore.getState().health > 0,
|
||||
deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
|
||||
attacks: useCombatStore.getState().mobs[bossId].attackRevision,
|
||||
|
||||
@@ -49,6 +49,35 @@ export function normalizeBossCardCollection(value: unknown): BossCardCollection
|
||||
return { counts, lastClaims };
|
||||
}
|
||||
|
||||
/** Sum independent legacy character logs; max reconciles copies of the same account log. */
|
||||
export function mergeBossCardCollections(collections: readonly unknown[], mode: "sum" | "max" = "max"): BossCardCollection {
|
||||
const counts: Record<string, BossCardCounts> = Object.create(null);
|
||||
const lastClaims: Record<string, string> = Object.create(null);
|
||||
for (const value of collections) {
|
||||
const collection = normalizeBossCardCollection(value);
|
||||
for (const [bossKey, entry] of Object.entries(collection.counts)) {
|
||||
const merged = counts[bossKey] ??= {};
|
||||
for (const rarity of BOSS_CARD_RARITIES) {
|
||||
const count = entry[rarity];
|
||||
if (count === undefined) continue;
|
||||
merged[rarity] = mode === "sum"
|
||||
? Math.min(Number.MAX_SAFE_INTEGER, (merged[rarity] ?? 0) + count)
|
||||
: Math.max(merged[rarity] ?? 0, count);
|
||||
}
|
||||
}
|
||||
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
|
||||
const previous = lastClaims[spawnId];
|
||||
if (!previous) { lastClaims[spawnId] = claim; continue; }
|
||||
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
|
||||
const [previousTime, previousRevision] = previous.split(":").map(Number);
|
||||
const [time, revision] = claim.split(":").map(Number);
|
||||
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
|
||||
} else if (claim > previous) lastClaims[spawnId] = claim;
|
||||
}
|
||||
}
|
||||
return { counts, lastClaims };
|
||||
}
|
||||
|
||||
/** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */
|
||||
export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] {
|
||||
return BOSS_CARD_RARITIES.filter((rarity) => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
|
||||
import { clearBossTestingDungeons, DUNGEON_DEFINITIONS, registerBossTestingDungeon, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { useGameStore } from "./store";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { bossTestIsDefeated } from "./BossTestingBridge";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
|
||||
afterEach(() => {
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
clearBossTestingDungeons();
|
||||
});
|
||||
|
||||
describe("Boss Testing", () => {
|
||||
const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns" && entry.name === "Lady Anacondra")!;
|
||||
const source = requireDungeonDefinition(boss.dungeonId);
|
||||
|
||||
it("includes unready and event-only bosses and isolates catalog changes from shipped content", () => {
|
||||
const blocked: DungeonDefinition = { ...source, availability: "blocked", staticSpawns: [], roamingPacks: [],
|
||||
encounterSpawnGroups: [{ id: "event", spawns: [boss.spawn] }], entities: { [boss.entityId]: source.entities[boss.entityId] } };
|
||||
expect(buildBossTestingCatalog([blocked])).toHaveLength(1);
|
||||
const count = DUNGEON_DEFINITIONS.length;
|
||||
const room = createBossTestingRoom(source, boss);
|
||||
registerBossTestingDungeon(room.definition, source.id);
|
||||
expect(requireDungeonDefinition(source.id)).toBe(source);
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(count);
|
||||
expect(room.definition.staticSpawns).toHaveLength(1);
|
||||
expect(room.definition.roamingPacks).toEqual([]);
|
||||
expect(room.definition.entities[boss.entityId].combat).toEqual(source.entities[boss.entityId].combat);
|
||||
expect(room.definition.staticSpawns[0].position).toEqual(boss.spawn.position);
|
||||
expect(room.definition.assets).toEqual({ visual: [], collision: [] });
|
||||
});
|
||||
|
||||
it("preserves boss phases and scripted summons without spawning unrelated dungeon packs", () => {
|
||||
const fixture: DungeonDefinition = { ...source, encounters: [
|
||||
{ id: "boss-mechanics", dungeonId: source.id, initialPhase: "idle", rules: [{ id: "adds", trigger: { kind: "entity-health", entityId: boss.spawn.id, atOrBelowPercent: 50 },
|
||||
actions: [{ id: "summon", kind: "activate-spawn-group", spawnGroupId: "boss-adds" }, { id: "loot", kind: "grant-loot", lootTableId: "test" }] }] },
|
||||
{ id: "other-boss", dungeonId: source.id, initialPhase: "idle", rules: [{ id: "other", trigger: { kind: "entity-engaged", entityId: "unrelated" }, actions: [] }] },
|
||||
], encounterSpawnGroups: [{ id: "boss-adds", spawns: [{ ...boss.spawn, id: "add", entityId: boss.entityId }] }, { id: "trash", spawns: [] }] };
|
||||
const room = createBossTestingRoom(fixture, boss);
|
||||
expect(room.definition.encounters?.map((entry) => entry.id)).toEqual([`${room.definition.id}:boss-mechanics`]);
|
||||
expect(room.definition.encounters?.[0].dungeonId).toBe(room.definition.id);
|
||||
expect(room.definition.encounters?.[0].rules[0].actions).toEqual([{ id: "summon", kind: "activate-spawn-group", spawnGroupId: "boss-adds" }]);
|
||||
expect(room.definition.encounterSpawnGroups?.map((entry) => entry.id)).toEqual(["boss-adds"]);
|
||||
expect(room.definition.staticSpawns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("starts an unready boss room, survives scene retries, and retains testing mode when restarted", () => {
|
||||
const room = createBossTestingRoom(source, boss);
|
||||
registerBossTestingDungeon(room.definition, source.id);
|
||||
expect(activateDungeonSession(room.definition.id)).toBe(false);
|
||||
expect(activateDungeonSession(room.definition.id, undefined, { gmTestMode: true, bossTest: room.session })).toBe(true);
|
||||
expect(restartActiveDungeonSession()).toBe(true);
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
expect(useGameStore.getState()).toMatchObject({ gmTestMode: true, bossTest: room.session, overlay: null });
|
||||
});
|
||||
|
||||
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
|
||||
const room = createBossTestingRoom(source, boss);
|
||||
registerBossTestingDungeon(room.definition, source.id);
|
||||
useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true });
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 });
|
||||
expect(bossTestIsDefeated()).toBe(false);
|
||||
useCombatStore.getState().registerMob(boss.spawn.id, { name: boss.name, boss: true, maxHealth: 10, xpReward: 500, hasLoot: true, lootSourceId: boss.entityId });
|
||||
const xp = useCombatStore.getState().xp;
|
||||
useCombatStore.getState().damageMob(boss.spawn.id, 100000, Date.now());
|
||||
expect(bossTestIsDefeated()).toBe(true);
|
||||
const combat = useCombatStore.getState();
|
||||
expect(combat.xp).toBe(xp);
|
||||
expect(combat.mobs[boss.spawn.id]).toMatchObject({ hasLoot: false, pendingLoot: [] });
|
||||
expect(combat.mobs[boss.spawn.id].bossCardDrop).toBeFalsy();
|
||||
});
|
||||
|
||||
it("draws a boss only from the selected filters and handles empty queues", () => {
|
||||
expect(randomTestingBoss([], () => 0.5)).toBeNull();
|
||||
const candidates = BOSS_TESTING_CATALOG.slice(0, 2);
|
||||
expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]);
|
||||
expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]);
|
||||
expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
||||
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
|
||||
import type { StaticMobSpawnDefinition } from "./mobPopulation";
|
||||
import type { BossTestSession } from "./store";
|
||||
|
||||
export interface TestableBoss {
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly dungeonId: string;
|
||||
readonly dungeonTitle: string;
|
||||
readonly entityId: string;
|
||||
readonly category: "wow" | "rom";
|
||||
readonly spawn: StaticMobSpawnDefinition;
|
||||
}
|
||||
|
||||
export function buildBossTestingCatalog(definitions: readonly DungeonDefinition[]): readonly TestableBoss[] {
|
||||
return definitions.flatMap((dungeon) => Object.values(dungeon.entities).flatMap((entity): TestableBoss[] => {
|
||||
if (entity.kind !== "boss") return [];
|
||||
const spawn = dungeon.staticSpawns.find((candidate) => candidate.entityId === entity.id)
|
||||
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((candidate) => candidate.entityId === entity.id);
|
||||
const objective = dungeon.bosses.find((boss) => boss.id === spawn?.id || boss.name === entity.name);
|
||||
const pack = dungeon.roamingPacks.find((candidate) => candidate.members.some((member) => member.entityId === entity.id));
|
||||
const member = pack?.members.find((candidate) => candidate.entityId === entity.id);
|
||||
const position = spawn?.position ?? objective?.position ?? pack?.waypoints[0];
|
||||
if (!position) return [];
|
||||
return [{
|
||||
key: `${dungeon.id}/${entity.id}`, name: entity.name, dungeonId: dungeon.id,
|
||||
dungeonTitle: dungeon.title, entityId: entity.id,
|
||||
category: dungeon.expansion === "runewaker" ? "rom" : "wow",
|
||||
spawn: { ...spawn, id: spawn?.id ?? (pack && member ? `${pack.id}:${member.id}` : objective?.id ?? entity.id),
|
||||
entityId: entity.id, position, spawnChance: 1 },
|
||||
}];
|
||||
})).sort((a, b) => a.name.localeCompare(b.name) || a.dungeonTitle.localeCompare(b.dungeonTitle));
|
||||
}
|
||||
|
||||
export const BOSS_TESTING_CATALOG = buildBossTestingCatalog(DUNGEON_DEFINITIONS);
|
||||
|
||||
export function randomTestingBoss(bosses: readonly TestableBoss[], random: () => number = Math.random): TestableBoss | null {
|
||||
if (!bosses.length) return null;
|
||||
const draw = random();
|
||||
const index = Math.floor((Number.isFinite(draw) ? Math.max(0, Math.min(0.999999999, draw)) : 0) * bosses.length);
|
||||
return bosses[index];
|
||||
}
|
||||
|
||||
function references(value: unknown, ids: ReadonlySet<string>): boolean {
|
||||
if (typeof value === "string") return ids.has(value);
|
||||
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
|
||||
return Boolean(value && typeof value === "object" && Object.values(value).some((entry) => references(entry, ids)));
|
||||
}
|
||||
|
||||
function remapEncounterReferences<T>(value: T, ids: ReadonlyMap<string, string>): T {
|
||||
if (typeof value === "string") return (ids.get(value) ?? value) as T;
|
||||
if (Array.isArray(value)) return value.map((entry) => remapEncounterReferences(entry, ids)) as T;
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
|
||||
.map(([key, entry]) => [key, remapEncounterReferences(entry, ids)])) as T;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Keep original mechanic coordinates and spawn IDs while replacing the environment with a clear floor. */
|
||||
export function createBossTestingRoom(source: DungeonDefinition, boss: TestableBoss): {
|
||||
readonly definition: DungeonDefinition;
|
||||
readonly session: BossTestSession;
|
||||
} {
|
||||
const entity = source.entities[boss.entityId];
|
||||
if (source.id !== boss.dungeonId || entity?.kind !== "boss") throw new Error("That boss is unavailable.");
|
||||
const id = `boss-testing:${source.id}:${boss.entityId}`;
|
||||
const ids = new Set([boss.entityId, boss.spawn.id]);
|
||||
const sourceEncounters = (source.encounters ?? []).filter((encounter) =>
|
||||
references(encounter.rules.map((rule) => rule.trigger), ids)
|
||||
|| references(entity.combat, new Set([encounter.id]))
|
||||
);
|
||||
const encounterIds = new Map(sourceEncounters.map((encounter) => [encounter.id, `${id}:${encounter.id}`]));
|
||||
const encounters = sourceEncounters.map((encounter) => {
|
||||
const isolated = remapEncounterReferences(encounter, encounterIds);
|
||||
return { ...isolated, id: encounterIds.get(encounter.id)!, dungeonId: id,
|
||||
rules: isolated.rules.map((rule) => ({ ...rule, actions: rule.actions.filter((action) =>
|
||||
action.kind !== "grant-loot" && action.kind !== "set-checkpoint"
|
||||
) })),
|
||||
};
|
||||
});
|
||||
const groups = (source.encounterSpawnGroups ?? []).filter((group) => references(encounters, new Set([group.id])));
|
||||
const objects = source.serverGameObjects.filter((object) => references(encounters, new Set([object.id, object.interaction?.signalId ?? ""])));
|
||||
const entityIds = new Set([boss.entityId, ...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId))]);
|
||||
// Summoned creature definitions are needed even though they are absent from the initial room.
|
||||
for (const entityId of entityIds) {
|
||||
for (const attack of source.entities[entityId]?.combat?.attacks ?? []) {
|
||||
for (const effect of attack.effect ? [attack.effect] : []) {
|
||||
if (effect.kind !== "summon") continue;
|
||||
for (const candidate of Object.values(source.entities)) {
|
||||
if (candidate.id === effect.entityId || candidate.combat?.serverEntry === effect.creatureEntry) entityIds.add(candidate.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const center = boss.spawn.position;
|
||||
const radius = Math.max(40, ...groups.flatMap((group) => group.spawns.map((spawn) =>
|
||||
Math.hypot(spawn.position[0] - center[0], spawn.position[2] - center[2]) + 8
|
||||
)), ...objects.map((object) => Math.hypot(object.position[0] - center[0], object.position[2] - center[2]) + 8));
|
||||
const spawnDistance = Math.max(14, (entity.combat?.aggroRange ?? 12) + 5);
|
||||
const entrance: Vector3Tuple = [center[0], center[1], center[2] + Math.min(radius - 5, spawnDistance)];
|
||||
const spawn = { ...boss.spawn, spawnMask: undefined, spawnChance: 1 };
|
||||
const objective = { id: spawn.id, name: boss.name, position: center };
|
||||
return {
|
||||
session: { key: boss.key, name: boss.name, sourceDungeonId: source.id, entityId: boss.entityId, runtimeIds: [spawn.id] },
|
||||
definition: {
|
||||
...source, id, title: `${boss.name} — Boss Testing`, availability: "available",
|
||||
assets: { visual: [], collision: [] },
|
||||
bounds: { min: [center[0] - radius, center[1] - 2, center[2] - radius], max: [center[0] + radius, center[1] + 24, center[2] + radius] },
|
||||
entrance: { name: "Testing room", footPosition: entrance, yaw: Math.PI, forward: [0, 0, -1] },
|
||||
areas: [{ id: "testing-room", name: "Boss Testing", center, radius: radius * 2 }],
|
||||
entities: Object.fromEntries([...entityIds].filter((entityId) => source.entities[entityId])
|
||||
.map((entityId) => [entityId, remapEncounterReferences(source.entities[entityId], encounterIds)])),
|
||||
staticSpawns: [spawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective],
|
||||
navigationLinks: [], traversalRamps: [], encounters, encounterSpawnGroups: groups, serverGameObjects: objects,
|
||||
presentation: { ...source.presentation, location: "Boss Testing", summary: "An isolated room for testing boss mechanics.",
|
||||
loadingMessage: `Preparing ${boss.name}…`, background: "#111923",
|
||||
fog: { color: "#111923", near: radius, far: radius * 3 },
|
||||
ambientLight: { color: "#ffffff", intensity: 1.2 },
|
||||
hemisphereLight: { skyColor: "#c8ddf0", groundColor: "#444c57", intensity: 1.4 },
|
||||
map: { width: 600, height: 600, padding: 20, contours: [] } },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,15 @@ const rankEffectsForSpell = (spellId: number): readonly AbilityEffect[] => ranks
|
||||
.find(({ rank }) => rank.spellId === spellId)?.rank.effects ?? [];
|
||||
|
||||
describe("Ascension Conquest ability catalog", () => {
|
||||
it("keeps Rejuvenating Venom healing in its cast proc", () => {
|
||||
const venom = byName("venomancer", "Rejuvenating Venom");
|
||||
expect(venom.effects.some(effect => effect.kind === "heal")).toBe(false);
|
||||
expect(venom.effects.some(effect => effect.kind === "apply-aura"
|
||||
&& effect.aura.procs?.some(proc => proc.action.kind === "custom" && proc.action.data?.spellId === 630869))).toBe(true);
|
||||
const payload = COA_TRIGGERED_ABILITIES_BY_SPELL_ID[630869];
|
||||
expect(payload.effects.find(effect => effect.kind === "hot")).toMatchObject({ scope: "party", radius: 30, maximumTargets: 1 });
|
||||
});
|
||||
|
||||
it("covers every class, source family, and playable rank", () => {
|
||||
expect(Object.keys(COA_ABILITIES_BY_CLASS).sort()).toEqual([...COA_CLASS_IDS].sort());
|
||||
expect(Object.keys(COA_CLASS_RESOURCES).sort()).toEqual([...COA_CLASS_IDS].sort());
|
||||
@@ -181,14 +190,16 @@ describe("Ascension Conquest ability catalog", () => {
|
||||
});
|
||||
|
||||
it("ships executable hidden payloads for proc-triggered DBC spell IDs", () => {
|
||||
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(585);
|
||||
// Geode Barrage now uses its ranked parent payload instead of hidden placeholder 803138.
|
||||
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(584);
|
||||
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[803138]).toBeUndefined();
|
||||
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[800951]).toMatchObject({
|
||||
name: "Battle Rhythm",
|
||||
effects: expect.arrayContaining([{ kind: "resource", amount: 5 }]),
|
||||
});
|
||||
expect(Object.values(COA_TRIGGERED_ABILITIES_BY_SPELL_ID).filter((ability) => (
|
||||
ability.effects.some((effect) => effect.kind !== "utility")
|
||||
))).toHaveLength(546);
|
||||
))).toHaveLength(545);
|
||||
for (const [spellId, ability] of Object.entries(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
|
||||
expect(ability.dbcSpellId, spellId).toBe(Number(spellId));
|
||||
expect(ability.effects.length, spellId).toBeGreaterThan(0);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
AbilityCost,
|
||||
AbilityDefinition,
|
||||
AbilityEffect,
|
||||
AbilityHealingRecipients,
|
||||
AbilityRankDefinition,
|
||||
AbilityTarget,
|
||||
ClassResourceProfile,
|
||||
@@ -339,6 +340,16 @@ function effectTargetsHostile(effect: CoaDbcEffect): boolean {
|
||||
return targetsForEffect(effect).some((target) => HOSTILE_TARGETS.has(target));
|
||||
}
|
||||
|
||||
function healingRecipients(effect: CoaDbcEffect, spell: CoaDbcSpell): AbilityHealingRecipients {
|
||||
const targets = targetsForEffect(effect);
|
||||
if (effect.radius > 0 && targets.some(target => [20, 21, 22, 30, 31, 33, 35, 37, 45, 57].includes(target))) {
|
||||
return { scope: "party", center: targets.includes(21) || targets.includes(45) ? "ability-target" : "caster",
|
||||
radius: effect.radius, ...(spell.maximumTargets > 0 ? { maximumTargets: spell.maximumTargets } : {}),
|
||||
...(/\brandom ally\b/i.test(spell.description) ? { selection: "random" as const } : {}) };
|
||||
}
|
||||
return targets.includes(1) ? { recipient: "caster" } : {};
|
||||
}
|
||||
|
||||
function schoolsFromMask(mask: number): readonly DamageSchool[] {
|
||||
const schools: readonly [number, DamageSchool][] = [
|
||||
[1, "physical"],
|
||||
@@ -520,6 +531,42 @@ function effectAmountScaling(effect: CoaDbcEffect, spell: CoaDbcSpell): AbilityA
|
||||
};
|
||||
}
|
||||
|
||||
/** CoA server-scripted heals expose power scaling in formulas rather than the DBC coefficient. */
|
||||
function healingAmountScaling(effect: CoaDbcEffect, spell: CoaDbcSpell): AbilityAmountScaling {
|
||||
const scaling = effectAmountScaling(effect, spell);
|
||||
const expression = [...spell.description.matchAll(/\$\{([^{}]+)\}/g)]
|
||||
.map(match => match[1]).find(value => new RegExp(`\\$m${effect.index + 1}\\b`, "i").test(value));
|
||||
if (!expression) return scaling;
|
||||
const coefficient = (names: string) => Number(expression.match(new RegExp(`\\$(?:${names})\\s*\\*\\s*(\\d*\\.?\\d+)`, "i"))?.[1] ?? 0);
|
||||
const power = coefficient("BH|SP");
|
||||
const attackPower = coefficient("AP");
|
||||
const spirit = coefficient("SPI");
|
||||
return { ...scaling, ...(effect.coefficient === 0 && power > 0 ? { bonusPowerCoefficient: power } : {}),
|
||||
...(attackPower > 0 ? { attackPowerCoefficient: attackPower } : {}),
|
||||
...(spirit > 0 ? { spiritCoefficient: spirit } : {}) };
|
||||
}
|
||||
|
||||
/** Primalist stone amounts come from the parent spell, not its hidden damage placeholder. */
|
||||
function primalistStoneEffect(effect: CoaDbcEffect, spell: CoaDbcSpell): AbilityEffect | null {
|
||||
if (spell.name === "Geode Barrage" && effect.auraType === 227 && effect.triggerSpellId === 803138) {
|
||||
// Initial pulse plus two 500ms pulses. Use Geode (804002)'s normalized
|
||||
// 5 Rage per landed stone; Barrage's DBC omits the server-side rage hook.
|
||||
return { kind: "dot", coefficient: 0, ...effectAmountScaling(effect, spell), bonusPowerCoefficient: .23,
|
||||
attackPowerCoefficient: .09, ticks: 3, intervalMs: 500, startsImmediately: true,
|
||||
tag: "primalist-geode-barrage", onDamageResource: { resource: "rage", amount: 5 } };
|
||||
}
|
||||
if (spell.name === "Seismic Crash" && effect.auraType === 3 && effect.periodMs > 0) {
|
||||
return { kind: "dot", coefficient: 0, ...effectAmountScaling(effect, spell), bonusPowerCoefficient: .10,
|
||||
attackPowerCoefficient: .06, ticks: Math.floor(spell.durationMs / effect.periodMs) + 1,
|
||||
intervalMs: effect.periodMs, startsImmediately: true, tag: `spell-${spell.id}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resourceForPowerType(powerType: number): ResourceType {
|
||||
return ({ [-2]: "health", 0: "mana", 1: "rage", 2: "focus", 3: "energy", 6: "runic-power" } as const)[powerType as -2 | 0 | 1 | 2 | 3 | 6] ?? "mana";
|
||||
}
|
||||
|
||||
function controlFromAura(auraType: number): AbilityAuraControl | undefined {
|
||||
switch (auraType) {
|
||||
case 5:
|
||||
@@ -644,7 +691,7 @@ function namedAuraForEffect(
|
||||
return {
|
||||
id: `coa:${spell.id}:effect:${effectIndex}:aura:${effect.auraType}`,
|
||||
name: `${abilityName} (Aura ${effect.auraType})`,
|
||||
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
|
||||
disposition: effect.auraType === 8 || spell.name === "Protective Roar" ? "buff" : effectTargetsHostile(effect) ? "debuff" : "buff",
|
||||
...([3, 8].includes(effect.auraType) && effect.periodMs > 0 ? { hideFromAuraStrip: true } : {}),
|
||||
durationMs: spell.durationMs > 0 ? spell.durationMs : null,
|
||||
maxStacks: Math.max(1, spell.stackAmount || 1),
|
||||
@@ -719,25 +766,33 @@ function effectsForSpell(
|
||||
kind: "apply-aura",
|
||||
aura: namedAuraForEffect(effect, spell, abilityName, description, effectIndex),
|
||||
...(targetsForEffect(effect).includes(1) ? { recipient: "caster" as const } : {}),
|
||||
...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}),
|
||||
...([35, 65].includes(effect.effectType) || spell.name === "Protective Roar" ? { scope: "party" as const } : {}),
|
||||
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
|
||||
sourceEffectType: effect.effectType,
|
||||
sourceAuraType: effect.auraType,
|
||||
miscValue: effect.miscValue,
|
||||
triggerSpellId: effect.triggerSpellId,
|
||||
});
|
||||
if (effect.auraType === 3 && effect.periodMs > 0) {
|
||||
const stone = primalistStoneEffect(effect, spell);
|
||||
if (stone) {
|
||||
const aura = effects[effects.length - 1];
|
||||
if (aura.kind === "apply-aura" && effect.triggerSpellId === 803138) {
|
||||
effects[effects.length - 1] = { ...aura, aura: { ...aura.aura, procs: [] } };
|
||||
}
|
||||
effects.push(stone);
|
||||
if (spell.name === "Seismic Crash") effects.push({ kind: "resource", resource: "rage", amount: 20 });
|
||||
} else if (effect.auraType === 3 && effect.periodMs > 0) {
|
||||
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, spell.durationMs) / effect.periodMs));
|
||||
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${spell.id}` });
|
||||
} else if ([8, 20].includes(effect.auraType) && effect.periodMs > 0) {
|
||||
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, spell.durationMs) / effect.periodMs));
|
||||
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
|
||||
effects.push({ kind: "hot", coefficient, ...healingAmountScaling(effect, spell), ...healingRecipients(effect, spell), ticks, intervalMs: effect.periodMs });
|
||||
} else if (effect.auraType === 11) {
|
||||
effects.push({ kind: "taunt", durationMs: Math.max(3_000, spell.durationMs) });
|
||||
} else if (effect.auraType === 53 && effect.periodMs > 0) {
|
||||
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, spell.durationMs) / effect.periodMs));
|
||||
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${spell.id}` });
|
||||
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
|
||||
effects.push({ kind: "hot", coefficient, ...scaling, recipient: "caster", ticks, intervalMs: effect.periodMs });
|
||||
} else if (effect.auraType === 64 && effect.periodMs > 0) {
|
||||
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, spell.durationMs) / effect.periodMs));
|
||||
effects.push({ kind: "resource-drain", amount: Math.abs(effect.basePoints) * ticks, burn: false, resource: "mana", percentage: true });
|
||||
@@ -769,7 +824,10 @@ function effectsForSpell(
|
||||
continue;
|
||||
}
|
||||
if (effect.effectType === 2) {
|
||||
effects.push({ kind: "damage", coefficient, ...scaling });
|
||||
effects.push(spell.name === "Seismic Spike"
|
||||
? { kind: "damage", coefficient, ...scaling, bonusPowerCoefficient: .65, attackPowerCoefficient: .065,
|
||||
onDamageResource: { resource: "rage", amount: 4 } }
|
||||
: { kind: "damage", coefficient, ...scaling });
|
||||
continue;
|
||||
}
|
||||
if (effect.effectType === 8 || effect.effectType === 62) {
|
||||
@@ -778,7 +836,7 @@ function effectsForSpell(
|
||||
}
|
||||
if (effect.effectType === 9) {
|
||||
effects.push({ kind: "damage", coefficient, ...scaling });
|
||||
effects.push({ kind: "heal", coefficient, ...scaling });
|
||||
effects.push({ kind: "heal", coefficient, ...scaling, recipient: "caster" });
|
||||
continue;
|
||||
}
|
||||
if (WEAPON_DAMAGE_EFFECTS.has(effect.effectType)) {
|
||||
@@ -786,21 +844,29 @@ function effectsForSpell(
|
||||
continue;
|
||||
}
|
||||
if (effect.effectType === 67) {
|
||||
effects.push({ kind: "heal", coefficient: 0, percentMaxHealth: 1, ...scaling });
|
||||
effects.push({ kind: "heal", coefficient: 0, percentMaxHealth: 1, ...scaling, ...healingRecipients(effect, spell) });
|
||||
continue;
|
||||
}
|
||||
if (effect.effectType === 10 || effect.effectType === 67 || effect.effectType === 136) {
|
||||
effects.push(effect.effectType === 136
|
||||
? { kind: "heal", coefficient: 0, percentMaxHealth: Math.max(0, Math.abs(effect.basePoints) / 100), ...scaling }
|
||||
: { kind: "heal", coefficient, ...scaling });
|
||||
? { kind: "heal", coefficient: 0, percentMaxHealth: Math.max(0, Math.abs(effect.basePoints) / 100), ...healingAmountScaling(effect, spell), ...healingRecipients(effect, spell) }
|
||||
: { kind: "heal", coefficient, ...healingAmountScaling(effect, spell), ...healingRecipients(effect, spell) });
|
||||
continue;
|
||||
}
|
||||
if (effect.effectType === 30) {
|
||||
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
|
||||
// Primalist's imported energizes use DBC power units. Other CoA class
|
||||
// script payloads retain their existing interpretation in this pass.
|
||||
if (!coaSpellById(spell.id)?.classIds.includes(31)) {
|
||||
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
|
||||
continue;
|
||||
}
|
||||
const resource = resourceForPowerType(effect.miscValue);
|
||||
effects.push({ kind: "resource", resource, amount: Math.abs(effect.basePoints) / (["rage", "runic-power"].includes(resource) ? 10 : 1) });
|
||||
continue;
|
||||
}
|
||||
if (effect.effectType === 137) {
|
||||
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints), percentage: true });
|
||||
effects.push({ kind: "resource", ...(coaSpellById(spell.id)?.classIds.includes(31) ? { resource: resourceForPowerType(effect.miscValue) } : {}),
|
||||
amount: Math.abs(effect.basePoints), percentage: true });
|
||||
continue;
|
||||
}
|
||||
if ([63, 91, 125, 130].includes(effect.effectType)) {
|
||||
@@ -994,9 +1060,13 @@ function effectsForSpell(
|
||||
|| (effect.kind === "apply-aura" && effect.aura.procs?.some((proc) => ["damage", "custom"].includes(proc.action.kind))));
|
||||
const describesHealing = /\b(?:heal(?:s|ing|ed)?|restores? .*health|mends?|rejuvenat\w*)\b/i.test(description);
|
||||
const describesDamage = /\b(?:deal(?:s|ing)?|inflict(?:s|ing)?|caus(?:e|es|ing)|damag(?:e|es|ing))\b[^.]*\bdamage\b/i.test(description);
|
||||
if (!hasHealing && describesHealing) effects.unshift({ kind: "heal", coefficient: 1 });
|
||||
const conditionalHealing = effects.some(effect => effect.kind === "apply-aura" && (
|
||||
effect.aura.procs?.length || effect.aura.modifiers?.some(modifier => modifier.kind === "healing")
|
||||
)) || effects.some(effect => ["resurrection", "summon", "resource", "trigger-spell"].includes(effect.kind));
|
||||
if (!hasHealing && describesHealing && !conditionalHealing) effects.unshift({ kind: "heal", coefficient: 1 });
|
||||
const describesDamageCondition = /^\s*Dealing\b.*\b(?:reduces?|increases?)\b/i.test(description);
|
||||
if (!hasDamage && describesDamage && !describesDamageCondition) effects.unshift({ kind: "damage", coefficient: 1 });
|
||||
if (!hasDamage && !hasHealing && describesDamage && !describesDamageCondition
|
||||
&& !/\b(?:reduces?|reduced|less|prevent\w*)\b[^.]*\bdamage\b/i.test(description)) effects.unshift({ kind: "damage", coefficient: 1 });
|
||||
if (!effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
||||
if (/\bswap health percentages\b/i.test(description)) {
|
||||
const minimum = Number(description.match(/to\s+(\d+(?:\.\d+)?)%/)?.[1] ?? 40);
|
||||
@@ -1043,12 +1113,13 @@ function targetForSpell(
|
||||
effects: readonly AbilityEffect[],
|
||||
description = "",
|
||||
): AbilityTarget {
|
||||
if (spell?.name === "Protective Roar") return "self";
|
||||
const targets = spell?.effects.flatMap((effect) => [effect.implicitTargetA, effect.implicitTargetB]) ?? [];
|
||||
const offensive = effects.some((effect) => (
|
||||
["damage", "dot", "taunt", "status", "interrupt", "pull", "execute", "resource-drain", "threat"].includes(effect.kind)
|
||||
|| (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")
|
||||
|| (effect.kind === "dispel" && effect.relationship === "hostile")
|
||||
|| (effect.kind === "trigger-spell" && targets.some((target) => HOSTILE_TARGETS.has(target)))
|
||||
|| (effect.kind === "trigger-spell" && !effect.expanded && targets.some((target) => HOSTILE_TARGETS.has(target)))
|
||||
));
|
||||
const beneficial = effects.some((effect) => (
|
||||
["heal", "hot", "shield", "resurrection"].includes(effect.kind)
|
||||
@@ -1096,7 +1167,7 @@ function resourceForSpell(fallback: ResourceType, spell: CoaDbcSpell | undefined
|
||||
|
||||
function liveTooltipCost(resource: ResourceType, tooltip: string, fallback: AbilityCost): AbilityCost {
|
||||
const percentage = tooltip.match(/(\d+(?:\.\d+)?)%\s+of base mana/i);
|
||||
if (percentage) return { resource: "mana", amount: Number(percentage[1]), percentage: true };
|
||||
if (percentage) return { resource: "mana", amount: Number(percentage[1]), percentage: true, percentageBasis: "base-mana" };
|
||||
const resourceName = resource === "runic-power" ? "Runic Power" : resource.replace(/(^|-)([a-z])/g, (_match, _separator, letter) => ` ${letter.toUpperCase()}`).trim();
|
||||
const direct = tooltip.match(new RegExp(`(\\d+(?:\\.\\d+)?)\\s+${resourceName}`, "i"));
|
||||
return direct ? { resource, amount: Number(direct[1]) } : fallback;
|
||||
@@ -1105,7 +1176,8 @@ function liveTooltipCost(resource: ResourceType, tooltip: string, fallback: Abil
|
||||
function costForSpell(fallbackResource: ResourceType, spell: CoaDbcSpell | undefined): AbilityCost {
|
||||
if (!spell) return { resource: fallbackResource, amount: 0 };
|
||||
const resource = resourceForSpell(fallbackResource, spell);
|
||||
if (spell.powerCostPercentage > 0) return { resource, amount: spell.powerCostPercentage, percentage: true };
|
||||
if (spell.powerCostPercentage > 0) return { resource, amount: spell.powerCostPercentage, percentage: true,
|
||||
...(resource === "mana" ? { percentageBasis: "base-mana" as const } : {}) };
|
||||
const amount = resource === "rage" || resource === "runic-power"
|
||||
? Math.round(spell.powerCost / 10)
|
||||
: spell.powerCost;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { COA_CLASS_IDS } from "../app/characterCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
|
||||
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const boss = finalDungeonBoss(dungeon)!;
|
||||
const samples: unknown[] = [];
|
||||
|
||||
afterAll(() => {
|
||||
if (process.env.HEALER_BALANCE_REPORT !== "1") return;
|
||||
mkdirSync("artifacts/boss-difficulty", { recursive: true });
|
||||
writeFileSync("artifacts/boss-difficulty/coa-healer-balance.json", `${JSON.stringify({
|
||||
dungeon: dungeon.id, difficulty: 1, durationLimitSeconds: 60, policy: BENCHMARK_POLICY_ID,
|
||||
gear: "resolved starter heirlooms", samples,
|
||||
}, null, 2)}\n`);
|
||||
});
|
||||
|
||||
describe("default difficulty CoA healer rotations", () => {
|
||||
for (const level of [8, 20, 40, 80]) {
|
||||
it(`sustains healing against final boss attacks at level ${level}`, async () => {
|
||||
const builds = (await loadCanonicalBenchmarkBuilds(level, COA_CLASS_IDS)).filter(build => build.role === "healer");
|
||||
expect(builds).toHaveLength(9);
|
||||
const failures: string[] = [];
|
||||
for (const build of builds) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
for (const seed of ["class-balance-1", "class-balance-2", "class-balance-3"]) {
|
||||
const result = await runDungeonBossBenchmark(build, boss.id, seed, true);
|
||||
samples.push({ level, classId: build.classId, orientation: build.orientation, seed, result });
|
||||
if (!result.playerAlive || result.allyHealing <= 0 || result.deaths > 0) {
|
||||
failures.push(`${build.id}:${seed}: ${result.outcome}, ${result.deaths} deaths, ${result.allyHealing} ally healing`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
}, 120_000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
|
||||
import { equipmentAdjustedCombatAmount, resolvedAbilityCosts, useCombatStore } from "./combatStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { baseResourcesFor } from "./wotlkStats";
|
||||
import { companionActorFromMember } from "./combatActorAuthority";
|
||||
import { BenchmarkController } from "./benchmark/benchmarkController";
|
||||
import { benchmarkScenarioById } from "./benchmark/benchmarkScenarios";
|
||||
|
||||
describe("CoA healer resources and recipients", () => {
|
||||
beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); });
|
||||
|
||||
it("charges base mana independently of the boosted pool and equipment", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 80 });
|
||||
const state = useCombatStore.getState();
|
||||
const heal = abilityAtLevel(abilitiesForClass("chronomancer").find(a => a.name === "Reverse Wound")!, 80);
|
||||
const small = resolvedAbilityCosts(heal, [], { ...state, maxResource: 1_000 });
|
||||
const large = resolvedAbilityCosts(heal, [], { ...state, maxResource: 50_000 });
|
||||
expect(small).toEqual(large);
|
||||
expect(small).toEqual([{ resource: "mana", amount: Math.round(baseResourcesFor("chronomancer", 80).mana * heal.cost.amount / 100) }]);
|
||||
expect(resolvedAbilityCosts({ ...heal, costs: undefined, cost: { resource: "health", amount: 8, percentage: true } }, [], state))
|
||||
.toEqual([{ resource: "health", amount: Math.round(state.maxHealth * .08) }]);
|
||||
});
|
||||
|
||||
it("preserves Tinker's secondary mana for both the player and companion actor", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "tinker", role: "healer", level: 80 });
|
||||
const state = useCombatStore.getState();
|
||||
usePartyStore.getState().initializeParty("tinker-mana", 80, state.health);
|
||||
const member = { ...usePartyStore.getState().members[0], classId: "tinker" as const, raceId: state.raceId, level: 80,
|
||||
resourceType: "energy" as const, maxResource: state.maxResource, resourcePools: state.resourcePools, gearStats: state.gearStats };
|
||||
const actor = companionActorFromMember(member);
|
||||
expect(state.maxResourcePools.mana).toBeGreaterThan(1_000);
|
||||
expect(actor.resources.mana.current).toBe(state.resourcePools.mana);
|
||||
expect(actor.resources.mana.maximum).toBe(state.maxResourcePools.mana);
|
||||
});
|
||||
|
||||
it("sends Protection From Light and its periodic heal to the selected ally", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "cultist", role: "healer", level: 80 });
|
||||
usePartyStore.getState().initializeParty("protection-heal", 80, useCombatStore.getState().health, "healer", 2);
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||
usePartyStore.getState().damageMember(member.id, 500);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
const before = usePartyStore.getState().members[0].health;
|
||||
const spell = abilitiesForClass("cultist").find(a => a.name === "Protection From Light")!;
|
||||
expect(spell.target).toBe("friendly");
|
||||
expect(useCombatStore.getState().castAbility(spell.id, undefined, 1_000).ok).toBe(true);
|
||||
useCombatStore.getState().tick(.1, 3_000);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(before);
|
||||
expect(useCombatStore.getState().effects.some(e => e.kind === "hot" && e.targetId === member.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("starts Bloodmage weapon attacks with its hostile life-stealing bite", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "bloodmage", role: "healer", level: 8 });
|
||||
usePartyStore.getState().initializeParty("blood-rage", 8, useCombatStore.getState().health, "healer", 2);
|
||||
useCombatStore.getState().registerMob("boss", { name: "Boss", boss: true, level: 8, maxHealth: 100_000 });
|
||||
setMobPosition("boss", [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob("boss");
|
||||
const bite = abilitiesForClass("bloodmage").find(a => a.name === "Ravenous Bite (Packleader)")!;
|
||||
expect(useCombatStore.getState().castAbility(bite.id, undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().autoAttackTargetId).toBe("boss");
|
||||
useCombatStore.getState().tick(.1, 2_500);
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses Sanguine Mend's authored spell power, attack power and Spirit coefficients", () => {
|
||||
const spell = abilityAtLevel(abilitiesForClass("bloodmage").find(a => a.name === "Sanguine Mend")!, 80);
|
||||
const effect = spell.effects.find(e => e.kind === "heal")!;
|
||||
expect(effect.kind).toBe("heal");
|
||||
if (effect.kind !== "heal") throw new Error("Missing heal");
|
||||
const amount = (gear: typeof EMPTY_ITEM_STATS) => equipmentAdjustedCombatAmount(effect, 80, gear, "bloodmage", spell);
|
||||
const base = amount(EMPTY_ITEM_STATS);
|
||||
expect(amount({ ...EMPTY_ITEM_STATS, spellPower: 100 }) - base).toBe(58);
|
||||
expect(amount({ ...EMPTY_ITEM_STATS, attackPower: 100 }) - base).toBe(20);
|
||||
// Human Spirit also applies to the added Spirit.
|
||||
expect(amount({ ...EMPTY_ITEM_STATS, spirit: 100 }) - base).toBe(26);
|
||||
});
|
||||
|
||||
it("lets a maintained heal tick while the caster is at full health", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "druid", role: "healer", level: 8 });
|
||||
usePartyStore.getState().initializeParty("hot-maintenance", 8, useCombatStore.getState().health, "healer", 2);
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||
usePartyStore.getState().damageMember(member.id, member.maxHealth * .5);
|
||||
const before = usePartyStore.getState().members[0].health;
|
||||
useCombatStore.setState({ abilities: abilitiesForClass("druid").filter(a => a.name === "Rejuvenation") });
|
||||
const controller = new BenchmarkController();
|
||||
const scenario = benchmarkScenarioById("heal-stacked-party")!;
|
||||
expect(controller.act(1_000, "healer", scenario, false).cast).toBe(true);
|
||||
const nextTick = useCombatStore.getState().effects[0].nextTickAt;
|
||||
expect(controller.act(2_500, "healer", scenario, false).cast).toBe(false);
|
||||
expect(useCombatStore.getState().effects[0].nextTickAt).toBe(nextTick);
|
||||
useCombatStore.getState().tick(.1, nextTick);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it("heals nearby allies with Mending Mist while respecting its radius", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "venomancer", role: "healer", level: 80 });
|
||||
usePartyStore.getState().initializeParty("mist-radius", 80, useCombatStore.getState().health, "healer", 4);
|
||||
const members = usePartyStore.getState().members;
|
||||
for (const [index, member] of members.entries()) {
|
||||
registerPartyRuntimePosition(member.id, [index === 2 ? 100 : 3 + index, 0, 0]);
|
||||
usePartyStore.getState().damageMember(member.id, 1_000);
|
||||
}
|
||||
const before = usePartyStore.getState().members.map(m => m.health);
|
||||
const spell = abilitiesForClass("venomancer").find(a => a.name === "Mending Mist")!;
|
||||
expect(spell.target).toBe("self");
|
||||
expect(useCombatStore.getState().castAbility(spell.id, undefined, 1_000).ok).toBe(true);
|
||||
useCombatStore.getState().tick(.1, 3_000);
|
||||
useCombatStore.getState().tick(.1, 4_000);
|
||||
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(before[0]);
|
||||
expect(usePartyStore.getState().members[1].health).toBeGreaterThan(before[1]);
|
||||
expect(usePartyStore.getState().members[2].health).toBe(before[2]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,13 @@ import {
|
||||
type CombatActorState,
|
||||
} from "./combatActors";
|
||||
import type { PartyMember } from "./partyStore";
|
||||
import { baseClassFor, isRomClassId } from "../app/characterCatalog";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
|
||||
export function companionResourceMaximum(member: PartyMember, id: string): number {
|
||||
return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId)
|
||||
? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana : 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the authoritative companion actor. Existing actor values win over
|
||||
@@ -21,7 +28,7 @@ export function companionActorFromMember(
|
||||
normalizeActorResourcePool(
|
||||
id,
|
||||
current ?? 0,
|
||||
id === member.resourceType ? member.maxResource : 100,
|
||||
companionResourceMaximum(member, id),
|
||||
id === member.resourceType ? profile.regenerationPerSecond : 0,
|
||||
member.lastResourceSpentAt[id as ResourceType] ?? 0,
|
||||
),
|
||||
@@ -84,7 +91,7 @@ export function companionActorWithMemberRuntime(
|
||||
normalizeActorResourcePool(
|
||||
id,
|
||||
current ?? 0,
|
||||
id === member.resourceType ? member.maxResource : actor.resources[id]?.maximum ?? 100,
|
||||
companionResourceMaximum(member, id),
|
||||
id === member.resourceType ? profile.regenerationPerSecond : actor.resources[id]?.regenerationPerSecond ?? 0,
|
||||
member.lastResourceSpentAt[id as ResourceType] ?? actor.resources[id]?.lastSpentAt ?? 0,
|
||||
),
|
||||
|
||||
+110
-40
@@ -3,6 +3,7 @@ import { combatNow } from "./combatClock";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
baseClassFor,
|
||||
isRomClassId,
|
||||
type ClassId,
|
||||
type RaceId,
|
||||
} from "../app/characterCatalog";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
resourceProfileForClass,
|
||||
triggeredAbilityBySpellId,
|
||||
type AbilityAmountScaling,
|
||||
type AbilityDamageResource,
|
||||
type AbilityDefinition,
|
||||
type AbilityEffect,
|
||||
type MobStatusKind,
|
||||
@@ -155,6 +157,7 @@ import {
|
||||
type ItemStats,
|
||||
} from "./itemStats";
|
||||
import {
|
||||
baseResourcesFor,
|
||||
damageAfterEquipmentMitigation,
|
||||
deriveCharacterStats,
|
||||
equipmentAttackPower,
|
||||
@@ -164,6 +167,7 @@ import {
|
||||
type CharacterStats,
|
||||
type WotlkAttackKind,
|
||||
} from "./wotlkStats";
|
||||
import { healingRecipientIds } from "./healingAbilityRules";
|
||||
import {
|
||||
combatAttackerFromCharacterStats,
|
||||
combatDefenderFromCharacterStats,
|
||||
@@ -417,7 +421,7 @@ export interface ActiveCast {
|
||||
readonly nextTickAt: number;
|
||||
}
|
||||
|
||||
export interface TimedCombatEffect {
|
||||
export interface TimedCombatEffect extends AbilityDamageResource {
|
||||
readonly id: string;
|
||||
readonly kind: "dot" | "hot";
|
||||
readonly sourceAbilityId: string;
|
||||
@@ -431,6 +435,8 @@ export interface TimedCombatEffect {
|
||||
readonly pointsPerLevel?: number;
|
||||
readonly coefficientPerLevel?: number;
|
||||
readonly bonusPowerCoefficient?: number;
|
||||
readonly attackPowerCoefficient?: number;
|
||||
readonly spiritCoefficient?: number;
|
||||
readonly sourceLevel?: number;
|
||||
readonly maxScalingLevel?: number;
|
||||
readonly remainingTicks: number;
|
||||
@@ -1193,7 +1199,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
|
||||
));
|
||||
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
|
||||
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
||||
if (healing) return "spell";
|
||||
if (healing && ability.target !== "hostile") return "spell";
|
||||
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
||||
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
||||
return "spell";
|
||||
@@ -1253,7 +1259,11 @@ export function equipmentAdjustedCombatAmount(
|
||||
0,
|
||||
scaling.bonusPowerCoefficient ?? coefficient,
|
||||
);
|
||||
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower));
|
||||
const attributes = scaling.attackPowerCoefficient || scaling.spiritCoefficient
|
||||
? deriveCharacterStats(classId, level, gearStats) : null;
|
||||
const scriptedPower = (attributes?.attackPower ?? 0) * (scaling.attackPowerCoefficient ?? 0)
|
||||
+ (attributes?.spirit ?? 0) * (scaling.spiritCoefficient ?? 0);
|
||||
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower + scriptedPower));
|
||||
}
|
||||
|
||||
function playerCharacterStats(
|
||||
@@ -1609,6 +1619,7 @@ function initialResourcePools(
|
||||
classId: ClassId,
|
||||
abilities: readonly AbilityDefinition[],
|
||||
activeMaximum: number,
|
||||
manaMaximum = 100,
|
||||
): { pools: Partial<Record<ResourceType, number>>; maximums: Partial<Record<ResourceType, number>> } {
|
||||
const activeProfile = resourceProfileForClass(classId);
|
||||
const resourceTypes = new Set<ResourceType>([activeProfile.type]);
|
||||
@@ -1617,7 +1628,7 @@ function initialResourcePools(
|
||||
const pools: Partial<Record<ResourceType, number>> = {};
|
||||
for (const type of resourceTypes) {
|
||||
if (type === "health") continue;
|
||||
const maximum = type === activeProfile.type ? activeMaximum : defaultResourceMaximum(type);
|
||||
const maximum = type === activeProfile.type ? activeMaximum : type === "mana" ? manaMaximum : defaultResourceMaximum(type);
|
||||
maximums[type] = maximum;
|
||||
pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
|
||||
? 0
|
||||
@@ -1629,12 +1640,14 @@ function initialResourcePools(
|
||||
export function resolvedAbilityCosts(
|
||||
ability: AbilityDefinition,
|
||||
modifiers: readonly TalentModifier[],
|
||||
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
|
||||
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras" | "level">>,
|
||||
): readonly { resource: ResourceType; amount: number }[] {
|
||||
const aggregated = new Map<ResourceType, number>();
|
||||
const activeResourceType = resourceProfileForClass(state.classId).type;
|
||||
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
|
||||
const maximum = cost.resource === "health"
|
||||
const maximum = cost.percentageBasis === "base-mana"
|
||||
? baseResourcesFor(state.classId, state.level ?? ability.unlockLevel).mana
|
||||
: cost.resource === "health"
|
||||
? state.maxHealth
|
||||
: cost.resource === activeResourceType
|
||||
? state.maxResource
|
||||
@@ -2618,15 +2631,16 @@ function damageMobInWork(
|
||||
const health = mob.health - applied;
|
||||
const killed = health <= 0;
|
||||
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
|
||||
const canAward = killed && !mob.experienceGranted;
|
||||
const testing = Boolean(useGameStore.getState().bossTest);
|
||||
const canAward = killed && !mob.experienceGranted && !testing;
|
||||
const beforeLevel = work.progression.level;
|
||||
const pendingLoot = killed && mob.lootSourceId
|
||||
const pendingLoot = testing ? [] : killed && mob.lootSourceId
|
||||
? rollBossLoot(mob.lootSourceId, beforeLevel, {
|
||||
now,
|
||||
bonusItemChance: mob.bonusLootChance,
|
||||
})
|
||||
: mob.pendingLoot;
|
||||
const card = killed && mob.boss
|
||||
const card = killed && mob.boss && !testing
|
||||
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
|
||||
: null;
|
||||
const bossCardDrop = card ? {
|
||||
@@ -2635,7 +2649,7 @@ function damageMobInWork(
|
||||
claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`,
|
||||
rarities: rollBossCards(),
|
||||
} satisfies BossCardDrop : mob.bossCardDrop;
|
||||
const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
|
||||
const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
|
||||
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
|
||||
engageMobGroupInWork(work, id, now, source.actorId);
|
||||
const engagedMob = work.mobs[id] ?? mob;
|
||||
@@ -2887,7 +2901,7 @@ function executeTriggeredSpellInWork(
|
||||
ability,
|
||||
);
|
||||
for (const mobId of mobTargets) {
|
||||
resolveAndDamageMobInWork(
|
||||
const result = resolveAndDamageMobInWork(
|
||||
work.classId,
|
||||
work,
|
||||
mobId,
|
||||
@@ -2899,6 +2913,7 @@ function executeTriggeredSpellInWork(
|
||||
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
||||
ability,
|
||||
);
|
||||
grantDamageResourceInWork(work, effect, result.amount, sourceId, ability.id, now);
|
||||
}
|
||||
} else if (effect.kind === "heal") {
|
||||
const rawAmount = talentAdjustedAmount(
|
||||
@@ -2907,17 +2922,21 @@ function executeTriggeredSpellInWork(
|
||||
"healing-percent",
|
||||
ability,
|
||||
);
|
||||
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
|
||||
const amount = Math.round(resolveAuraHealingValue(
|
||||
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
|
||||
aurasForEntity(work.auras, recipient),
|
||||
"taken",
|
||||
));
|
||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
work.totalHealing += healPlayerInWork(work, amount);
|
||||
} else {
|
||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||
work.totalHealing += healed;
|
||||
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
|
||||
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
||||
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
|
||||
for (const recipient of recipients) {
|
||||
const amount = Math.round(resolveAuraHealingValue(
|
||||
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
|
||||
aurasForEntity(work.auras, recipient),
|
||||
"taken",
|
||||
));
|
||||
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
||||
work.totalHealing += healPlayerInWork(work, amount);
|
||||
} else {
|
||||
const healed = healCompanionActorInWork(work, recipient, amount);
|
||||
work.totalHealing += healed;
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "status") {
|
||||
for (const mobId of mobTargets) {
|
||||
@@ -2972,10 +2991,14 @@ function executeTriggeredSpellInWork(
|
||||
));
|
||||
}
|
||||
} else if (effect.kind === "hot") {
|
||||
const recipient = targetId && !work.mobs[targetId] ? targetId : null;
|
||||
work.effects.push(timedEffect(
|
||||
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
|
||||
));
|
||||
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
|
||||
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
||||
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
|
||||
for (const recipient of recipients) {
|
||||
work.effects.push(timedEffect(
|
||||
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
|
||||
));
|
||||
}
|
||||
} else if (effect.kind === "shield") {
|
||||
const amount = equipmentAdjustedCombatAmount(
|
||||
effect, work.progression.level, work.gearStats, work.classId, ability,
|
||||
@@ -3106,12 +3129,43 @@ function executeTriggeredSpellInWork(
|
||||
}
|
||||
}
|
||||
|
||||
function grantDamageResourceInWork(
|
||||
work: MutableCastWork, effect: AbilityDamageResource, damage: number,
|
||||
sourceActorId: string, abilityId: string, now: number,
|
||||
): void {
|
||||
const gain = effect.onDamageResource;
|
||||
if (!gain || damage <= 0 || gain.amount <= 0) return;
|
||||
const player = sourceActorId === PLAYER_AURA_ENTITY_ID;
|
||||
const actor = player ? null : ensureCompanionActorInWork(work, sourceActorId) ?? work.actors[sourceActorId];
|
||||
if (player ? work.health <= 0 : !actor?.alive) return;
|
||||
const pool = actor?.resources[gain.resource];
|
||||
const maximum = player ? work.maxResourcePools[gain.resource] ?? defaultResourceMaximum(gain.resource)
|
||||
: pool?.maximum ?? defaultResourceMaximum(gain.resource);
|
||||
const current = player ? work.resourcePools[gain.resource] ?? 0 : pool?.current ?? 0;
|
||||
const auras = aurasForEntity(work.auras, sourceActorId);
|
||||
const requested = Math.max(0, resolveAuraResourceValue(
|
||||
resolveAuraResourceValue(gain.amount, auras, gain.resource, "generation"), auras, "power", "generation",
|
||||
));
|
||||
const restored = Math.max(0, Math.min(requested, maximum - current));
|
||||
if (player) {
|
||||
work.resourcePools[gain.resource] = current + restored;
|
||||
if (gain.resource === resourceProfileForClass(work.classId).type) work.resource = current + restored;
|
||||
} else if (actor) {
|
||||
const next = { ...actor, resources: { ...actor.resources,
|
||||
[gain.resource]: { ...(pool ?? normalizeActorResourcePool(gain.resource, current, maximum)), current: current + restored } } };
|
||||
work.actors[sourceActorId] = next;
|
||||
mirrorCompanionActor(next);
|
||||
}
|
||||
recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: sourceActorId,
|
||||
abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false });
|
||||
}
|
||||
|
||||
function timedEffect(
|
||||
kind: "dot" | "hot",
|
||||
abilityId: string,
|
||||
sourceActorId: AggroActorId,
|
||||
targetId: string | null,
|
||||
scaling: AbilityAmountScaling & { readonly coefficient: number },
|
||||
scaling: AbilityAmountScaling & AbilityDamageResource & { readonly coefficient: number; readonly startsImmediately?: boolean },
|
||||
ticks: number,
|
||||
intervalMs: number,
|
||||
now: number,
|
||||
@@ -3132,11 +3186,14 @@ function timedEffect(
|
||||
...(scaling.pointsPerLevel === undefined ? {} : { pointsPerLevel: scaling.pointsPerLevel }),
|
||||
...(scaling.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: scaling.coefficientPerLevel }),
|
||||
...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }),
|
||||
...(scaling.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: scaling.attackPowerCoefficient }),
|
||||
...(scaling.spiritCoefficient === undefined ? {} : { spiritCoefficient: scaling.spiritCoefficient }),
|
||||
...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }),
|
||||
...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }),
|
||||
...(scaling.onDamageResource ? { onDamageResource: scaling.onDamageResource } : {}),
|
||||
remainingTicks: ticks,
|
||||
intervalMs,
|
||||
nextTickAt: now + intervalMs,
|
||||
nextTickAt: now + (scaling.startsImmediately ? 0 : intervalMs),
|
||||
...(tag ? { tag } : {}),
|
||||
...(damageSchool ? { damageSchool } : {}),
|
||||
...(spellPenetration ? { spellPenetration } : {}),
|
||||
@@ -3495,7 +3552,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
raceId,
|
||||
secondaryClassId,
|
||||
);
|
||||
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
||||
// RoM secondary pools use native resource limits, not the mapped WoW class's mana.
|
||||
const initialPools = initialResourcePools(classId, abilities, maxResource,
|
||||
isRomClassId(classId) ? defaultResourceMaximum("mana")
|
||||
: deriveCharacterStats(baseClassFor(classId), progression.level, gearStats, raceId).mana);
|
||||
set((state) => ({
|
||||
...initialState(),
|
||||
classId,
|
||||
@@ -4500,7 +4560,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
summons: [...state.summons],
|
||||
areaEffects: [...state.areaEffects],
|
||||
combatEvents: [...state.combatEvents],
|
||||
actors: { ...state.actors },
|
||||
actors: { ...combatActorSnapshots(state) },
|
||||
cooldowns: { ...state.cooldowns },
|
||||
health: healthAfterCosts,
|
||||
maxHealth: state.maxHealth,
|
||||
@@ -4606,7 +4666,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
ability,
|
||||
);
|
||||
for (const id of affectedMobIds) {
|
||||
resolveAndDamageMobInWork(
|
||||
const result = resolveAndDamageMobInWork(
|
||||
state.classId,
|
||||
work,
|
||||
id,
|
||||
@@ -4618,6 +4678,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
||||
ability,
|
||||
);
|
||||
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
|
||||
}
|
||||
} else if (effect.kind === "finisher-damage") {
|
||||
const coefficient = effect.baseCoefficient + effect.perComboCoefficient * work.comboPoints;
|
||||
@@ -4723,6 +4784,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
const criticalChance = Math.max(0, casterStats.spellCritPercent / 100);
|
||||
let directHealing = 0;
|
||||
healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
|
||||
id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)];
|
||||
for (const healingTargetId of [...new Set(healingTargetIds)]) {
|
||||
const friendlyTarget = partyMembers.find((member) => member.id === healingTargetId);
|
||||
const friendlySummon = work.summons.find((summon) => summon.id === healingTargetId);
|
||||
@@ -5029,14 +5092,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
} else if (effect.kind === "hot") {
|
||||
if (targeting?.shape === "ground") continue;
|
||||
const hotTargetId = effectiveTarget === "friendly" ? targetId : null;
|
||||
work.effects = work.effects.filter((entry) => !(
|
||||
entry.kind === "hot"
|
||||
&& entry.sourceAbilityId === ability.id
|
||||
&& entry.targetId === hotTargetId
|
||||
));
|
||||
work.effects.push(timedEffect(
|
||||
"hot", ability.id, castThreatSource.actorId, hotTargetId, effect, effect.ticks, effect.intervalMs, now,
|
||||
));
|
||||
const recipients = healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
|
||||
id => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
||||
[hotTargetId ?? PLAYER_AURA_ENTITY_ID], combatHasLineOfSight, now);
|
||||
for (const recipient of recipients) {
|
||||
const normalizedTarget = recipient === PLAYER_AURA_ENTITY_ID ? null : recipient;
|
||||
work.effects = work.effects.filter((entry) => !(
|
||||
entry.kind === "hot"
|
||||
&& entry.sourceAbilityId === ability.id
|
||||
&& entry.targetId === normalizedTarget
|
||||
));
|
||||
work.effects.push(timedEffect(
|
||||
"hot", ability.id, castThreatSource.actorId, normalizedTarget, effect, effect.ticks, effect.intervalMs, now,
|
||||
));
|
||||
}
|
||||
} else if (effect.kind === "taunt") {
|
||||
const friendlyAttackers = effectiveTarget === "friendly" && targetId
|
||||
? Object.values(work.mobs)
|
||||
@@ -7849,7 +7918,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: baseAmount;
|
||||
if (effect.kind === "dot" && effect.targetId) {
|
||||
const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell";
|
||||
resolveAndDamageMobInWork(
|
||||
const result = resolveAndDamageMobInWork(
|
||||
state.classId,
|
||||
work,
|
||||
effect.targetId,
|
||||
@@ -7869,6 +7938,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
canBlock: false,
|
||||
},
|
||||
);
|
||||
grantDamageResourceInWork(work, effect, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt);
|
||||
} else if (effect.kind === "hot") {
|
||||
const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
||||
const modifiedHealing = Math.round(resolveAuraHealingValue(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { BASE_CLASS_IDS, COA_CLASS_IDS } from "../app/characterCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||
|
||||
@@ -15,7 +16,7 @@ afterAll(() => {
|
||||
mkdirSync("artifacts/boss-difficulty", { recursive: true });
|
||||
writeFileSync("artifacts/boss-difficulty/wow-coa-balance.json", `${JSON.stringify({
|
||||
difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60,
|
||||
targets: { ordinary: 25, final: 45 }, policy: "baseline-v1", gear: "resolved starter heirlooms", samples,
|
||||
targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples,
|
||||
}, null, 2)}\n`);
|
||||
});
|
||||
|
||||
|
||||
@@ -199,8 +199,27 @@ export type DungeonId = string;
|
||||
|
||||
export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY));
|
||||
|
||||
// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable.
|
||||
const bossTestingDefinitions = new Map<string, { definition: DungeonDefinition; sourceDungeonId: string }>();
|
||||
|
||||
export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void {
|
||||
if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) {
|
||||
throw new Error("Invalid boss testing dungeon.");
|
||||
}
|
||||
bossTestingDefinitions.clear();
|
||||
bossTestingDefinitions.set(definition.id, { definition, sourceDungeonId });
|
||||
}
|
||||
|
||||
export function bossTestingSourceDungeonId(id: string): string | null {
|
||||
return bossTestingDefinitions.get(id)?.sourceDungeonId ?? null;
|
||||
}
|
||||
|
||||
export function clearBossTestingDungeons(): void {
|
||||
bossTestingDefinitions.clear();
|
||||
}
|
||||
|
||||
export function dungeonDefinitionById(id: string): DungeonDefinition | null {
|
||||
return DUNGEON_REGISTRY[id] ?? null;
|
||||
return bossTestingDefinitions.get(id)?.definition ?? DUNGEON_REGISTRY[id] ?? null;
|
||||
}
|
||||
|
||||
export function requireDungeonDefinition(id: string): DungeonDefinition {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore, type GameMode } from "./store";
|
||||
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||
import { useGameStore, type BossTestSession, type GameMode } from "./store";
|
||||
import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
import type { GmReviewTarget } from "./gmContentReview";
|
||||
import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
|
||||
@@ -27,6 +27,7 @@ export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
readonly bossTest?: BossTestSession | null;
|
||||
readonly gmReviewTarget?: GmReviewTarget | null;
|
||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||
readonly preserveCharacter?: boolean;
|
||||
@@ -47,7 +48,10 @@ export function activateDungeonSession(
|
||||
options: DungeonSessionOptions = {},
|
||||
): boolean {
|
||||
const definition = dungeonDefinitionById(dungeonId);
|
||||
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
|
||||
const testingSource = bossTestingSourceDungeonId(dungeonId);
|
||||
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
|
||||
if (!definition || (!testing && !dungeonCanEnter(dungeonId))
|
||||
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
|
||||
return false;
|
||||
}
|
||||
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
|
||||
@@ -107,6 +111,7 @@ export function activateDungeonSession(
|
||||
gameMode: options.gameMode,
|
||||
spawn: shelvedDungeonSpawn(dungeonId, options.spawn),
|
||||
gmTestMode: options.gmTestMode,
|
||||
bossTest: options.bossTest,
|
||||
gmReviewTarget: options.gmReviewTarget,
|
||||
bossDifficulty,
|
||||
});
|
||||
@@ -128,6 +133,7 @@ export function restartActiveDungeonSession(): boolean {
|
||||
gameMode: game.gameMode,
|
||||
spawn: game.activeSpawn,
|
||||
gmTestMode: game.gmTestMode,
|
||||
bossTest: game.bossTest,
|
||||
gmReviewTarget: game.activeGmReviewTarget,
|
||||
preserveCharacter: true,
|
||||
bossDifficulty: game.activeBossDifficulty,
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"barbarian","abilities":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptiLine truncated
|
||||
{"schemaVersion":1,"classId":"barbarian","abilities":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"namLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"bloodmage","abilities":[{"id":"coa-tree-29871","classId":"bloodmage","dbcSpellId":504285,"name":"Alacrity","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AL","description":"Increases all primary stats by 4%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504285,"level":1,"description":"Increases all primary stats by 4%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]},{"rank":2,"spellId":504893,"level":1,"description":"Increases all primary stats by 8%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504893:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.08}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]}],"talentEntryId":29871},{"id":"coa-20-atheranns-wickedness-73-704642","classId":"bloodmage","dbcSpellId":704642,"name":"Atherann's Wickedness","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_destructivesoul.png","sigil":"AS","description":"Atherann's WickednessRank 1Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704642,"level":1,"description":"Atherann's WickednessRank 1Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":704643,"level":1,"description":"Atherann's WickednessRank 2Increases the damage of Atherann's Anguish by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704643:effect:0:aura:108","name":"Atherann's Wickedness (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]}]},{"id":"coa-tree-8215","classId":"bloodmage","dbcSpellId":504627,"name":"Blood Bond","unlockLevel":1,"icon":"/assets/ui/spells/nhi_bloodsacrifice_border.png","sigil":"BB","description":"Bond yourself to an ally for 30 minutes, causing direct damage taken by tLine truncated
|
||||
{"schemaVersion":1,"classId":"bloodmage","abilities":[{"id":"coa-tree-29871","classId":"bloodmage","dbcSpellId":504285,"name":"Alacrity","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AL","description":"Increases all primary stats by 4%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504285,"level":1,"description":"Increases all primary stats by 4%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]},{"rank":2,"spellId":504893,"level":1,"description":"Increases all primary stats by 8%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504893:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.08}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]}],"talentEntryId":29871},{"id":"coa-20-atheranns-wickedness-73-704642","classId":"bloodmage","dbcSpellId":704642,"name":"Atherann's Wickedness","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_destructivesoul.png","sigil":"AS","description":"Atherann's WickednessRank 1Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704642,"level":1,"description":"Atherann's WickednessRank 1Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":704643,"level":1,"description":"Atherann's WickednessRank 2Increases the damage of Atherann's Anguish by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704643:effect:0:aura:108","name":"Atherann's Wickedness (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]}]},{"id":"coa-tree-8215","classId":"bloodmage","dbcSpellId":504627,"name":"Blood Bond","unlockLevel":1,"icon":"/assets/ui/spells/nhi_bloodsacrifice_border.png","sigil":"BB","description":"Bond yourself to an ally for 30 minutes, causing direct damage taken by tLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"chronomancer","abilities":[{"id":"coa-tree-30277","classId":"chronomancer","dbcSpellId":800857,"name":"Accelerated Recovery","unlockLevel":1,"icon":"/assets/ui/spells/spell_holy_healingfocus.png","sigil":"AR","description":"Accelerated RecoveryRank 118% of base mana Instant castAccelerates the bodies natural healing process, healing the target for (15*1000/3)*(12+0+Heal*0.23) over 15 sec. Periodic healing done by this scales with your spell haste.","target":"friendly","range":{"min":0,"max":40},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":18,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:800857:effect:0:aura:8","name":"Accelerated Recovery (Aura 8)","disposition":"buff","hideFromAuraStrip":true,"durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":6,"sourceAuraType":8,"miscValue":0,"triggerSpellId":0},{"kind":"hot","coefficient":0.24070173263549804,"basePoints":12,"dieSides":1,"pointsPerLevel":0.6052629947662354,"bonusPowerCoefficient":0,"sourceLevel":10,"maxScalingLevel":16,"ticks":5,"intervalMs":3000},{"kind":"apply-aura","aura":{"id":"coa:800857:effect:1:aura:283","name":"Accelerated Recovery (Aura 283)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":3,"sourceAuraType":283,"miscValue":0,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":800857,"level":1,"description":"Accelerated RecoveryRank 118% of base mana Instant castAccelerates the bodies natural healing process, healing the target for (15*1000/3)*(12+0+Heal*0.23) over 15 sec. Periodic healing done by this scales with your spell haste.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":18,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:800857:effect:0:aura:8","name":"Accelerated Recovery (Aura 8)","disposition":"buff","hideFromAuraStrip":true,"durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":6,"sourceAuraType":8,"miscValue":0,"triggerSpellId":0},{"kind":"hot","coefficient":0.24070173263549804,"basePoints":12,"dieSides":1,"pointsPerLevel":0.6052629947662354,"bonusPowerCoefficient":0,"sourceLevel":10,"maxScalingLevel":16,"ticks":5,"intervalMs":3000},{"kind":"apply-aura","aura":{"id":"coa:800857:effect:1:aura:283","name":"Accelerated Recovery (Aura 283)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":3,"sourceAuraType":283,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":30277},{"id":"coa-tree-4032","classId":"chronomancer","dbcSpellId":92119,"name":"Aeon of Resilience","unlockLevel":1,"icon":"/assets/ui/spells/inv_trinket_80_kultiras02a.png","sigil":"AO","description":"Level 10 Passive Teaches you Aeon of Resilience.Aeon of ResilienceReduces Epoch's cast time by -25% and it's mana cost by -10%, and healing done by it now reduces the target's damage taken by -5% for 6 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:92119:effect:0:aura:4","name":"Aeon of Resilience (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92119,"level":1,"description":"Level 10 Passive Teaches you Aeon of Resilience.Aeon of ResilienceReduces Epoch's cast time by -25% and it's mana cost by -10%, and healing done by it now reduces the target's damage taken by -5% for 6 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:92119:effect:0:aura:4","name":"Aeon of Resilience (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":4032},{"id":"coa-tree-6214","classId":"chronomancer","dbcSpellId":560948,"name":"Ahead of the Game","unlockLevel":1,"icon":"/assets/ui/spells/achievement_bg_win_av_x_times.png","sigil":"AO","description":"Every 10th instance of your damage dealt or healing now gives you 60 increased spell power for 10 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:560948:effect:0:aura:42","name":"AheadLine truncated
|
||||
{"schemaVersion":1,"classId":"chronomancer","abilities":[{"id":"coa-tree-30277","classId":"chronomancer","dbcSpellId":800857,"name":"Accelerated Recovery","unlockLevel":1,"icon":"/assets/ui/spells/spell_holy_healingfocus.png","sigil":"AR","description":"Accelerated RecoveryRank 118% of base mana Instant castAccelerates the bodies natural healing process, healing the target for (15*1000/3)*(12+0+Heal*0.23) over 15 sec. Periodic healing done by this scales with your spell haste.","target":"friendly","range":{"min":0,"max":40},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":18,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:800857:effect:0:aura:8","name":"Accelerated Recovery (Aura 8)","disposition":"buff","hideFromAuraStrip":true,"durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":6,"sourceAuraType":8,"miscValue":0,"triggerSpellId":0},{"kind":"hot","coefficient":0.24070173263549804,"basePoints":12,"dieSides":1,"pointsPerLevel":0.6052629947662354,"bonusPowerCoefficient":0.23,"sourceLevel":10,"maxScalingLevel":16,"ticks":5,"intervalMs":3000},{"kind":"apply-aura","aura":{"id":"coa:800857:effect:1:aura:283","name":"Accelerated Recovery (Aura 283)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":3,"sourceAuraType":283,"miscValue":0,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":800857,"level":1,"description":"Accelerated RecoveryRank 118% of base mana Instant castAccelerates the bodies natural healing process, healing the target for (15*1000/3)*(12+0+Heal*0.23) over 15 sec. Periodic healing done by this scales with your spell haste.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":18,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:800857:effect:0:aura:8","name":"Accelerated Recovery (Aura 8)","disposition":"buff","hideFromAuraStrip":true,"durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":6,"sourceAuraType":8,"miscValue":0,"triggerSpellId":0},{"kind":"hot","coefficient":0.24070173263549804,"basePoints":12,"dieSides":1,"pointsPerLevel":0.6052629947662354,"bonusPowerCoefficient":0.23,"sourceLevel":10,"maxScalingLevel":16,"ticks":5,"intervalMs":3000},{"kind":"apply-aura","aura":{"id":"coa:800857:effect:1:aura:283","name":"Accelerated Recovery (Aura 283)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"sourceEffectType":3,"sourceAuraType":283,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":30277},{"id":"coa-tree-4032","classId":"chronomancer","dbcSpellId":92119,"name":"Aeon of Resilience","unlockLevel":1,"icon":"/assets/ui/spells/inv_trinket_80_kultiras02a.png","sigil":"AO","description":"Level 10 Passive Teaches you Aeon of Resilience.Aeon of ResilienceReduces Epoch's cast time by -25% and it's mana cost by -10%, and healing done by it now reduces the target's damage taken by -5% for 6 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:92119:effect:0:aura:4","name":"Aeon of Resilience (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92119,"level":1,"description":"Level 10 Passive Teaches you Aeon of Resilience.Aeon of ResilienceReduces Epoch's cast time by -25% and it's mana cost by -10%, and healing done by it now reduces the target's damage taken by -5% for 6 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:92119:effect:0:aura:4","name":"Aeon of Resilience (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":4032},{"id":"coa-tree-6214","classId":"chronomancer","dbcSpellId":560948,"name":"Ahead of the Game","unlockLevel":1,"icon":"/assets/ui/spells/achievement_bg_win_av_x_times.png","sigil":"AO","description":"Every 10th instance of your damage dealt or healing now gives you 60 increased spell power for 10 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:5609Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"cultist","abilities":[{"id":"coa-25-abyssal-decay-89-500705","classId":"cultist","dbcSpellId":500705,"name":"Abyssal Decay","unlockLevel":1,"icon":"/assets/ui/spells/spell_priest_void_blast.png","sigil":"AD","description":"Abyssal DecayRank 19% of base mana Instant castApplies an Eldritch curse to an enemy, causing 9+(PL*0.6) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","target":"hostile","range":{"min":0,"max":30},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500705:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.24166666716337204,"basePoints":9,"dieSides":5,"pointsPerLevel":0.6000000238418579,"bonusPowerCoefficient":0,"sourceLevel":1,"maxScalingLevel":9,"ticks":6,"intervalMs":3000,"tag":"spell-500705"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":500705,"level":1,"description":"Abyssal DecayRank 19% of base mana Instant castApplies an Eldritch curse to an enemy, causing 9+(PL*0.6) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500705:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.24166666716337204,"basePoints":9,"dieSides":5,"pointsPerLevel":0.6000000238418579,"bonusPowerCoefficient":0,"sourceLevel":1,"maxScalingLevel":9,"ticks":6,"intervalMs":3000,"tag":"spell-500705"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":2,"spellId":502144,"level":10,"description":"Abyssal DecayRank 29% of base mana Instant castApplies an Eldritch curse to an enemy, causing 17+(PL*1-10*1) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502144:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.36066666603088376,"basePoints":17,"dieSides":1,"pointsPerLevel":1.0049999952316284,"bonusPowerCoefficient":0,"sourceLevel":10,"maxScalingLevel":18,"ticks":6,"intervalMs":3000,"tag":"spell-502144"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":3,"spellId":502145,"level":19,"description":"Abyssal DecayRank 39% of base mana Instant castApplies an Eldritch curse to an enemy, causing 31+(PL*1.4-19*1.4) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502145:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.5590245174426659,"basePoints":31,"dieSides":1,"pointsPerLevel":1.3695000410079956,"bonusPowerCoefficient":0,"sourceLevel":19,"maxScalingLevel":27,"ticks":6,"intervalMs":3000,"tag":"spell-502145"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":4,"spellId":502146,"level":28,"description":"Abyssal DecayRank 49% of base mana Instant castApplies an Eldritch curse to an enemy, causing 55+(PL*1.7-28*1.7) Shadow damage every 3 sec for18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502146:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellIdLine truncated
|
||||
{"schemaVersion":1,"classId":"cultist","abilities":[{"id":"coa-25-abyssal-decay-89-500705","classId":"cultist","dbcSpellId":500705,"name":"Abyssal Decay","unlockLevel":1,"icon":"/assets/ui/spells/spell_priest_void_blast.png","sigil":"AD","description":"Abyssal DecayRank 19% of base mana Instant castApplies an Eldritch curse to an enemy, causing 9+(PL*0.6) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","target":"hostile","range":{"min":0,"max":30},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500705:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.24166666716337204,"basePoints":9,"dieSides":5,"pointsPerLevel":0.6000000238418579,"bonusPowerCoefficient":0,"sourceLevel":1,"maxScalingLevel":9,"ticks":6,"intervalMs":3000,"tag":"spell-500705"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":500705,"level":1,"description":"Abyssal DecayRank 19% of base mana Instant castApplies an Eldritch curse to an enemy, causing 9+(PL*0.6) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500705:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.24166666716337204,"basePoints":9,"dieSides":5,"pointsPerLevel":0.6000000238418579,"bonusPowerCoefficient":0,"sourceLevel":1,"maxScalingLevel":9,"ticks":6,"intervalMs":3000,"tag":"spell-500705"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":2,"spellId":502144,"level":10,"description":"Abyssal DecayRank 29% of base mana Instant castApplies an Eldritch curse to an enemy, causing 17+(PL*1-10*1) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502144:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.36066666603088376,"basePoints":17,"dieSides":1,"pointsPerLevel":1.0049999952316284,"bonusPowerCoefficient":0,"sourceLevel":10,"maxScalingLevel":18,"ticks":6,"intervalMs":3000,"tag":"spell-502144"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":3,"spellId":502145,"level":19,"description":"Abyssal DecayRank 39% of base mana Instant castApplies an Eldritch curse to an enemy, causing 31+(PL*1.4-19*1.4) Shadow damage every 3 sec for 18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502145:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durationMs":18000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse"},"sourceEffectType":6,"sourceAuraType":3,"miscValue":0,"triggerSpellId":0},{"kind":"dot","coefficient":0.5590245174426659,"basePoints":31,"dieSides":1,"pointsPerLevel":1.3695000410079956,"bonusPowerCoefficient":0,"sourceLevel":19,"maxScalingLevel":27,"ticks":6,"intervalMs":3000,"tag":"spell-502145"},{"kind":"utility","action":"class-script","effectType":168,"auraType":0,"miscValue":120640,"triggerSpellId":0,"durationMs":18000}]},{"rank":4,"spellId":502146,"level":28,"description":"Abyssal DecayRank 49% of base mana Instant castApplies an Eldritch curse to an enemy, causing 55+(PL*1.7-28*1.7) Shadow damage every 3 sec for18 sec. Generates 5 Insanity.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":9,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:502146:effect:0:aura:3","name":"Abyssal Decay (Aura 3)","disposition":"debuff","hideFromAuraStrip":true,"durLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"felsworn","abilities":[{"id":"coa-tree-29686","classId":"felsworn","dbcSpellId":803904,"name":"Annihilation","unlockLevel":1,"icon":"/assets/ui/spells/ability_boss_lordanthricystgreen.png","sigil":"AN","description":"Gorge on fel energy, guaranteeing your next direct instance of damage to critically strike, but causing it to consume 5% of your maximum health. Lasts for 5 attacks or 12 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:803904:effect:0:aura:290","name":"Annihilation (Aura 290)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:803904:effect:1:aura:42","name":"Annihilation (Aura 42)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:803904:proc:1:807961","triggers":["crit","hit"],"chance":1,"charges":5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":807961}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":807961}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":803904,"level":1,"description":"Gorge on fel energy, guaranteeing your next direct instance of damage to critically strike, but causing it to consume 5% of your maximum health. Lasts for 5 attacks or 12 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:803904:effect:0:aura:290","name":"Annihilation (Aura 290)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:803904:effect:1:aura:42","name":"Annihilation (Aura 42)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:803904:proc:1:807961","triggers":["crit","hit"],"chance":1,"charges":5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":807961}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":807961}]}],"talentEntryId":29686},{"id":"coa-tree-30301","classId":"felsworn","dbcSpellId":560822,"name":"Anti-Magic","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_antimagicshell.png","sigil":"AM","description":"When Hateforged Barrier is removed from you, you now reduce -10% of all Magic damage taken for 8 seconds. In addition, Hateforged Barrier now heals for an additional 2% of your maximum health each tick.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:0:aura:4","name":"Anti-Magic (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:1:aura:107","name":"Anti-Magic (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":560822,"level":1,"description":"When Hateforged Barrier is removed from you, you now reduce -10% of all Magic damage taken for 8 seconds. In addition, Hateforged Barrier now heals for an additional 2% of your maximum health each tick.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:0:aura:4","name":"Anti-Magic (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:1:aura:107","name":"Anti-Magic (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}]}],"talentEntryId":30301},{"id":"coa-tree-33671","classId":"felsworn","dbcSpellId":706266,"namLine truncated
|
||||
{"schemaVersion":1,"classId":"felsworn","abilities":[{"id":"coa-tree-29686","classId":"felsworn","dbcSpellId":803904,"name":"Annihilation","unlockLevel":1,"icon":"/assets/ui/spells/ability_boss_lordanthricystgreen.png","sigil":"AN","description":"Gorge on fel energy, guaranteeing your next direct instance of damage to critically strike, but causing it to consume 5% of your maximum health. Lasts for 5 attacks or 12 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:803904:effect:0:aura:290","name":"Annihilation (Aura 290)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:803904:effect:1:aura:42","name":"Annihilation (Aura 42)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:803904:proc:1:807961","triggers":["crit","hit"],"chance":1,"charges":5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":807961}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":807961}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":803904,"level":1,"description":"Gorge on fel energy, guaranteeing your next direct instance of damage to critically strike, but causing it to consume 5% of your maximum health. Lasts for 5 attacks or 12 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:803904:effect:0:aura:290","name":"Annihilation (Aura 290)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:803904:effect:1:aura:42","name":"Annihilation (Aura 42)","disposition":"buff","durationMs":12000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:803904:proc:1:807961","triggers":["crit","hit"],"chance":1,"charges":5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":807961}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":807961}]}],"talentEntryId":29686},{"id":"coa-tree-30301","classId":"felsworn","dbcSpellId":560822,"name":"Anti-Magic","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_antimagicshell.png","sigil":"AM","description":"When Hateforged Barrier is removed from you, you now reduce -10% of all Magic damage taken for 8 seconds. In addition, Hateforged Barrier now heals for an additional 2% of your maximum health each tick.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:0:aura:4","name":"Anti-Magic (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:1:aura:107","name":"Anti-Magic (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":560822,"level":1,"description":"When Hateforged Barrier is removed from you, you now reduce -10% of all Magic damage taken for 8 seconds. In addition, Hateforged Barrier now heals for an additional 2% of your maximum health each tick.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:0:aura:4","name":"Anti-Magic (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560822:effect:1:aura:107","name":"Anti-Magic (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}]}],"talentEntryId":30301},{"id":"coa-tree-33671","classId":"felsworn","dbcSpellId":706266,"namLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"guardian","abilities":[{"id":"coa-18-banner-boy-68-705358","classId":"guardian","dbcSpellId":705358,"name":"Banner Boy","unlockLevel":1,"icon":"/assets/ui/spells/achievement_bg_captureflag_wsg.png","sigil":"BB","description":"Banner BoyRank 1Reduces the Energy cost of Banners by 10.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705358,"level":1,"description":"Banner BoyRank 1Reduces the Energy cost of Banners by 10.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}]}]},{"id":"coa-tree-11797","classId":"guardian","dbcSpellId":803683,"name":"Battle Drums","unlockLevel":1,"icon":"/assets/ui/spells/inv_archaeology_70_tauren_drum.png","sigil":"BD","description":"Place down 3 Battle Drums around you for 1 minute. Allies may click on the drums to increase their critical strike chance, haste, damage, and healing done by 10% and causes their damage dealt to trigger a Sound of War for their next 5 instances of damage or 30 seconds.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"energy","amount":50},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":803683,"level":1,"description":"Place down 3 Battle Drums around you for 1 minute. Allies may click on the drums to increase their critical strike chance, haste, damage, and healing done by 10% and causes their damage dealt to trigger a Sound of War for their next 5 instances of damage or 30 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"energy","amount":50},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000}]}],"talentEntryId":11797},{"id":"guardian-battle-rush","classId":"guardian","dbcSpellId":802197,"name":"Battle Rush","unlockLevel":1,"icon":"/assets/ui/spells/warrior_talent_icon_skirmisher.png","sigil":"BR","description":"Battle RushRank 1Instant cast30 sec cooldownCharge an enemy, dealing 16+0+AP*0.09996 Physical damage and stunnLine truncated
|
||||
{"schemaVersion":1,"classId":"guardian","abilities":[{"id":"coa-18-banner-boy-68-705358","classId":"guardian","dbcSpellId":705358,"name":"Banner Boy","unlockLevel":1,"icon":"/assets/ui/spells/achievement_bg_captureflag_wsg.png","sigil":"BB","description":"Banner BoyRank 1Reduces the Energy cost of Banners by 10.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705358,"level":1,"description":"Banner BoyRank 1Reduces the Energy cost of Banners by 10.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}]}]},{"id":"coa-tree-11797","classId":"guardian","dbcSpellId":803683,"name":"Battle Drums","unlockLevel":1,"icon":"/assets/ui/spells/inv_archaeology_70_tauren_drum.png","sigil":"BD","description":"Place down 3 Battle Drums around you for 1 minute. Allies may click on the drums to increase their critical strike chance, haste, damage, and healing done by 10% and causes their damage dealt to trigger a Sound of War for their next 5 instances of damage or 30 seconds.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"energy","amount":50},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":803683,"level":1,"description":"Place down 3 Battle Drums around you for 1 minute. Allies may click on the drums to increase their critical strike chance, haste, damage, and healing done by 10% and causes their damage dealt to trigger a Sound of War for their next 5 instances of damage or 30 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"energy","amount":50},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000}]}],"talentEntryId":11797},{"id":"guardian-battle-rush","classId":"guardian","dbcSpellId":802197,"name":"Battle Rush","unlockLevel":1,"icon":"/assets/ui/spells/warrior_talent_icon_skirmisher.png","sigil":"BR","description":"Battle RushRank 1Instant cast30 sec cooldownCharge an enemy, dealing 16+0+AP*0.09996 Physical damage and stunnLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"knight-of-xoroth","abilities":[{"id":"coa-tree-30701","classId":"knight-of-xoroth","dbcSpellId":805679,"name":"Black Shield","unlockLevel":1,"icon":"/assets/ui/spells/spell_warlock_soulburn_haunt.png","sigil":"BS","description":"Consume all of your Demon's Blood stacks to create a demonic shield that absorbs 860 damage, increased by 20% per Demon's Blood stack, for 30 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805679,"level":1,"description":"Consume all of your Demon's Blood stacks to create a demonic shield that absorbs 860 damage, increased by 20% per Demon's Blood stack, for 30 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30701},{"id":"coa-tree-34064","classId":"knight-of-xoroth","dbcSpellId":300395,"name":"Boiling Blood","unlockLevel":1,"icon":"/assets/ui/spells/nhi_bloodboil_border.png","sigil":"BB","description":"Casting Suffuse now increases your healing received by 25% for the first 6 seconds of it's duration.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:300395:effect:0:aura:42","name":"Boiling Blood (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:300395:proc:0:300394","triggers":["cast","heal"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":300394}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":300394}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300395,"level":1,"description":"Casting Suffuse now increases your healing received by 25% for the first 6 seconds of it's duration.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:300395:effect:0:aura:42","name":"Boiling Blood (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:300395:proc:0:300394","triggers":["cast","heal"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":300394}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":300394}]}],"talentEntryId":34064},{"id":"coa-tree-4018","classId":"knight-of-xoroth","dbcSpellId":92104,"name":"Brimstone Buckler","unlockLevel":1,"icon":"/assets/ui/spells/ability_warlock_improveddemonictactics.png","sigil":"BB","description":"Level 10 Passive Successful blocks now have a 30% chance to summon 1 Hellfire Imp to aid you in battle for 12 seconds. Can only occur once every 3 sec.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92104:effect:0:aura:23","name":"Brimstone Buckler (Aura 23)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92104:proc:0:562029","triggers":["periodic"],"chance":0.3,"internalCooldownMs":3000,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":562029}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":23,"miscValue":0,"triggerSpellId":562029},{"kind":"apply-aura","aura":{"id":"coa:92104:effect:1:aura:42","name":"Brimstone Buckler (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92104:proc:1:805966","triggers":["block"],"chance":0.3,"internalCooldownMs":3000,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805966}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":805966}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92104,"level":1,"description":"Level 10 Passive Successful blocks now have a 30% chance to summon 1 Hellfire Imp to aid you in battle for 12 seconds. Can only occur once every 3 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92104:effect:0:aura:23","name":"Brimstone Buckler (Aura 23)","disposition":"buff","durationMs":null,"maxSLine truncated
|
||||
{"schemaVersion":1,"classId":"knight-of-xoroth","abilities":[{"id":"coa-tree-30701","classId":"knight-of-xoroth","dbcSpellId":805679,"name":"Black Shield","unlockLevel":1,"icon":"/assets/ui/spells/spell_warlock_soulburn_haunt.png","sigil":"BS","description":"Consume all of your Demon's Blood stacks to create a demonic shield that absorbs 860 damage, increased by 20% per Demon's Blood stack, for 30 seconds.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805679,"level":1,"description":"Consume all of your Demon's Blood stacks to create a demonic shield that absorbs 860 damage, increased by 20% per Demon's Blood stack, for 30 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30701},{"id":"coa-tree-34064","classId":"knight-of-xoroth","dbcSpellId":300395,"name":"Boiling Blood","unlockLevel":1,"icon":"/assets/ui/spells/nhi_bloodboil_border.png","sigil":"BB","description":"Casting Suffuse now increases your healing received by 25% for the first 6 seconds of it's duration.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300395:effect:0:aura:42","name":"Boiling Blood (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:300395:proc:0:300394","triggers":["cast","heal"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":300394}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":300394}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300395,"level":1,"description":"Casting Suffuse now increases your healing received by 25% for the first 6 seconds of it's duration.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300395:effect:0:aura:42","name":"Boiling Blood (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:300395:proc:0:300394","triggers":["cast","heal"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":300394}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":300394}]}],"talentEntryId":34064},{"id":"coa-tree-4018","classId":"knight-of-xoroth","dbcSpellId":92104,"name":"Brimstone Buckler","unlockLevel":1,"icon":"/assets/ui/spells/ability_warlock_improveddemonictactics.png","sigil":"BB","description":"Level 10 Passive Successful blocks now have a 30% chance to summon 1 Hellfire Imp to aid you in battle for 12 seconds. Can only occur once every 3 sec.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92104:effect:0:aura:23","name":"Brimstone Buckler (Aura 23)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92104:proc:0:562029","triggers":["periodic"],"chance":0.3,"internalCooldownMs":3000,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":562029}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":23,"miscValue":0,"triggerSpellId":562029},{"kind":"apply-aura","aura":{"id":"coa:92104:effect:1:aura:42","name":"Brimstone Buckler (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92104:proc:1:805966","triggers":["block"],"chance":0.3,"internalCooldownMs":3000,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805966}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":805966}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92104,"level":1,"description":"Level 10 Passive Successful blocks now have a 30% chance to summon 1 Hellfire Imp to aid you in battle for 12 seconds. Can only occur once every 3 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92104:effect:0:aura:23","name":"Brimstone Buckler (Aura 23)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92104:proLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"expanded":true,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"expanded":true,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"expanded":true,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"expanded":true,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-scrLine truncated
|
||||
{"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"trigger-spell","spellId":525379,"expanded":true,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"trigger-spell","spellId":525379,"expanded":true,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"trigger-spell","spellId":525379,"expanded":true,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"trigger-spell","spellId":525379,"expanded":true,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMsLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"primalist","abilities":[{"id":"coa-tree-29362","classId":"primalist","dbcSpellId":800094,"name":"Bearskin","unlockLevel":1,"icon":"/assets/ui/spells/spell_druid_primaltenacity.png","sigil":"BE","description":"Reduces all damage taken by -60% and reduces the duration of root, stun, and incapacitate effects on you by -30% for 10 seconds.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":300000,"gcdMs":1500,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"status","status":"sleep","durationMs":10000},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:0:aura:87","name":"Bearskin (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:1:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":5,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:2:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":12,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":800094,"level":1,"description":"Reduces all damage taken by -60% and reduces the duration of root, stun, and incapacitate effects on you by -30% for 10 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":300000,"gcdMs":1500,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"status","status":"sleep","durationMs":10000},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:0:aura:87","name":"Bearskin (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:1:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":5,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:2:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":12,"triggerSpellId":0}]}],"talentEntryId":29362},{"id":"coa-tree-29252","classId":"primalist","dbcSpellId":500692,"name":"Boulder Dash","unlockLevel":1,"icon":"/assets/ui/spells/custom_elementl(earth)_b_01_border.png","sigil":"BD","description":"Raise a boulder out of the ground and encase yourself within it, preventing you from attacking or casting spells. For 10 seconds, you roll forward at 70% increased movement speed, dealing 1 + 50% AP + 100% SP Physical damage to enemies you roll over, and you are immune to crowd control effects.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1250,"cost":{"resource":"mana","amount":35,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500692:effect:0:aura:23","name":"Boulder Dash (Aura 23)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:500692:proc:0:500693","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":500693}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":23,"miscValue":0,"triggerSpellId":500693},{"kind":"apply-aura","aura":{"id":"coa:500692:effect:1:aura:31","name":"Boulder Dash (Aura 31)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"movement-speed","operation":"percent","value":0.7}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":31,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:500692:effect:2:aura:241","name":"Boulder Dash (Aura 241)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":241,"miscValue":0,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500692,"level":1,"description":"Raise a boulder out of the ground and encase yourself within it, preventLine truncated
|
||||
{"schemaVersion":1,"classId":"primalist","abilities":[{"id":"coa-tree-29362","classId":"primalist","dbcSpellId":800094,"name":"Bearskin","unlockLevel":1,"icon":"/assets/ui/spells/spell_druid_primaltenacity.png","sigil":"BE","description":"Reduces all damage taken by -60% and reduces the duration of root, stun, and incapacitate effects on you by -30% for 10 seconds.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":300000,"gcdMs":1500,"cost":{"resource":"mana","amount":24,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"status","status":"sleep","durationMs":10000},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:0:aura:87","name":"Bearskin (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:1:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":5,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:2:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":12,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":800094,"level":1,"description":"Reduces all damage taken by -60% and reduces the duration of root, stun, and incapacitate effects on you by -30% for 10 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":300000,"gcdMs":1500,"cost":{"resource":"mana","amount":24,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"status","status":"sleep","durationMs":10000},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:0:aura:87","name":"Bearskin (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:1:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":5,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:800094:effect:2:aura:232","name":"Bearskin (Aura 232)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":232,"miscValue":12,"triggerSpellId":0}]}],"talentEntryId":29362},{"id":"coa-tree-29252","classId":"primalist","dbcSpellId":500692,"name":"Boulder Dash","unlockLevel":1,"icon":"/assets/ui/spells/custom_elementl(earth)_b_01_border.png","sigil":"BD","description":"Raise a boulder out of the ground and encase yourself within it, preventing you from attacking or casting spells. For 10 seconds, you roll forward at 70% increased movement speed, dealing 1 + 50% AP + 100% SP Physical damage to enemies you roll over, and you are immune to crowd control effects.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1250,"cost":{"resource":"mana","amount":35,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500692:effect:0:aura:23","name":"Boulder Dash (Aura 23)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:500692:proc:0:500693","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":500693}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":23,"miscValue":0,"triggerSpellId":500693},{"kind":"apply-aura","aura":{"id":"coa:500692:effect:1:aura:31","name":"Boulder Dash (Aura 31)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"movement-speed","operation":"percent","value":0.7}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":31,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:500692:effect:2:aura:241","name":"Boulder Dash (Aura 241)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":241,"miscValue":0,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500692,"level"Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"pyromancer","abilities":[{"id":"coa-tree-33843","classId":"pyromancer","dbcSpellId":300921,"name":"Al Dente","unlockLevel":1,"icon":"/assets/ui/spells/achievement_cooking_masteroftheoven.png","sigil":"AD","description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300921,"level":1,"description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33843},{"id":"coa-tree-33882","classId":"pyromancer","dbcSpellId":504397,"name":"Blessing of the Firelands","unlockLevel":1,"icon":"/assets/ui/spells/nhi_fire_soul_border.png","sigil":"BO","description":"Each stack of Heat now increases the damage dealt by Dragon Leap by 6%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504397:effect:0:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.06}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":12,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:504397:effect:1:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504397,"level":1,"description":"Each stack of Heat now increases the damage dealt by Dragon Leap by 6%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504397:effect:0:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.06}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":12,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:504397:effect:1:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33882},{"id":"coa-tree-32499","classId":"pyromancer","dbcSpellId":300759,"name":"Bright Flames","unlockLevel":1,"icon":"/assets/ui/spells/spell_frost_fireresistancetotem.png","sigil":"BF","description":"Increases the amount absorbed by Inferno Barrier by 10% and the damage it deals by 15%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300759:effect:0:aura:317","name":"Bright Flames (Aura 317)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":317,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:300759:effect:1:aura:108","name":"Bright Flames (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300759,"level":1,"description":"Increases the amount absorbed by Inferno Barrier by 10% and the damage it dLine truncated
|
||||
{"schemaVersion":1,"classId":"pyromancer","abilities":[{"id":"coa-tree-33843","classId":"pyromancer","dbcSpellId":300921,"name":"Al Dente","unlockLevel":1,"icon":"/assets/ui/spells/achievement_cooking_masteroftheoven.png","sigil":"AD","description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300921,"level":1,"description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33843},{"id":"coa-tree-33882","classId":"pyromancer","dbcSpellId":504397,"name":"Blessing of the Firelands","unlockLevel":1,"icon":"/assets/ui/spells/nhi_fire_soul_border.png","sigil":"BO","description":"Each stack of Heat now increases the damage dealt by Dragon Leap by 6%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504397:effect:0:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.06}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":12,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:504397:effect:1:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504397,"level":1,"description":"Each stack of Heat now increases the damage dealt by Dragon Leap by 6%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504397:effect:0:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.06}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":12,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:504397:effect:1:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33882},{"id":"coa-tree-32499","classId":"pyromancer","dbcSpellId":300759,"name":"Bright Flames","unlockLevel":1,"icon":"/assets/ui/spells/spell_frost_fireresistancetotem.png","sigil":"BF","description":"Increases the amount absorbed by Inferno Barrier by 10% and the damage it deals by 15%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300759:effect:0:aura:317","name":"Bright Flames (Aura 317)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":317,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:300759:effect:1:aura:108","name":"Bright Flames (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300759,"level":1,"description":"Increases the amount absorbed by Inferno Barrier by 10% and the damage it dLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"ranger","abilities":[{"id":"coa-21-agile-none-560796","classId":"ranger","dbcSpellId":560796,"name":"Agile","unlockLevel":1,"icon":"/assets/ui/spells/inv_sword_38.png","sigil":"AG","description":"AgileRank 2Increases the damage of Wild Strike, Whipvine Arrow, and Ravage by 10%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":560796,"level":1,"description":"AgileRank 2Increases the damage of Wild Strike, Whipvine Arrow, and Ravage by 10%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}]}]},{"id":"coa-21-archery-training-76-705036","classId":"ranger","dbcSpellId":705036,"name":"Archery Training","unlockLevel":1,"icon":"/assets/ui/spells/inv_bow_1h_draenorcrafted_d_01_a_horde.png","sigil":"AT","description":"Archery TrainingRank 1Increases the damage of Skullpiercer by 3% and your critical strike chance by 1%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.03}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705036,"level":1,"description":"Archery TrainingRank 1Increases the damage of Skullpiercer by 3% and your critical strike chance by 1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.03}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":705037,"level":1,"description":"Archery TrainingRank 2Increases the damage of Skullpiercer by 6% and your critical strike chance by 2%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705037:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705037:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","dLine truncated
|
||||
{"schemaVersion":1,"classId":"ranger","abilities":[{"id":"coa-21-agile-none-560796","classId":"ranger","dbcSpellId":560796,"name":"Agile","unlockLevel":1,"icon":"/assets/ui/spells/inv_sword_38.png","sigil":"AG","description":"AgileRank 2Increases the damage of Wild Strike, Whipvine Arrow, and Ravage by 10%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":560796,"level":1,"description":"AgileRank 2Increases the damage of Wild Strike, Whipvine Arrow, and Ravage by 10%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}]}]},{"id":"coa-21-archery-training-76-705036","classId":"ranger","dbcSpellId":705036,"name":"Archery Training","unlockLevel":1,"icon":"/assets/ui/spells/inv_bow_1h_draenorcrafted_d_01_a_horde.png","sigil":"AT","description":"Archery TrainingRank 1Increases the damage of Skullpiercer by 3% and your critical strike chance by 1%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.03}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705036,"level":1,"description":"Archery TrainingRank 1Increases the damage of Skullpiercer by 3% and your critical strike chance by 1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.03}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":705037,"level":1,"description":"Archery TrainingRank 2Increases the damage of Skullpiercer by 6% and your critical strike chance by 2%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705037:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705037:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","dLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"reaper","abilities":[{"id":"coa-tree-6708","classId":"reaper","dbcSpellId":706790,"name":"Backswing","unlockLevel":1,"icon":"/assets/ui/spells/novart_magicspell_(32)_border.png","sigil":"BA","description":"Increases the Runic Power generated by Reap by 5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706790:effect:0:aura:107","name":"Backswing (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.5}]},"sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":706790,"level":1,"description":"Increases the Runic Power generated by Reap by 5.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706790:effect:0:aura:107","name":"Backswing (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.5}]},"sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}]}],"talentEntryId":6708},{"id":"coa-tree-34500","classId":"reaper","dbcSpellId":560412,"name":"Between Shadows","unlockLevel":1,"icon":"/assets/ui/spells/5_shadowworld_border.png","sigil":"BS","description":"Casting Spectre Stride now generates 1 Reaped Soul.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560412:effect:0:aura:42","name":"Between Shadows (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:560412:proc:0:504390","triggers":["cast"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":504390}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":504390}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":560412,"level":1,"description":"Casting Spectre Stride now generates 1 Reaped Soul.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560412:effect:0:aura:42","name":"Between Shadows (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:560412:proc:0:504390","triggers":["cast"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":504390}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":504390}]}],"talentEntryId":34500},{"id":"coa-tree-31289","classId":"reaper","dbcSpellId":680337,"name":"Bolstered Form","unlockLevel":1,"icon":"/assets/ui/spells/achievement_raid_torghast_shadowscourge_prisonofnerzhul.png","sigil":"BF","description":"Assume a bolstered form for 15 seconds, generating 30 Runic Power, increasing your Armor and your parry chance by 30%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:680337:effect:0:aura:101","name":"Bolstered Form (Aura 101)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"resistance:physical","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":101,"miscValue":1,"triggerSpellId":0},{"kind":"trigger-spell","spellId":359505,"expanded":true},{"kind":"resource","amount":300},{"kind":"apply-aura","aura":{"id":"coa:680337:effect:2:aura:47","name":"Bolstered Form (Aura 47)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"parry","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":47,"miscValue":127,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":680337,"level":1,"description":"Assume a bolstered form for 15 seconds, generating 30 Runic Power, increasing your Armor and your parry chance by 30%.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:680337:effect:0:aura:101","name":"Bolstered Form (Aura 101)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"resistance:physical","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sLine truncated
|
||||
{"schemaVersion":1,"classId":"reaper","abilities":[{"id":"coa-tree-6708","classId":"reaper","dbcSpellId":706790,"name":"Backswing","unlockLevel":1,"icon":"/assets/ui/spells/novart_magicspell_(32)_border.png","sigil":"BA","description":"Increases the Runic Power generated by Reap by 5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706790:effect:0:aura:107","name":"Backswing (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.5}]},"sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":706790,"level":1,"description":"Increases the Runic Power generated by Reap by 5.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706790:effect:0:aura:107","name":"Backswing (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.5}]},"sourceEffectType":6,"sourceAuraType":107,"miscValue":3,"triggerSpellId":0}]}],"talentEntryId":6708},{"id":"coa-tree-34500","classId":"reaper","dbcSpellId":560412,"name":"Between Shadows","unlockLevel":1,"icon":"/assets/ui/spells/5_shadowworld_border.png","sigil":"BS","description":"Casting Spectre Stride now generates 1 Reaped Soul.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560412:effect:0:aura:42","name":"Between Shadows (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:560412:proc:0:504390","triggers":["cast"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":504390}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":504390}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":560412,"level":1,"description":"Casting Spectre Stride now generates 1 Reaped Soul.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560412:effect:0:aura:42","name":"Between Shadows (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:560412:proc:0:504390","triggers":["cast"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":504390}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":504390}]}],"talentEntryId":34500},{"id":"coa-tree-31289","classId":"reaper","dbcSpellId":680337,"name":"Bolstered Form","unlockLevel":1,"icon":"/assets/ui/spells/achievement_raid_torghast_shadowscourge_prisonofnerzhul.png","sigil":"BF","description":"Assume a bolstered form for 15 seconds, generating 30 Runic Power, increasing your Armor and your parry chance by 30%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:680337:effect:0:aura:101","name":"Bolstered Form (Aura 101)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"resistance:physical","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":101,"miscValue":1,"triggerSpellId":0},{"kind":"trigger-spell","spellId":359505,"expanded":true},{"kind":"resource","amount":300},{"kind":"apply-aura","aura":{"id":"coa:680337:effect:2:aura:47","name":"Bolstered Form (Aura 47)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"parry","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":47,"miscValue":127,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":680337,"level":1,"description":"Assume a bolstered form for 15 seconds, generating 30 Runic Power, increasing your Armor and your parry chance by 30%.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:680337:effect:0:aura:101","name":"Bolstered Form (Aura 101)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"resistance:physical","operation":"percent","value":0.25}]},"recipient":"caster","sourceEffectType":6,"sLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"runemaster","abilities":[{"id":"coa-32-ancient-carvings-117-705616","classId":"runemaster","dbcSpellId":705616,"name":"Ancient Carvings","unlockLevel":1,"icon":"/assets/ui/spells/inv_relics_6orunestone_ogremissive.png","sigil":"AC","description":"Ancient CarvingsRank 2InstantReduces the cooldown of Words by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705616:effect:0:aura:108","name":"Ancient Carvings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.2}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":108,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:1:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:2:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":34,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":705616,"level":1,"description":"Ancient CarvingsRank 2InstantReduces the cooldown of Words by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705616:effect:0:aura:108","name":"Ancient Carvings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.2}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":108,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:1:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:2:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":34,"triggerSpellId":0}]}]},{"id":"coa-tree-29226","classId":"runemaster","dbcSpellId":806797,"name":"Ancient Teachings","unlockLevel":1,"icon":"/assets/ui/spells/ability_mage_potentspirit.png","sigil":"AT","description":"Increases the effectiveness of Etching of the Dextrous by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806797:effect:0:aura:216","name":"Ancient Teachings (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":126,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:1:aura:192","name":"Ancient Teachings (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:2:aura:108","name":"Ancient Teachings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":806797,"level":1,"description":"Increases the effectiveness of Etching of the Dextrous by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806797:effect:0:aura:216","name":"Ancient Teachings (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":126,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:1:aura:192","name":"Ancient Teachings (Aura 192)","disposition":Line truncated
|
||||
{"schemaVersion":1,"classId":"runemaster","abilities":[{"id":"coa-32-ancient-carvings-117-705616","classId":"runemaster","dbcSpellId":705616,"name":"Ancient Carvings","unlockLevel":1,"icon":"/assets/ui/spells/inv_relics_6orunestone_ogremissive.png","sigil":"AC","description":"Ancient CarvingsRank 2InstantReduces the cooldown of Words by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705616:effect:0:aura:108","name":"Ancient Carvings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.2}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":108,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:1:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:2:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":34,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":705616,"level":1,"description":"Ancient CarvingsRank 2InstantReduces the cooldown of Words by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705616:effect:0:aura:108","name":"Ancient Carvings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.2}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":108,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:1:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705616:effect:2:aura:4","name":"Ancient Carvings (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":34,"triggerSpellId":0}]}]},{"id":"coa-tree-29226","classId":"runemaster","dbcSpellId":806797,"name":"Ancient Teachings","unlockLevel":1,"icon":"/assets/ui/spells/ability_mage_potentspirit.png","sigil":"AT","description":"Increases the effectiveness of Etching of the Dextrous by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806797:effect:0:aura:216","name":"Ancient Teachings (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":126,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:1:aura:192","name":"Ancient Teachings (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:2:aura:108","name":"Ancient Teachings (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":806797,"level":1,"description":"Increases the effectiveness of Etching of the Dextrous by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806797:effect:0:aura:216","name":"Ancient Teachings (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":126,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806797:effect:1:aura:192","name":"Ancient Teachings (Aura 192)","disposition":Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"starcaller","abilities":[{"id":"coa-26-arrows-of-starlight-93-704733","classId":"starcaller","dbcSpellId":704733,"name":"Arrows of Starlight","unlockLevel":1,"icon":"/assets/ui/spells/ability_ironmaidens_ironshot.png","sigil":"AO","description":"Arrows of StarlightRank 1Increases the damage of Moon Arrow by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704733:effect:0:aura:108","name":"Arrows of Starlight (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704733,"level":1,"description":"Arrows of StarlightRank 1Increases the damage of Moon Arrow by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704733:effect:0:aura:108","name":"Arrows of Starlight (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]}]},{"id":"coa-tree-30838","classId":"starcaller","dbcSpellId":806155,"name":"Astral Aegis","unlockLevel":1,"icon":"/assets/ui/spells/ability_bastion_monk.png","sigil":"AA","description":"Drains 30% Maximum Mana For 10 seconds, direct Magic damage taken will silence the attacker for 1 seconds and deal 57 + 18% arcane SP Arcane damage to them.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1500,"cost":{"resource":"mana","amount":19,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806155:effect:0:aura:87","name":"Astral Aegis (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806155:effect:1:aura:42","name":"Astral Aegis (Aura 42)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:806155:proc:1:806453","triggers":["damage-taken","hit"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":806453}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":806453}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":806155,"level":1,"description":"Drains 30% Maximum Mana For 10 seconds, direct Magic damage taken will silence the attacker for 1 seconds and deal 57 + 18% arcane SP Arcane damage to them.","castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1500,"cost":{"resource":"mana","amount":19,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806155:effect:0:aura:87","name":"Astral Aegis (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806155:effect:1:aura:42","name":"Astral Aegis (Aura 42)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:806155:proc:1:806453","triggers":["damage-taken","hit"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":806453}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":806453}]}],"talentEntryId":30838},{"id":"starcaller-astral-flare","classId":"starcaller","dbcSpellId":800499,"name":"Astral Flare","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_manaburn.png","sigil":"AF","description":"Astral FlareRank 12 sec castYou call an Astral Flare down from the sky, dealing 1 Arcane Damage to an enemy and regenerating 5% of your Maximum Mana.","target":"hostile","range":{"min":0,"max":40},"castMode":"cast","castTimeMs":2000,"cooldownMs":0,"gcdMs":1250,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"trigger-spell","spellId":800507,"expanded":true,"withValue":10},{"kind":"damage","coefficient":0.08,"basePoints":1,"dieSides":1,"pointsPerLevel":0,"bonusPowerCoefficient":0,"sourceLevel":6},{"kind":"trigger-spell","spellId":804096,"expanded":true,"withValue":5},{"kind":"resource","amount":2,"pLine truncated
|
||||
{"schemaVersion":1,"classId":"starcaller","abilities":[{"id":"coa-26-arrows-of-starlight-93-704733","classId":"starcaller","dbcSpellId":704733,"name":"Arrows of Starlight","unlockLevel":1,"icon":"/assets/ui/spells/ability_ironmaidens_ironshot.png","sigil":"AO","description":"Arrows of StarlightRank 1Increases the damage of Moon Arrow by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704733:effect:0:aura:108","name":"Arrows of Starlight (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704733,"level":1,"description":"Arrows of StarlightRank 1Increases the damage of Moon Arrow by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704733:effect:0:aura:108","name":"Arrows of Starlight (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]}]},{"id":"coa-tree-30838","classId":"starcaller","dbcSpellId":806155,"name":"Astral Aegis","unlockLevel":1,"icon":"/assets/ui/spells/ability_bastion_monk.png","sigil":"AA","description":"Drains 30% Maximum Mana For 10 seconds, direct Magic damage taken will silence the attacker for 1 seconds and deal 57 + 18% arcane SP Arcane damage to them.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1500,"cost":{"resource":"mana","amount":19,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806155:effect:0:aura:87","name":"Astral Aegis (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806155:effect:1:aura:42","name":"Astral Aegis (Aura 42)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:806155:proc:1:806453","triggers":["damage-taken","hit"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":806453}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":806453}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":806155,"level":1,"description":"Drains 30% Maximum Mana For 10 seconds, direct Magic damage taken will silence the attacker for 1 seconds and deal 57 + 18% arcane SP Arcane damage to them.","castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1500,"cost":{"resource":"mana","amount":19,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:806155:effect:0:aura:87","name":"Astral Aegis (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:806155:effect:1:aura:42","name":"Astral Aegis (Aura 42)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:806155:proc:1:806453","triggers":["damage-taken","hit"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":806453}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":806453}]}],"talentEntryId":30838},{"id":"starcaller-astral-flare","classId":"starcaller","dbcSpellId":800499,"name":"Astral Flare","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_manaburn.png","sigil":"AF","description":"Astral FlareRank 12 sec castYou call an Astral Flare down from the sky, dealing 1 Arcane Damage to an enemy and regenerating 5% of your Maximum Mana.","target":"hostile","range":{"min":0,"max":40},"castMode":"cast","castTimeMs":2000,"cooldownMs":0,"gcdMs":1250,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"trigger-spell","spellId":800507,"expanded":true,"withValue":10},{"kind":"damage","coefficient":0.08,"basePoints":1,"dieSides":1,"pointsPerLevel":0,"bonusPowerCoefficient":0,"sourceLevel":6},{"kind":"trigger-spell","spellId":804096,"exLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"stormbringer","abilities":[{"id":"coa-tree-34916","classId":"stormbringer","dbcSpellId":807462,"name":"Aether Mastery","unlockLevel":1,"icon":"/assets/ui/spells/achievement_zone_netherstorm_01.png","sigil":"AM","description":"Allows 30% of your mana regeneration to continue while casting and increases your critical strike chance by 2%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807462:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.3}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807462:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":807462,"level":1,"description":"Allows 30% of your mana regeneration to continue while casting and increases your critical strike chance by 2%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807462:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.3}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807462:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":807887,"level":1,"description":"Allows 60% of your mana regeneration to continue while casting and increases your critical strike chance by 4%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807887:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807887:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":34916},{"id":"coa-16-aethermancy-62-705690","classId":"stormbringer","dbcSpellId":705690,"name":"Aethermancy","unlockLevel":1,"icon":"/assets/ui/spells/spell_shaman_improvedreincarnation.png","sigil":"AE","description":"AethermancyRank 1Increases the critical strike chance of your Invigorating Surge by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705690:effect:0:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705690:effect:1:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705690,"level":1,"description":"AethermancyRank 1Increases the critical strike chance of your Invigorating Surge by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705690:effect:0:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0},{"kinLine truncated
|
||||
{"schemaVersion":1,"classId":"stormbringer","abilities":[{"id":"coa-tree-34916","classId":"stormbringer","dbcSpellId":807462,"name":"Aether Mastery","unlockLevel":1,"icon":"/assets/ui/spells/achievement_zone_netherstorm_01.png","sigil":"AM","description":"Allows 30% of your mana regeneration to continue while casting and increases your critical strike chance by 2%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807462:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.3}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807462:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":807462,"level":1,"description":"Allows 30% of your mana regeneration to continue while casting and increases your critical strike chance by 2%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807462:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.3}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807462:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":807887,"level":1,"description":"Allows 60% of your mana regeneration to continue while casting and increases your critical strike chance by 4%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:807887:effect:0:aura:134","name":"Aether Mastery (Aura 134)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"resource","resource":"mana","property":"regeneration","operation":"percent","value":0.6}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":134,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:807887:effect:1:aura:290","name":"Aether Mastery (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0}]}],"talentEntryId":34916},{"id":"coa-16-aethermancy-62-705690","classId":"stormbringer","dbcSpellId":705690,"name":"Aethermancy","unlockLevel":1,"icon":"/assets/ui/spells/spell_shaman_improvedreincarnation.png","sigil":"AE","description":"AethermancyRank 1Increases the critical strike chance of your Invigorating Surge by 20%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705690:effect:0:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705690:effect:1:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":3,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705690,"level":1,"description":"AethermancyRank 1Increases the critical strike chance of your Invigorating Surge by 20%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705690:effect:0:aura:107","name":"Aethermancy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0},{"kinLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"sun-cleric","abilities":[{"id":"coa-tree-4048","classId":"sun-cleric","dbcSpellId":92137,"name":"Angelic Presence","unlockLevel":1,"icon":"/assets/ui/spells/ability_paladin_conviction.png","sigil":"AP","description":"Level 10 Passive Avoiding an attack now has a 20% chance to grant you Angelic Presence.Angelic PresenceYour next Illumination within 15 seconds is instant cast and free of cost.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92137:effect:0:aura:42","name":"Angelic Presence (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92137:proc:0:800722","triggers":["cast","hit"],"chance":0.2,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":800722}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":800722},{"kind":"apply-aura","aura":{"id":"coa:92137:effect:1:aura:79","name":"Angelic Presence (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92137,"level":1,"description":"Level 10 Passive Avoiding an attack now has a 20% chance to grant you Angelic Presence.Angelic PresenceYour next Illumination within 15 seconds is instant cast and free of cost.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92137:effect:0:aura:42","name":"Angelic Presence (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92137:proc:0:800722","triggers":["cast","hit"],"chance":0.2,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":800722}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":800722},{"kind":"apply-aura","aura":{"id":"coa:92137:effect:1:aura:79","name":"Angelic Presence (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":4048},{"id":"coa-27-bastion-of-vengeance-97-704925","classId":"sun-cleric","dbcSpellId":704925,"name":"Bastion of Vengeance","unlockLevel":1,"icon":"/assets/ui/spells/inv_shield_1h_artifactnorgannon_d_06.png","sigil":"BO","description":"Bastion of VengeanceRank 1Increases your block chance by 2% and gives successful blocks a 50% chance to reduce the remaining cooldown of Vengeful Slam by 0.01 sec.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704925:effect:0:aura:42","name":"Bastion of Vengeance (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:704925:proc:0:704926","triggers":["block"],"chance":0.5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":704926}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":704926},{"kind":"apply-aura","aura":{"id":"coa:704925:effect:1:aura:51","name":"Bastion of Vengeance (Aura 51)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"block","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":51,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704925,"level":1,"description":"Bastion of VengeanceRank 1Increases your block chance by 2% and gives successful blocks a 50% chance to reduce the remaining cooldown of Vengeful Slam by 0.01 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704925:effect:0:aura:42","name":"Bastion of Vengeance (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:704925:proc:0:704926","triggers":["block"],"chance":0.5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":704926}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":704926},{"kind":"apply-aura","aura":{"id":"coa:704925:effect:1:aura:51","name":"Bastion of Vengeance (Aura 51)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","statLine truncated
|
||||
{"schemaVersion":1,"classId":"sun-cleric","abilities":[{"id":"coa-tree-4048","classId":"sun-cleric","dbcSpellId":92137,"name":"Angelic Presence","unlockLevel":1,"icon":"/assets/ui/spells/ability_paladin_conviction.png","sigil":"AP","description":"Level 10 Passive Avoiding an attack now has a 20% chance to grant you Angelic Presence.Angelic PresenceYour next Illumination within 15 seconds is instant cast and free of cost.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92137:effect:0:aura:42","name":"Angelic Presence (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92137:proc:0:800722","triggers":["cast","hit"],"chance":0.2,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":800722}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":800722},{"kind":"apply-aura","aura":{"id":"coa:92137:effect:1:aura:79","name":"Angelic Presence (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":92137,"level":1,"description":"Level 10 Passive Avoiding an attack now has a 20% chance to grant you Angelic Presence.Angelic PresenceYour next Illumination within 15 seconds is instant cast and free of cost.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:92137:effect:0:aura:42","name":"Angelic Presence (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:92137:proc:0:800722","triggers":["cast","hit"],"chance":0.2,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":800722}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":800722},{"kind":"apply-aura","aura":{"id":"coa:92137:effect:1:aura:79","name":"Angelic Presence (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":4048},{"id":"coa-27-bastion-of-vengeance-97-704925","classId":"sun-cleric","dbcSpellId":704925,"name":"Bastion of Vengeance","unlockLevel":1,"icon":"/assets/ui/spells/inv_shield_1h_artifactnorgannon_d_06.png","sigil":"BO","description":"Bastion of VengeanceRank 1Increases your block chance by 2% and gives successful blocks a 50% chance to reduce the remaining cooldown of Vengeful Slam by 0.01 sec.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704925:effect:0:aura:42","name":"Bastion of Vengeance (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:704925:proc:0:704926","triggers":["block"],"chance":0.5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":704926}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":704926},{"kind":"apply-aura","aura":{"id":"coa:704925:effect:1:aura:51","name":"Bastion of Vengeance (Aura 51)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"block","operation":"percent","value":0.02}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":51,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704925,"level":1,"description":"Bastion of VengeanceRank 1Increases your block chance by 2% and gives successful blocks a 50% chance to reduce the remaining cooldown of Vengeful Slam by 0.01 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704925:effect:0:aura:42","name":"Bastion of Vengeance (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:704925:proc:0:704926","triggers":["block"],"chance":0.5,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":704926}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":704926},{"kind":"apply-aura","aura":{"id":"coa:704925:effect:1:aura:51","name":"Bastion of Vengeance (Aura 51)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","statLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"templar","abilities":[{"id":"coa-19-alertness-71-706321","classId":"templar","dbcSpellId":706321,"name":"Alertness","unlockLevel":1,"icon":"/assets/ui/spells/spell_holy_powerinfusion.png","sigil":"AL","description":"AlertnessRank 1Reduces damage taken by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706321:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706321:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":706321,"level":1,"description":"AlertnessRank 1Reduces damage taken by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706321:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706321:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}]},{"rank":2,"spellId":706322,"level":1,"description":"AlertnessRank 2Reduces Physical Damage taken by 4% and increases the range of Benediction by 10 yds.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706322:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706322:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}]}]},{"id":"coa-tree-34375","classId":"templar","dbcSpellId":801457,"name":"Binding Scripture","unlockLevel":1,"icon":"/assets/ui/spells/_d3breathofheaven.png","sigil":"BS","description":"Healing an ally now redirects 10% of their damage taken to you and reduces your damage taken by -5% for 10 seconds, stacking 3 times.","target":"friendly","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:801457:effect:0:aura:42","name":"Binding Scripture (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801457:proc:0:705309","triggers":["heal","damage-taken"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":705309}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":705309}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":801457,"level":1,"description":"Healing an ally now redirects 10% of their damage taken to you and reduces your damage taken by -5% for 10 seconds, stacking 3 times.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:801457:effect:0:aura:42","name":"Binding Scripture (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801457:proc:0:705309","triggers":["heal","damage-taken"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":705309}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":705309}]}],"talentEntryId":34375},{"id":"coa-tree-29704","classId":"templar","dbcSpLine truncated
|
||||
{"schemaVersion":1,"classId":"templar","abilities":[{"id":"coa-19-alertness-71-706321","classId":"templar","dbcSpellId":706321,"name":"Alertness","unlockLevel":1,"icon":"/assets/ui/spells/spell_holy_powerinfusion.png","sigil":"AL","description":"AlertnessRank 1Reduces damage taken by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706321:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706321:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":706321,"level":1,"description":"AlertnessRank 1Reduces damage taken by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706321:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706321:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}]},{"rank":2,"spellId":706322,"level":1,"description":"AlertnessRank 2Reduces Physical Damage taken by 4% and increases the range of Benediction by 10 yds.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:706322:effect:0:aura:87","name":"Alertness (Aura 87)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:706322:effect:1:aura:107","name":"Alertness (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":5,"triggerSpellId":0}]}]},{"id":"coa-tree-34375","classId":"templar","dbcSpellId":801457,"name":"Binding Scripture","unlockLevel":1,"icon":"/assets/ui/spells/_d3breathofheaven.png","sigil":"BS","description":"Healing an ally now redirects 10% of their damage taken to you and reduces your damage taken by -5% for 10 seconds, stacking 3 times.","target":"friendly","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801457:effect:0:aura:42","name":"Binding Scripture (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801457:proc:0:705309","triggers":["heal","damage-taken"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":705309}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":705309}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":801457,"level":1,"description":"Healing an ally now redirects 10% of their damage taken to you and reduces your damage taken by -5% for 10 seconds, stacking 3 times.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801457:effect:0:aura:42","name":"Binding Scripture (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801457:proc:0:705309","triggers":["heal","damage-taken"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":705309}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":705309}]}],"talentEntryId":34375},{"id":"coa-tree-29704","classId":"templar","dbcSpellId":803872,"name":"Blade of Faith","unlockLevel":1,"icon":"/aLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"tinker","abilities":[{"id":"coa-tree-29440","classId":"tinker","dbcSpellId":801744,"name":"Air Strike","unlockLevel":1,"icon":"/assets/ui/spells/ability_mount_gyrocoptor.png","sigil":"AS","description":"Mark the target location for an Air Strike. After 7 seconds, it deals 1250 Fire damage to up to 12 enemies caught within the blast. This ability always critically strikes.","target":"hostile","range":{"min":0,"max":60},"radius":20,"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801744:effect:0:aura:23","name":"Air Strike (Aura 23)","disposition":"debuff","durationMs":7000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:801744:proc:0:801745","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801745}}}]},"sourceEffectType":27,"sourceAuraType":23,"miscValue":0,"triggerSpellId":801745}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":801744,"level":1,"description":"Mark the target location for an Air Strike. After 7 seconds, it deals 1250 Fire damage to up to 12 enemies caught within the blast. This ability always critically strikes.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801744:effect:0:aura:23","name":"Air Strike (Aura 23)","disposition":"debuff","durationMs":7000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:801744:proc:0:801745","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801745}}}]},"sourceEffectType":27,"sourceAuraType":23,"miscValue":0,"triggerSpellId":801745}]}],"talentEntryId":29440},{"id":"coa-tree-5997","classId":"tinker","dbcSpellId":520445,"name":"Auto Resuscitation Device","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_enggizmos_15.png","sigil":"AR","description":"Attach a resuscitation device to your target ally for 20 seconds. When the target would otherwise die, they are instead healed for 50% of their maximum health.","target":"friendly","range":{"min":0,"max":20},"castMode":"cast","castTimeMs":1500,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"mana","amount":40,"percentage":true},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:520445:effect:0:aura:69","name":"Auto Resuscitation Device (Aura 69)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","absorbs":[{"id":"coa:520445:absorb:0","amount":20,"perStack":false}]},"sourceEffectType":6,"sourceAuraType":69,"miscValue":127,"triggerSpellId":524780}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":520445,"level":1,"description":"Attach a resuscitation device to your target ally for 20 seconds. When the target would otherwise die, they are instead healed for 50% of their maximum health.","castMode":"cast","castTimeMs":1500,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"mana","amount":40,"percentage":true},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:520445:effect:0:aura:69","name":"Auto Resuscitation Device (Aura 69)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","absorbs":[{"id":"coa:520445:absorb:0","amount":20,"perStack":false}]},"sourceEffectType":6,"sourceAuraType":69,"miscValue":127,"triggerSpellId":524780}]}],"talentEntryId":5997},{"id":"coa-28-blasting-barrels-99-705835","classId":"tinker","dbcSpellId":705835,"name":"Blasting Barrels","unlockLevel":1,"icon":"/assets/ui/spells/5_gunshot_(3)_border.png","sigil":"BB","description":"Blasting BarrelsRank 1InstantIncreases critical strike damage by 0% and increases damage dealt by Rounds by 0%.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"damage","coefficient":1}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705835,"level":1,"description":"Blasting BarrelsRank 1InstantIncreases critical strike damage by 0% and increases damage dealt by Rounds by 0%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"damage","coefficient":1}]}]},{"id":"coa-tree-30583","classId":"tinker","dbcSpellId":560754,"name":"Build: Alarm Beacon","unlockLevel":1,"icon":"/assets/ui/spells/inv_lightforgedmechsuit.png","sigil":"BA","description":"3 Charges, 15 sec recharge Throw down an alarm beacon for 10 seconds, removing fear, charm and sleep effects from up to 1 ally within 20 yds every 1 sec.","target":"hostile","range":{"min":0,"max":20},"radius":5,"castMode":"instant","castTimeMs":0,"cooldownMsLine truncated
|
||||
{"schemaVersion":1,"classId":"tinker","abilities":[{"id":"coa-tree-29440","classId":"tinker","dbcSpellId":801744,"name":"Air Strike","unlockLevel":1,"icon":"/assets/ui/spells/ability_mount_gyrocoptor.png","sigil":"AS","description":"Mark the target location for an Air Strike. After 7 seconds, it deals 1250 Fire damage to up to 12 enemies caught within the blast. This ability always critically strikes.","target":"hostile","range":{"min":0,"max":60},"radius":20,"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801744:effect:0:aura:23","name":"Air Strike (Aura 23)","disposition":"debuff","durationMs":7000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:801744:proc:0:801745","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801745}}}]},"sourceEffectType":27,"sourceAuraType":23,"miscValue":0,"triggerSpellId":801745}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":801744,"level":1,"description":"Mark the target location for an Air Strike. After 7 seconds, it deals 1250 Fire damage to up to 12 enemies caught within the blast. This ability always critically strikes.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801744:effect:0:aura:23","name":"Air Strike (Aura 23)","disposition":"debuff","durationMs":7000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:801744:proc:0:801745","triggers":["periodic"],"chance":1,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801745}}}]},"sourceEffectType":27,"sourceAuraType":23,"miscValue":0,"triggerSpellId":801745}]}],"talentEntryId":29440},{"id":"coa-tree-5997","classId":"tinker","dbcSpellId":520445,"name":"Auto Resuscitation Device","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_enggizmos_15.png","sigil":"AR","description":"Attach a resuscitation device to your target ally for 20 seconds. When the target would otherwise die, they are instead healed for 50% of their maximum health.","target":"friendly","range":{"min":0,"max":20},"castMode":"cast","castTimeMs":1500,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"mana","amount":40,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:520445:effect:0:aura:69","name":"Auto Resuscitation Device (Aura 69)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","absorbs":[{"id":"coa:520445:absorb:0","amount":20,"perStack":false}]},"sourceEffectType":6,"sourceAuraType":69,"miscValue":127,"triggerSpellId":524780}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":520445,"level":1,"description":"Attach a resuscitation device to your target ally for 20 seconds. When the target would otherwise die, they are instead healed for 50% of their maximum health.","castMode":"cast","castTimeMs":1500,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"mana","amount":40,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"heal","coefficient":1},{"kind":"apply-aura","aura":{"id":"coa:520445:effect:0:aura:69","name":"Auto Resuscitation Device (Aura 69)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","absorbs":[{"id":"coa:520445:absorb:0","amount":20,"perStack":false}]},"sourceEffectType":6,"sourceAuraType":69,"miscValue":127,"triggerSpellId":524780}]}],"talentEntryId":5997},{"id":"coa-28-blasting-barrels-99-705835","classId":"tinker","dbcSpellId":705835,"name":"Blasting Barrels","unlockLevel":1,"icon":"/assets/ui/spells/5_gunshot_(3)_border.png","sigil":"BB","description":"Blasting BarrelsRank 1InstantIncreases critical strike damage by 0% and increases damage dealt by Rounds by 0%.","target":"hostile","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"damage","coefficient":1}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705835,"level":1,"description":"Blasting BarrelsRank 1InstantIncreases critical strike damage by 0% and increases damage dealt by Rounds by 0%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"damage","coefficient":1}]}]},{"id":"coa-tree-30583","classId":"tinker","dbcSpellId":560754,"name":"Build: Alarm Beacon","unlockLevel":1,"icon":"/assets/ui/spells/inv_lightforgedmechsuit.png","sigil":"BA","description":"3 Charges, 15 sec recharge Throw down an alarm beacon for 10 seconds, removing fear, charm and sleep effects from up to 1 ally within 20 ydLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"venomancer","abilities":[{"id":"coa-29-acceleration-103-705960","classId":"venomancer","dbcSpellId":705960,"name":"Acceleration","unlockLevel":1,"icon":"/assets/ui/spells/_meat_rotten.png","sigil":"AC","description":"AccelerationRank 2Increases the tick rate of Wilt and Shadra's Vigil by 10%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705960:effect:0:aura:108","name":"Acceleration (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":19,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705960:effect:1:aura:108","name":"Acceleration (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":19,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":705960,"level":1,"description":"AccelerationRank 2Increases the tick rate of Wilt and Shadra's Vigil by 10%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705960:effect:0:aura:108","name":"Acceleration (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":19,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705960:effect:1:aura:108","name":"Acceleration (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":19,"triggerSpellId":0}]}]},{"id":"coa-tree-6086","classId":"venomancer","dbcSpellId":805775,"name":"Adrenal Venom","unlockLevel":1,"icon":"/assets/ui/spells/trade_alchemy_dpotion_a26.png","sigil":"AV","description":"Activate Adrenal Venom for 120 minutes, giving your casts a 25% chance to increase your haste by 6% for 6 seconds, stacking 3 times. You can have a maximum of 2 unique Venoms active at a time.","target":"self","range":{"min":0,"max":0},"castMode":"cast","castTimeMs":1000,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":20,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:805775:effect:0:aura:42","name":"Adrenal Venom (Aura 42)","disposition":"buff","durationMs":7200000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"poison","procs":[{"id":"coa:805775:proc:0:805894","triggers":["cast"],"chance":0.25,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805894}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":805894},{"kind":"apply-aura","aura":{"id":"coa:805775:effect:1:aura:107","name":"Adrenal Venom (Aura 107)","disposition":"buff","durationMs":7200000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"poison","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":17,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805775,"level":1,"description":"Activate Adrenal Venom for 120 minutes, giving your casts a 25% chance to increase your haste by 6% for 6 seconds, stacking 3 times. You can have a maximum of 2 unique Venoms active at a time.","castMode":"cast","castTimeMs":1000,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":20,"percentage":true},"effects":[{"kind":"apply-aura","aura":{"id":"coa:805775:effect:0:aura:42","name":"Adrenal Venom (Aura 42)","disposition":"buff","durationMs":7200000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"poison","procs":[{"id":"coa:805775:proc:0:805894","triggers":["cast"],"chance":0.25,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805894}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":805894},{"kind":"apply-aura","aura":{"id":"coa:805775:effect:1:aura:107","name":"Adrenal Venom (Aura 107)","disposition":"buff","durationMs":7200000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"poison","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":17,"triggerSpellId":0}]}],"talentEntryId":6086},{"id":"coa-tree-29574"Line truncated
|
||||
{"schemaVersion":1,"classId":"venomancer","abilities":[{"id":"coa-29-acceleration-103-705960","classId":"venomancer","dbcSpellId":705960,"name":"Acceleration","unlockLevel":1,"icon":"/assets/ui/spells/_meat_rotten.png","sigil":"AC","description":"AccelerationRank 2Increases the tick rate of Wilt and Shadra's Vigil by 10%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705960:effect:0:aura:108","name":"Acceleration (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":19,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705960:effect:1:aura:108","name":"Acceleration (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":19,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":705960,"level":1,"description":"AccelerationRank 2Increases the tick rate of Wilt and Shadra's Vigil by 10%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705960:effect:0:aura:108","name":"Acceleration (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":19,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705960:effect:1:aura:108","name":"Acceleration (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":19,"triggerSpellId":0}]}]},{"id":"coa-tree-6086","classId":"venomancer","dbcSpellId":805775,"name":"Adrenal Venom","unlockLevel":1,"icon":"/assets/ui/spells/trade_alchemy_dpotion_a26.png","sigil":"AV","description":"Activate Adrenal Venom for 120 minutes, giving your casts a 25% chance to increase your haste by 6% for 6 seconds, stacking 3 times. You can have a maximum of 2 unique Venoms active at a time.","target":"self","range":{"min":0,"max":0},"castMode":"cast","castTimeMs":1000,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":20,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:805775:effect:0:aura:42","name":"Adrenal Venom (Aura 42)","disposition":"buff","durationMs":7200000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"poison","procs":[{"id":"coa:805775:proc:0:805894","triggers":["cast"],"chance":0.25,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805894}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":805894},{"kind":"apply-aura","aura":{"id":"coa:805775:effect:1:aura:107","name":"Adrenal Venom (Aura 107)","disposition":"buff","durationMs":7200000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"poison","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":17,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805775,"level":1,"description":"Activate Adrenal Venom for 120 minutes, giving your casts a 25% chance to increase your haste by 6% for 6 seconds, stacking 3 times. You can have a maximum of 2 unique Venoms active at a time.","castMode":"cast","castTimeMs":1000,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":20,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:805775:effect:0:aura:42","name":"Adrenal Venom (Aura 42)","disposition":"buff","durationMs":7200000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"poison","procs":[{"id":"coa:805775:proc:0:805894","triggers":["cast"],"chance":0.25,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805894}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":0,"triggerSpellId":805894},{"kind":"apply-aura","aura":{"id":"coa:805775:effect:1:aura:107","name":"Adrenal Venom (Aura 107)","disposition":"buff","durationMs":7200000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"poison","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":17,"triggeLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"sLine truncated
|
||||
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"sLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"classId":"witch-hunter","abilities":[{"id":"coa-15-agile-steps-58-705521","classId":"witch-hunter","dbcSpellId":705521,"name":"Agile Steps","unlockLevel":1,"icon":"/assets/ui/spells/inv_boot_cloth_nzothraidmythic_d_01.png","sigil":"AS","description":"Agile StepsRank 1Reduces the cooldown of Vault by 2 sec.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705521:effect:0:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705521:effect:1:aura:107","name":"Agile Steps (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-20}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705521:effect:2:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705521,"level":1,"description":"Agile StepsRank 1Reduces the cooldown of Vault by 2 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705521:effect:0:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705521:effect:1:aura:107","name":"Agile Steps (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-20}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705521:effect:2:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705522,"level":1,"description":"Agile StepsRank 2Reduces the cooldown of Vault by 4 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705522:effect:0:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705522:effect:1:aura:107","name":"Agile Steps (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-40}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705522:effect:2:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":1,"triggerSpellId":0}]},{"rank":3,"spellId":705523,"level":1,"description":"Agile StepsRank 3InstantReduces the cooldown of Vault by 3 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705523:effect:0:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705523:effect:1:aura:107","name":"Agile Steps (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-30}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705523:effect:2:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceALine truncated
|
||||
{"schemaVersion":1,"classId":"witch-hunter","abilities":[{"id":"coa-15-agile-steps-58-705521","classId":"witch-hunter","dbcSpellId":705521,"name":"Agile Steps","unlockLevel":1,"icon":"/assets/ui/spells/inv_boot_cloth_nzothraidmythic_d_01.png","sigil":"AS","description":"Agile StepsRank 1Reduces the cooldown of Vault by 2 sec.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705521:effect:0:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705521:effect:1:aura:107","name":"Agile Steps (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-20}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705521:effect:2:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705521,"level":1,"description":"Agile StepsRank 1Reduces the cooldown of Vault by 2 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705521:effect:0:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705521:effect:1:aura:107","name":"Agile Steps (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-20}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705521:effect:2:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705522,"level":1,"description":"Agile StepsRank 2Reduces the cooldown of Vault by 4 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705522:effect:0:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705522:effect:1:aura:107","name":"Agile Steps (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-40}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705522:effect:2:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":1,"triggerSpellId":0}]},{"rank":3,"spellId":705523,"level":1,"description":"Agile StepsRank 3InstantReduces the cooldown of Vault by 3 sec.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705523:effect:0:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705523:effect:1:aura:107","name":"Agile Steps (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-30}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":11,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705523:effect:2:aura:4","name":"Agile Steps (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceALine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptionLine truncated
|
||||
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name"Line truncated
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { AbilityDefinition, AbilityHealingRecipients } from "./abilityCatalog";
|
||||
import type { CombatActorState } from "./combatActors";
|
||||
import type { TimedCombatEffect } from "./combatStore";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { targetingRulesForAbility } from "./abilityRules";
|
||||
|
||||
type Position = readonly [number, number, number];
|
||||
|
||||
/** Healing riders retain their own recipients even when the cast targets an enemy. */
|
||||
export function healingRecipientIds(
|
||||
effect: AbilityHealingRecipients,
|
||||
sourceId: string,
|
||||
targetId: string | null,
|
||||
actors: Readonly<Record<string, CombatActorState>>,
|
||||
positionFor: (id: string) => Position | null,
|
||||
fallback: readonly string[],
|
||||
hasLineOfSight: (from: Position, to: Position) => boolean,
|
||||
seed = 0,
|
||||
): readonly string[] {
|
||||
if (effect.scope !== "party") return effect.recipient === "caster" ? [sourceId] : fallback;
|
||||
const center = positionFor(effect.center === "ability-target" && targetId ? targetId : sourceId);
|
||||
if (!center) return [];
|
||||
const candidates = Object.values(actors)
|
||||
.filter(actor => actor.kind !== "enemy" && actor.alive && actor.health > 0)
|
||||
.filter(actor => {
|
||||
const position = positionFor(actor.id);
|
||||
return position && position.reduce((sum, value, index) => sum + (value - center[index]) ** 2, 0) <= (effect.radius ?? 30) ** 2
|
||||
&& hasLineOfSight(center, position);
|
||||
})
|
||||
.sort((a, b) => a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id));
|
||||
if (effect.selection === "random" && candidates.length > 0) {
|
||||
let hash = 2166136261;
|
||||
for (const value of `${seed}:${sourceId}:${targetId}`) hash = Math.imul(hash ^ value.charCodeAt(0), 16777619);
|
||||
const offset = Math.floor((hash >>> 0) / 0x1_0000_0000 * candidates.length);
|
||||
return [...candidates.slice(offset), ...candidates.slice(0, offset)].slice(0, effect.maximumTargets ?? 1).map(actor => actor.id);
|
||||
}
|
||||
return candidates.slice(0, effect.maximumTargets ?? Infinity).map(actor => actor.id);
|
||||
}
|
||||
|
||||
export function healingAbilityCanAidOthers(ability: AbilityDefinition): boolean {
|
||||
return ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|
||||
&& "scope" in effect && effect.scope === "party")
|
||||
|| (ability.target !== "self" && ability.target !== "hostile");
|
||||
}
|
||||
|
||||
/** Let an existing HoT finish rather than resetting its first tick on every GCD. */
|
||||
export function healingAbilityHasPendingHot(
|
||||
ability: AbilityDefinition, sourceId: string, targetId: string, effects: readonly TimedCombatEffect[],
|
||||
): boolean {
|
||||
return ability.effects.some(effect => effect.kind === "hot") && effects.some(effect => effect.kind === "hot"
|
||||
&& effect.sourceAbilityId === ability.id && effect.sourceActorId === sourceId
|
||||
&& (effect.targetId ?? PLAYER_AGGRO_ID) === targetId && effect.remainingTicks > 1);
|
||||
}
|
||||
|
||||
export function healingAbilityIsArea(ability: AbilityDefinition): boolean {
|
||||
const rules = targetingRulesForAbility(ability);
|
||||
return rules.shape !== "unit" || (rules.maximumTargets ?? 1) > 1 || (rules.radius ?? ability.radius ?? 0) > 0
|
||||
|| ability.effects.some(effect => "scope" in effect && effect.scope === "party");
|
||||
}
|
||||
+18
-12
@@ -10,6 +10,8 @@ import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||
import { spendDeathKnightRunes } from "./combatResources";
|
||||
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
|
||||
import type { PartyMember } from "./partyStore";
|
||||
import { baseResourcesFor } from "./wotlkStats";
|
||||
import { companionResourceMaximum } from "./combatActorAuthority";
|
||||
|
||||
export interface PartyAbilityContext {
|
||||
readonly now: number;
|
||||
@@ -27,10 +29,12 @@ function currentResource(member: PartyMember, type: ResourceType): number {
|
||||
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
|
||||
}
|
||||
|
||||
function abilityCosts(ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
|
||||
function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
|
||||
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ({
|
||||
resource: cost.resource,
|
||||
amount: cost.percentage ? cost.amount / 100 : cost.amount,
|
||||
amount: cost.percentage ? cost.amount / 100 * (cost.percentageBasis === "base-mana"
|
||||
? baseResourcesFor(member.classId, member.level).mana
|
||||
: cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource)) : cost.amount,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -40,12 +44,8 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
|
||||
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||
if (abilityCosts(ability).some((cost) => {
|
||||
const amount = cost.amount <= 1 && ability.cost.percentage
|
||||
? (cost.resource === member.resourceType ? member.maxResource : 100) * cost.amount
|
||||
: cost.amount;
|
||||
return currentResource(member, cost.resource) < amount;
|
||||
})) return false;
|
||||
if (abilityCosts(member, ability).some(cost => cost.resource === "health"
|
||||
? member.health <= cost.amount : currentResource(member, cost.resource) < cost.amount)) return false;
|
||||
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
|
||||
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
|
||||
}
|
||||
@@ -78,7 +78,11 @@ function scoreAbility(member: PartyMember, ability: AbilityDefinition, context:
|
||||
if ((context.lowestHealthFraction ?? 1) < 0.3 && categories.has("emergency-heal")) score += 950;
|
||||
if ((context.injuredAllies ?? 0) >= 3 && categories.has("area-heal")) score += 850;
|
||||
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) score += 600;
|
||||
if (categories.has("mitigation")) score += 350;
|
||||
if (categories.has("mitigation") && ((context.lowestHealthFraction ?? 1) < 0.8 || member.health / member.maxHealth < 0.8)) score += 350;
|
||||
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) {
|
||||
score += Math.min(150, ability.effects.reduce((total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
||||
? partyAbilityAmount(member, ability, effect) * (effect.kind === "hot" ? Math.min(3, effect.ticks) : 1) : 0), 0) / member.maxHealth * 100);
|
||||
}
|
||||
if (context.target?.activeCast && categories.has("interrupt")) score += 250;
|
||||
if (categories.has("damage")) score += 80;
|
||||
} else {
|
||||
@@ -114,9 +118,10 @@ export function commitPartyAbilityUse(
|
||||
const resourcePools = { ...member.resourcePools };
|
||||
const lastResourceSpentAt = { ...member.lastResourceSpentAt };
|
||||
let resource = member.resource;
|
||||
for (const cost of abilityCosts(ability)) {
|
||||
const maximum = cost.resource === member.resourceType ? member.maxResource : 100;
|
||||
const amount = cost.amount <= 1 && ability.cost.percentage ? maximum * cost.amount : cost.amount;
|
||||
let health = member.health;
|
||||
for (const cost of abilityCosts(member, ability)) {
|
||||
const amount = cost.amount;
|
||||
if (cost.resource === "health") { health -= amount; continue; }
|
||||
const current = currentResource(member, cost.resource);
|
||||
resourcePools[cost.resource] = Math.max(0, current - amount);
|
||||
if (amount > 0) lastResourceSpentAt[cost.resource] = now;
|
||||
@@ -127,6 +132,7 @@ export function commitPartyAbilityUse(
|
||||
if (!runes) return null;
|
||||
return {
|
||||
...member,
|
||||
health,
|
||||
resource,
|
||||
resourcePools,
|
||||
lastResourceSpentAt,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
|
||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
@@ -49,6 +50,28 @@ describe("party combat runtime", () => {
|
||||
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
|
||||
});
|
||||
|
||||
it("maintains HoTs on separate injured allies without resetting pending ticks", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 8 });
|
||||
usePartyStore.getState().initializeParty("party-hot-coverage", 8, useCombatStore.getState().health, "damage");
|
||||
const healer = usePartyStore.getState().members.find(member => member.role === "healer")!;
|
||||
const abilities = abilitiesForClass("druid");
|
||||
const hot = abilities.find(ability => ability.name === "Rejuvenation")!;
|
||||
usePartyStore.setState(state => ({ members: state.members.map(member => member.id === healer.id
|
||||
? { ...member, classId: "druid", resourceType: "mana", resource: 10_000, maxResource: 10_000,
|
||||
resourcePools: { mana: 10_000 }, talentRanks: {}, cooldowns: Object.fromEntries(abilities.filter(a => a.id !== hot.id).map(a => [a.id, 60_000])) }
|
||||
: { ...member, health: member.maxHealth * .5 }) }));
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("hot-boss", 100_000);
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const first = useCombatStore.getState().effects.find(effect => effect.kind === "hot" && effect.sourceActorId === healer.id)!;
|
||||
expect(first).toBeDefined();
|
||||
advanceDungeonPartyCombat(3_000);
|
||||
const effects = useCombatStore.getState().effects.filter(effect => effect.kind === "hot" && effect.sourceActorId === healer.id);
|
||||
expect(new Set(effects.map(effect => effect.targetId)).size).toBe(2);
|
||||
expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt);
|
||||
});
|
||||
|
||||
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||
registerEngagedMob();
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||
|
||||
+30
-16
@@ -56,6 +56,7 @@ import {
|
||||
import { normalizeActorResourcePool } from "./combatActors";
|
||||
import { advanceCombatResourcePool } from "./combatResources";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules";
|
||||
import {
|
||||
combatHasLineOfSight,
|
||||
combatSpatialActor,
|
||||
@@ -884,17 +885,22 @@ function choosePartySupportIntent(
|
||||
|| (right.maxHealth - right.health) - (left.maxHealth - left.health)
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
const target = injured[0];
|
||||
if (!target) return null;
|
||||
const ability = choosePartyAbility(member, {
|
||||
now,
|
||||
target: null,
|
||||
injuredAllies: injured.length,
|
||||
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
|
||||
});
|
||||
return ability?.effects.some((effect) => ["heal", "hot", "shield"].includes(effect.kind))
|
||||
? { ability, targetId: target.id }
|
||||
: null;
|
||||
for (const target of injured) {
|
||||
const ability = choosePartyAbility(member, {
|
||||
now,
|
||||
target: null,
|
||||
injuredAllies: injured.length,
|
||||
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
|
||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind))
|
||||
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
|
||||
&& partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id)
|
||||
&& !(ability.effects.some(effect => effect.kind === "shield") && (combat.actors[target.id]?.absorb ?? 0) > 0),
|
||||
});
|
||||
if (ability) return { ability, targetId: ability.target === "self" ? member.id : target.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function executePartyAbility(
|
||||
@@ -933,17 +939,23 @@ function executePartyAbility(
|
||||
if (effect.kind === "dot") {
|
||||
if (!groundAreaCreated) combat.applyTimedActorEffect(member.id, hostileTargetId, ability, effect, now);
|
||||
} else {
|
||||
effectiveAmount += combat.damageMob(
|
||||
const result = combat.damageMob(
|
||||
hostileTargetId,
|
||||
partyAbilityAmount(runtimeMember, ability, effect),
|
||||
now,
|
||||
{ actorId: member.id, role: member.role, abilityId: ability.id },
|
||||
spellSchool,
|
||||
).amount;
|
||||
);
|
||||
effectiveAmount += result.amount;
|
||||
if (effect.kind === "damage" && effect.onDamageResource && result.amount > 0) {
|
||||
combat.restoreActorResource(member.id, effect.onDamageResource.resource, effect.onDamageResource.amount,
|
||||
false, member.id, ability.id, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "heal") {
|
||||
for (const friendlyTargetId of friendlyTargets) {
|
||||
for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors,
|
||||
id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) {
|
||||
const actor = useCombatStore.getState().actors[friendlyTargetId];
|
||||
if (!actor?.alive) continue;
|
||||
const requested = partyAbilityAmount(runtimeMember, ability, effect)
|
||||
@@ -959,11 +971,13 @@ function executePartyAbility(
|
||||
effectiveAmount += result.effectiveAmount;
|
||||
}
|
||||
} else if (effect.kind === "hot") {
|
||||
if (!groundAreaCreated) for (const friendlyTargetId of friendlyTargets) {
|
||||
if (!groundAreaCreated) for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors,
|
||||
id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) {
|
||||
useCombatStore.getState().applyTimedActorEffect(member.id, friendlyTargetId, ability, effect, now);
|
||||
}
|
||||
} else if (effect.kind === "shield") {
|
||||
for (const friendlyTargetId of friendlyTargets) {
|
||||
for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors,
|
||||
id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) {
|
||||
effectiveAmount += useCombatStore.getState().shieldActor(
|
||||
friendlyTargetId,
|
||||
partyAbilityAmount(runtimeMember, ability, effect),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { useCombatStore, equipmentAdjustedCombatAmount } from "./combatStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
|
||||
const ability = (name: string, level = 8) => abilityAtLevel(abilitiesForClass("primalist").find(a => a.name === name)!, level);
|
||||
const gains = (id: string) => useCombatStore.getState().combatEvents.filter(e => e.kind === "resource" && e.abilityId === id);
|
||||
|
||||
function beginBarrage(health = 100_000, absorb = 0): void {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.registerMob("target", { name: "Target", level: 8, maxHealth: health, xpReward: 0 });
|
||||
setMobPosition("target", [25, 0, 0]);
|
||||
combat.selectMob("target");
|
||||
if (absorb > 0) useCombatStore.setState(state => ({ auras: applyAura(state.auras, {
|
||||
definition: { id: "stone-absorb", name: "Stone Absorb", disposition: "buff", durationMs: 10_000,
|
||||
absorbs: [{ id: "pool", amount: absorb }] }, sourceId: "target", targetId: "target", now: 1_000,
|
||||
}).auras }));
|
||||
const rageBefore = useCombatStore.getState().resource;
|
||||
expect(combat.castAbility(ability("Geode Barrage").id, undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().resource).toBe(rageBefore);
|
||||
combat.tick(0, 3_000); // Finish the two-second cast.
|
||||
combat.tick(0, 3_000); // Resolve its initial stone.
|
||||
}
|
||||
|
||||
describe("Primalist rage generation", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry();
|
||||
usePartyStore.setState({ members: [], selectedMemberId: null });
|
||||
useCombatStore.getState().initializeCharacter({ classId: "primalist", role: "healer", level: 8 });
|
||||
});
|
||||
|
||||
it("lands three stones over one second and grants 5 rage per hit without weapon attacks", () => {
|
||||
beginBarrage();
|
||||
expect(useCombatStore.getState().resource).toBe(5);
|
||||
useCombatStore.getState().tick(0, 3_499);
|
||||
expect(useCombatStore.getState().resource).toBe(5);
|
||||
useCombatStore.getState().tick(0, 3_500);
|
||||
expect(useCombatStore.getState().resource).toBe(10);
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().resource).toBe(15);
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(15);
|
||||
expect(useCombatStore.getState().autoAttackTargetId).toBeNull();
|
||||
expect(gains(ability("Geode Barrage").id).map(e => [e.occurredAt, e.effectiveAmount]))
|
||||
.toEqual([[3_000, 5], [3_500, 5], [4_000, 5]]);
|
||||
expect(useCombatStore.getState().combatEvents.filter(e => e.kind === "damage" && e.abilityId === ability("Geode Barrage").id)).toHaveLength(3);
|
||||
useCombatStore.getState().tick(0, 5_000);
|
||||
expect(gains(ability("Geode Barrage").id)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("uses the ranked damage with nature spell power and attack power once per stone", () => {
|
||||
const spell = ability("Geode Barrage", 80);
|
||||
const stone = spell.effects.find(e => e.kind === "dot")!;
|
||||
const amount = (gear: typeof EMPTY_ITEM_STATS) => equipmentAdjustedCombatAmount(stone, 80, gear, "primalist", spell);
|
||||
const base = amount(EMPTY_ITEM_STATS);
|
||||
expect(amount({ ...EMPTY_ITEM_STATS, spellPower: 100 }) - base).toBe(23);
|
||||
expect(amount({ ...EMPTY_ITEM_STATS, attackPower: 100 }) - base).toBe(9);
|
||||
expect(base).toBeGreaterThan(equipmentAdjustedCombatAmount(ability("Geode Barrage").effects.find(e => e.kind === "dot")!, 8, EMPTY_ITEM_STATS, "primalist", ability("Geode Barrage")));
|
||||
});
|
||||
|
||||
it("stops rage gains when the target dies and caps gains at 100", () => {
|
||||
beginBarrage(1);
|
||||
useCombatStore.getState().tick(0, 5_000);
|
||||
expect(gains(ability("Geode Barrage").id)).toHaveLength(1);
|
||||
expect(useCombatStore.getState().resource).toBe(5);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "primalist", role: "healer", level: 8 });
|
||||
useCombatStore.setState({ resource: 98, resourcePools: { ...useCombatStore.getState().resourcePools, rage: 98 } });
|
||||
beginBarrage();
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().resource).toBe(100);
|
||||
expect(gains(ability("Geode Barrage").id).map(e => e.effectiveAmount)).toEqual([2, 0, 0]);
|
||||
});
|
||||
|
||||
it("credits companion Barrage hits to their caster's rage pool", () => {
|
||||
usePartyStore.getState().initializeParty("primalist-source", 8, useCombatStore.getState().maxHealth);
|
||||
const original = usePartyStore.getState().members[0];
|
||||
usePartyStore.setState({ members: [{ ...original, classId: "primalist", resourceType: "rage", resource: 0,
|
||||
maxResource: 100, resourcePools: { rage: 0 }, gearStats: EMPTY_ITEM_STATS }] });
|
||||
registerPartyRuntimePosition(original.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("target", [25, 0, 0]);
|
||||
const spell = ability("Geode Barrage");
|
||||
const stone = spell.effects.find(e => e.kind === "dot")!;
|
||||
if (stone.kind !== "dot") throw new Error("Missing stones");
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(original.id, "target", spell, stone, 1_000)).toBe(true);
|
||||
useCombatStore.getState().tick(0, 2_000);
|
||||
expect(useCombatStore.getState().actors[original.id].resources.rage.current).toBe(15);
|
||||
expect(usePartyStore.getState().members[0].resource).toBe(15);
|
||||
expect(useCombatStore.getState().resource).toBe(0);
|
||||
expect(gains(spell.id).every(e => e.sourceActorId === original.id && e.targetActorId === original.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not grant rage for fully absorbed stones", () => {
|
||||
beginBarrage(100_000, 10_000);
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().resource).toBe(0);
|
||||
expect(gains(ability("Geode Barrage").id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("normalizes the passive Protective Roar payload to 20 rage when triggered", () => {
|
||||
const spell = ability("Protective Roar");
|
||||
expect(spell.passive).toBe(true);
|
||||
expect(spell.target).toBe("self");
|
||||
expect(spell.effects.find(e => e.kind === "resource")).toMatchObject({ resource: "rage", amount: 20 });
|
||||
useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("target", [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob("target");
|
||||
useCombatStore.setState(state => ({ auras: applyAura(state.auras, {
|
||||
definition: { id: "roar-trigger", name: "Roar Trigger", disposition: "buff", durationMs: 10_000,
|
||||
procs: [{ id: "roar", triggers: "cast", chance: 1, charges: 1,
|
||||
action: { kind: "custom", id: "wow335-trigger-spell", data: { spellId: spell.dbcSpellId } } }] },
|
||||
sourceId: PLAYER_AGGRO_ID, targetId: PLAYER_AGGRO_ID, now: 1_000,
|
||||
}).auras }));
|
||||
expect(useCombatStore.getState().castAbility(ability("Primal Strike").id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(20);
|
||||
expect(useCombatStore.getState().resource).toBe(20);
|
||||
});
|
||||
|
||||
it("restores Seismic Crash's 20 rage once per cast, alongside its initial damage pulse", () => {
|
||||
const spell = ability("Seismic Crash");
|
||||
useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("target", [20, 0, 0]);
|
||||
useCombatStore.getState().selectMob("target");
|
||||
expect(useCombatStore.getState().castAbility(spell.id, undefined, 1_000).ok).toBe(true);
|
||||
useCombatStore.getState().tick(0, 2_000);
|
||||
expect(gains(spell.id).map(e => e.effectiveAmount)).toEqual([20]);
|
||||
expect(useCombatStore.getState().combatEvents.filter(e => e.kind === "damage" && e.abilityId === spell.id)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("grants 4 rage when Seismic Spike lands", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "primalist", role: "healer", level: 20 });
|
||||
const spell = ability("Seismic Spike", 20);
|
||||
useCombatStore.getState().registerMob("target", { name: "Target", level: 20, maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("target", [15, 0, 0]);
|
||||
useCombatStore.getState().selectMob("target");
|
||||
expect(useCombatStore.getState().castAbility(spell.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().resource).toBe(4);
|
||||
expect(gains(spell.id).map(e => e.effectiveAmount)).toEqual([4]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { defaultActionBarForCharacter } from "./abilityCatalog";
|
||||
import { defaultActionBarForCharacter, resourceProfileForClass } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { companionActorFromMember, companionActorWithMemberRuntime } from "./combatActorAuthority";
|
||||
|
||||
describe("RuneWaker combat runtime", () => {
|
||||
beforeEach(() => {
|
||||
@@ -68,6 +70,31 @@ describe("RuneWaker combat runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["rom-warrior", "rom-scout", "rom-rogue"] as const)(
|
||||
"preserves %s secondary mana in player and companion resource snapshots",
|
||||
(classId) => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId, secondaryClassId: "rom-warden", raceId: "rom-elf", level: 80,
|
||||
});
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.maxResourcePools.mana).toBe(100);
|
||||
expect(state.resourcePools.mana).toBe(100);
|
||||
state.synchronizeActors();
|
||||
expect(useCombatStore.getState().actors[PLAYER_AGGRO_ID].resources.mana).toMatchObject({ current: 100, maximum: 100 });
|
||||
|
||||
usePartyStore.getState().initializeParty("rom-secondary-mana", 80, state.health);
|
||||
const member = {
|
||||
...usePartyStore.getState().members[0]!, classId, raceId: state.raceId,
|
||||
resourceType: resourceProfileForClass(classId).type, maxResource: state.maxResource,
|
||||
resourcePools: state.resourcePools, gearStats: state.gearStats,
|
||||
};
|
||||
const actor = companionActorFromMember(member);
|
||||
expect(actor.resources.mana).toMatchObject({ current: 100, maximum: 100 });
|
||||
expect(companionActorWithMemberRuntime(actor, member).resources.mana)
|
||||
.toMatchObject({ current: 100, maximum: 100 });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["rom-priest", "rom-human", "rom-priest-general-1"],
|
||||
["rom-druid", "rom-elf", "rom-druid-primary-0"],
|
||||
@@ -262,10 +289,13 @@ describe("RuneWaker combat runtime", () => {
|
||||
const revival = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("rom-revival-charge"));
|
||||
expect(revival?.stacks).toBe(3);
|
||||
const injuredHealth = useCombatStore.getState().health;
|
||||
const manaBefore = useCombatStore.getState().resourcePools.mana!;
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warden-elite-60", undefined, Date.now())).toMatchObject({ ok: true });
|
||||
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(injuredHealth);
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(manaBefore - 15);
|
||||
expect(useCombatStore.getState().resourcePools.energy).toBe(100);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-revival-charge"))).toBe(false);
|
||||
});
|
||||
});
|
||||
+14
-2
@@ -9,7 +9,15 @@ import { validBossDifficulty } from "./dungeonDifficulty";
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
export type GameMode = "dungeon" | "manastorm";
|
||||
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
|
||||
export type GameOverlay = "pause" | "boss-test-review" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
|
||||
|
||||
export interface BossTestSession {
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly sourceDungeonId: string;
|
||||
readonly entityId: string;
|
||||
readonly runtimeIds: readonly string[];
|
||||
}
|
||||
|
||||
interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
@@ -24,6 +32,7 @@ export interface GameState {
|
||||
activeBossDifficulty: number;
|
||||
gameMode: GameMode;
|
||||
gmTestMode: boolean;
|
||||
bossTest: BossTestSession | null;
|
||||
activeGmReviewTarget: GmReviewTarget | null;
|
||||
activeSpawn: WorldSpawn;
|
||||
sessionRevision: number;
|
||||
@@ -55,6 +64,7 @@ export interface GameState {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
readonly bossTest?: BossTestSession | null;
|
||||
readonly gmReviewTarget?: GmReviewTarget | null;
|
||||
readonly bossDifficulty?: number;
|
||||
},
|
||||
@@ -88,6 +98,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeBossDifficulty: 1,
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
bossTest: null,
|
||||
activeGmReviewTarget: null,
|
||||
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
|
||||
sessionRevision: 0,
|
||||
@@ -125,6 +136,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1,
|
||||
gameMode: options?.gameMode ?? "dungeon",
|
||||
gmTestMode: options?.gmTestMode ?? false,
|
||||
bossTest: options?.bossTest ?? null,
|
||||
activeGmReviewTarget: options?.gmReviewTarget ?? null,
|
||||
activeSpawn,
|
||||
sessionRevision: state.sessionRevision + 1,
|
||||
@@ -279,7 +291,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
|
||||
return {
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
gmTestMode: Boolean(state.bossTest),
|
||||
activeGmReviewTarget: null,
|
||||
activeSpawn,
|
||||
overlay: null,
|
||||
|
||||
@@ -1,10 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay,
|
||||
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
|
||||
HUD_ELEMENTS, resolveHudScreen,
|
||||
} from "./uiLayout";
|
||||
|
||||
describe("personal UI layouts", () => {
|
||||
it("assigns every element one display, saves routing, and keeps single-display widgets available", () => {
|
||||
const preferences = normalizeUiPreferences(null);
|
||||
const layout = preferences.current.mobile;
|
||||
expect(resolveHudScreen("party", layout, true)).toBe("top");
|
||||
for (const id of ["actions", "player", "cast", "experience"] as const) expect(resolveHudScreen(id, layout, true)).toBe("bottom");
|
||||
for (const id of Object.keys(HUD_ELEMENTS) as (keyof typeof HUD_ELEMENTS)[]) {
|
||||
layout.elements[id].screen = "bottom";
|
||||
expect(resolveHudScreen(id, layout, true)).toBe("bottom");
|
||||
expect(resolveHudScreen(id, layout, false)).toBe("top");
|
||||
if (id === "dps") {
|
||||
expect(resolveDpsMeterDisplay(layout, false)).toBe("main");
|
||||
expect(resolveDpsMeterDisplay(layout, true)).toBe("companion");
|
||||
}
|
||||
layout.elements[id].screen = "top";
|
||||
expect(resolveHudScreen(id, layout, true)).toBe("top");
|
||||
layout.elements[id].screen = "hidden";
|
||||
expect(resolveHudScreen(id, layout, true)).toBe("hidden");
|
||||
}
|
||||
expect(importUiPreferences(exportUiPreferences(preferences))).toEqual(preferences);
|
||||
});
|
||||
it("saves and clears abilities on inactive WoW bars without changing keys, templates, or mobile controls", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.slots.pc[0] = { name: "Original", layout: ui.current.pc };
|
||||
const original = exportUiPreferences(ui);
|
||||
ui.current.pc = assignDesktopAbility(ui.current.pc, 4, 11, "test-heal");
|
||||
const saved = importUiPreferences(exportUiPreferences(ui));
|
||||
expect(saved.current.pc.bars[4].slots[11]).toEqual({ abilityId: "test-heal", key: null });
|
||||
expect(saved.current.pc.bars[0]).toEqual(JSON.parse(original).current.pc.bars[0]);
|
||||
expect(saved.current.mobile).toEqual(ui.current.mobile);
|
||||
expect(saved.slots.pc[0]?.layout.bars[4].slots[11].abilityId).toBeNull();
|
||||
expect(assignDesktopAbility(saved.current.pc, 4, 11, null).bars[4].slots[11].abilityId).toBeNull();
|
||||
});
|
||||
it("restores one or all WoW ability bars with the correct offset and retains custom keys and geometry", () => {
|
||||
let layout = defaultUiLayout();
|
||||
layout.wowBars = true; layout.barCount = 2; layout.bars[1].columns = 3;
|
||||
layout = bindDesktopSlot(layout, 1, 0, "Ctrl+KeyQ");
|
||||
layout = assignDesktopAbility(assignDesktopAbility(layout, 0, 0, "custom-one"), 1, 0, "custom-two");
|
||||
const defaults = Array.from({ length: 14 }, (_, i) => `spell-${i}`);
|
||||
const bar = resetDesktopAbilities(layout, defaults, 1);
|
||||
expect(bar.bars[0]).toEqual(layout.bars[0]);
|
||||
expect(bar.bars[1].slots[0]).toEqual({ abilityId: "spell-12", key: "Ctrl+KeyQ" });
|
||||
expect(bar.bars[1].slots[2].abilityId).toBeNull();
|
||||
const all = resetDesktopAbilities(bar, defaults);
|
||||
expect(all.bars[0].slots[0]).toEqual({ abilityId: "spell-0", key: "Digit1" });
|
||||
expect(all.bars[1].columns).toBe(3);
|
||||
expect(all.wowBars).toBe(true); expect(all.barCount).toBe(2);
|
||||
});
|
||||
it("defaults DPS to PC lower-left flow and the Thor companion, with saved overrides", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
expect(ui.current.pc.elements.dps).toEqual({ visible: true, position: null, width: null });
|
||||
|
||||
+30
-2
@@ -6,7 +6,7 @@ export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
|
||||
export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
|
||||
export const HUD_ELEMENTS = {
|
||||
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
|
||||
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
|
||||
player: "Buffs and debuffs", timeline: "Encounter timeline", target: "Enemy target",
|
||||
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
|
||||
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
||||
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
||||
@@ -16,8 +16,10 @@ export const HUD_ELEMENTS = {
|
||||
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
|
||||
} as const;
|
||||
export type HudElementId = keyof typeof HUD_ELEMENTS;
|
||||
export type HudScreen = "auto" | "top" | "bottom" | "hidden";
|
||||
export interface HudElementLayout {
|
||||
visible: boolean;
|
||||
screen?: HudScreen;
|
||||
position: NormalizedHudPosition | null;
|
||||
width: number | null;
|
||||
}
|
||||
@@ -87,6 +89,7 @@ export function normalizeUiLayout(value: unknown): UiLayout {
|
||||
const element = record(elements[id]);
|
||||
defaults.elements[id] = {
|
||||
visible: element.visible !== false,
|
||||
...(["auto", "top", "bottom", "hidden"].includes(String(element.screen)) ? { screen: element.screen as HudScreen } : {}),
|
||||
position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null,
|
||||
width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null,
|
||||
};
|
||||
@@ -114,8 +117,21 @@ export function normalizeUiLayout(value: unknown): UiLayout {
|
||||
}
|
||||
|
||||
export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" {
|
||||
const screen = layout.elements.dps.screen;
|
||||
if (screen === "top" || screen === "bottom") return !dualScreen || screen === "top" ? "main" : "companion";
|
||||
return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay;
|
||||
}
|
||||
/** One owner per widget; single-display layouts keep all visible widgets available. */
|
||||
export function resolveHudScreen(id: HudElementId, layout: UiLayout, dualScreen: boolean): "top" | "bottom" | "hidden" {
|
||||
const element = layout.elements[id];
|
||||
if (!element.visible || element.screen === "hidden") return "hidden";
|
||||
if (!dualScreen) return "top";
|
||||
if (element.screen === "top" || element.screen === "bottom") return element.screen;
|
||||
if (id === "dps") return resolveDpsMeterDisplay(layout, true) === "main" ? "top" : "bottom";
|
||||
if (["player", "actions", "cast", "experience", "bar-1", "bar-2", "bar-3", "bar-4", "bar-5"].includes(id)) return "bottom";
|
||||
if (id === "hints") return "hidden";
|
||||
return "top";
|
||||
}
|
||||
export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||
const source = record(value);
|
||||
const current = record(source.current);
|
||||
@@ -139,6 +155,18 @@ export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: n
|
||||
...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key,
|
||||
})) })) };
|
||||
}
|
||||
export function assignDesktopAbility(layout: UiLayout, barIndex: number, slotIndex: number, abilityId: string | null): UiLayout {
|
||||
return { ...layout, bars: layout.bars.map((bar, b) => b !== barIndex ? bar : {
|
||||
...bar, slots: bar.slots.map((slot, s) => s === slotIndex ? { ...slot, abilityId } : slot),
|
||||
}) };
|
||||
}
|
||||
|
||||
/** Restore abilities only; key chords and the arrangement belong to the UI template. */
|
||||
export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly string[], barIndex?: number): UiLayout {
|
||||
return { ...layout, bars: layout.bars.map((bar, b) => barIndex !== undefined && b !== barIndex ? bar : {
|
||||
...bar, slots: bar.slots.map((slot, s) => ({ ...slot, abilityId: abilityIds[b * 12 + s] ?? null })),
|
||||
}) };
|
||||
}
|
||||
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
|
||||
if (!layout.wowBars || !key) return null;
|
||||
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
|
||||
@@ -162,7 +190,7 @@ export function importUiPreferences(text: string): UiPreferences {
|
||||
if (id === "dps" && record(layout.elements)[id] === undefined) continue;
|
||||
const element = record(record(layout.elements)[id]);
|
||||
const position = record(element.position);
|
||||
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||||
if ((element.screen !== undefined && !["auto", "top", "bottom", "hidden"].includes(String(element.screen))) || typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||||
|| (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility.");
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
|
||||
@@ -6,7 +6,8 @@ import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreference
|
||||
import { publishInputPreferences } from "../input/keybindRuntime";
|
||||
|
||||
export function currentUiDevice(): UiDevice {
|
||||
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|
||||
return (typeof window !== "undefined" && /(?:[?&]layout=(?:thor-preview|dual)|[?&]display=(?:top|bottom))\b/.test(window.location.search))
|
||||
|| Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|
||||
|| (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100)
|
||||
? "mobile" : "pc";
|
||||
}
|
||||
|
||||
@@ -4,15 +4,21 @@ import { Capacitor } from "@capacitor/core";
|
||||
import App from "./App";
|
||||
import { startControllerInput } from "./input/controller";
|
||||
import { currentDisplayLayout } from "./platform/displayLayout";
|
||||
import { THOR_DISPLAYS } from "./platform/thorDisplayProfile";
|
||||
import { initializeContentSystem } from "./content/contentManager";
|
||||
import "./styles.css";
|
||||
import "./ui/dungeonReports.css";
|
||||
import "./ui/thorUi.css";
|
||||
|
||||
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const displayMode = params.get("display");
|
||||
const nativeLayoutRequested = params.has("nativeLayout");
|
||||
if (Capacitor.isNativePlatform() && (displayMode === "top" || displayMode === "bottom")) {
|
||||
// Let each WebView fit the same physical UI density, independently of Android's shared display density.
|
||||
document.querySelector('meta[name="viewport"]')?.setAttribute("content", `width=${THOR_DISPLAYS[displayMode].cssWidth}, viewport-fit=cover`);
|
||||
}
|
||||
if (Capacitor.isNativePlatform() || nativeLayoutRequested) document.documentElement.classList.add("native-platform");
|
||||
if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode;
|
||||
document.documentElement.dataset.displayLayout = currentDisplayLayout();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { normalizeInputPreferences } from "../input/keybinds";
|
||||
import { publishInputPreferences } from "../input/keybindRuntime";
|
||||
import { useForcedThorDisplays } from "./useThorDualScreen";
|
||||
import { RemoteDungeonHud } from "./RemoteDungeonHud";
|
||||
|
||||
export function BottomDisplayApp() {
|
||||
useForcedThorDisplays();
|
||||
@@ -78,5 +79,7 @@ export function BottomDisplayApp() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <main className="bottom-display-root"><CompanionPanel snapshot={snapshot} send={send} preferControllerBinds /></main>;
|
||||
return <main className="bottom-display-root">{snapshot.phase === "game"
|
||||
? <RemoteDungeonHud snapshot={snapshot} send={send} />
|
||||
: <CompanionPanel snapshot={snapshot} send={send} preferControllerBinds />}</main>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { clearBossTestingDungeons, registerBossTestingDungeon } from "../game/dungeonRegistry";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { DungeonCompanionHud } from "../ui/DungeonCompanionHud";
|
||||
import type { CompanionCommand, CompanionSnapshot } from "./dualScreenSync";
|
||||
|
||||
export function RemoteDungeonHud({ snapshot, send }: {
|
||||
snapshot: CompanionSnapshot;
|
||||
send: (command: CompanionCommand) => void;
|
||||
}) {
|
||||
const hud = snapshot.game.hud;
|
||||
useLayoutEffect(() => {
|
||||
if (hud.bossTestingDungeon) registerBossTestingDungeon(hud.bossTestingDungeon.definition, hud.bossTestingDungeon.sourceDungeonId);
|
||||
const session = useShellStore.getState().session;
|
||||
useShellStore.setState({ ...hud.shell,
|
||||
session: session?.ownerId === snapshot.session?.ownerId ? session : snapshot.session,
|
||||
});
|
||||
useUiLayoutStore.setState(hud.ui);
|
||||
useGameStore.setState({ ...hud.game,
|
||||
toggleMap: () => send({ name: "toggleMap" }),
|
||||
openOverlay: (overlay) => send({ name: "openOverlay", overlay }),
|
||||
});
|
||||
if (!hud.bossTestingDungeon) clearBossTestingDungeons();
|
||||
useCombatStore.setState({ ...hud.combat,
|
||||
castActionBinding: (layer, control) => { send({ name: "castActionBinding", layer, control }); return { ok: false, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 }; },
|
||||
selectFriendlyActor: (id) => { send({ name: "selectFriendlyActor", id }); return true; },
|
||||
clearTarget: () => send({ name: "clearTarget" }),
|
||||
updateSettings: (patch) => { if (patch.threatMeterPosition) send({ name: "moveThreatMeter", position: patch.threatMeterPosition }); },
|
||||
});
|
||||
usePartyStore.setState({ ...hud.party,
|
||||
selectMember: (memberId) => { if (memberId) send({ name: "selectPartyMember", memberId }); else send({ name: "clearPartySelection" }); return true; },
|
||||
selectSelf: () => send({ name: "selectPlayer" }),
|
||||
clearSelection: () => send({ name: "clearPartySelection" }),
|
||||
setCommand: (command) => send({ name: "setPartyCommand", command }),
|
||||
recallParty: () => send({ name: "recallParty" }),
|
||||
});
|
||||
useManastormStore.setState(hud.manastorm);
|
||||
useDungeonRunStore.setState({ progress: hud.run });
|
||||
useOnlineSessionStore.setState({ snapshot: hud.onlineSession });
|
||||
useOnlineGroupStore.setState(hud.onlineGroup);
|
||||
useDpsMeterStore.setState(hud.dps);
|
||||
}, [hud, send, snapshot.session]);
|
||||
return <DungeonCompanionHud send={send} remote completion={hud.completion} interaction={hud.interaction} />;
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessa
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
|
||||
|
||||
export function connectAuthoritativeDualScreen(
|
||||
channel: BroadcastChannel,
|
||||
@@ -38,6 +42,11 @@ export function connectAuthoritativeDualScreen(
|
||||
const unsubscribeDps = useDpsMeterStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
const request = () => { if (companionReady) gamePublisher.request(); };
|
||||
const unsubscribeOnline = useOnlineSessionStore.subscribe(request);
|
||||
const unsubscribeGroup = useOnlineGroupStore.subscribe(request);
|
||||
const unsubscribeRun = useDungeonRunStore.subscribe(request);
|
||||
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(request);
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === "companion-ready") {
|
||||
companionReady = true;
|
||||
@@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen(
|
||||
unsubscribeParty();
|
||||
unsubscribeUi();
|
||||
unsubscribeDps();
|
||||
unsubscribeOnline();
|
||||
unsubscribeGroup();
|
||||
unsubscribeRun();
|
||||
unsubscribeEncounters();
|
||||
gamePublisher.dispose();
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,10 @@ import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { normalizeHudPosition } from "../game/hudLayout";
|
||||
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentDisplayLayout } from "./displayLayout";
|
||||
import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot";
|
||||
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
|
||||
import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction";
|
||||
import { resurrectDeadPartyMembers } from "../game/partyResurrection";
|
||||
|
||||
export { createDualScreenChannel } from "./dualScreenChannel";
|
||||
|
||||
@@ -95,6 +99,7 @@ export interface CompanionPartyObjectiveSnapshot {
|
||||
}
|
||||
|
||||
export interface CompanionGameSnapshot {
|
||||
hud: HudSnapshot;
|
||||
dpsMeter: {
|
||||
values: DpsMeterSnapshot;
|
||||
layout: HudElementLayout;
|
||||
@@ -172,7 +177,15 @@ export type CompanionCommand =
|
||||
| { name: "setPartyCommand"; command: PartyCommand }
|
||||
| { name: "recallParty" }
|
||||
| { name: "resetAtEntrance" }
|
||||
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
|
||||
| { name: "editDpsMeter"; update: Partial<HudElementLayout> }
|
||||
| { name: "editHudElement"; id: HudElementId; update: Partial<HudElementLayout> }
|
||||
| { name: "selectFriendlyActor"; id: string | null }
|
||||
| { name: "selectPlayer" }
|
||||
| { name: "clearPartySelection" }
|
||||
| { name: "resurrectParty"; checkpointId: string }
|
||||
| { name: "interactDungeonObject" }
|
||||
| { name: "finishDungeon" }
|
||||
| { name: "moveThreatMeter"; position: { x: number; y: number } };
|
||||
|
||||
export type DualScreenMessage =
|
||||
| { type: "companion-ready" }
|
||||
@@ -265,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
||||
draft: shell.draft,
|
||||
notice: shell.notice,
|
||||
game: {
|
||||
hud: currentHudSnapshot(),
|
||||
dpsMeter: {
|
||||
values: currentDpsMeterSnapshot(),
|
||||
layout: uiLayout.elements.dps,
|
||||
@@ -342,6 +356,24 @@ export function executeCompanionCommand(command: CompanionCommand): void {
|
||||
const game = useGameStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
switch (command.name) {
|
||||
case "editHudElement": {
|
||||
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break;
|
||||
const update: Partial<HudElementLayout> = {};
|
||||
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
|
||||
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.max(20, Math.min(2000, command.update.width));
|
||||
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||||
[command.id]: { ...layout.elements[command.id], ...update },
|
||||
} }));
|
||||
useUiLayoutStore.setState({ selectedElement: command.id });
|
||||
break;
|
||||
}
|
||||
case "selectFriendlyActor": combat.selectFriendlyActor(command.id); break;
|
||||
case "selectPlayer": combat.clearTarget(); usePartyStore.getState().selectSelf(); break;
|
||||
case "clearPartySelection": usePartyStore.getState().clearSelection(); break;
|
||||
case "resurrectParty": resurrectDeadPartyMembers(command.checkpointId); break;
|
||||
case "interactDungeonObject": if (!game.overlay) interactWithActiveDungeonObject(); break;
|
||||
case "finishDungeon": if (currentHudSnapshot().completion) { shell.saveActiveDungeonRunCheckpoint(null); shell.returnToMainMenu(); } break;
|
||||
case "moveThreatMeter": combat.updateSettings({ threatMeterPosition: normalizeHudPosition(command.position, { x: .8, y: .3 }) }); break;
|
||||
case "editDpsMeter": {
|
||||
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
|
||||
const update: Partial<HudElementLayout> = {};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { currentHudSnapshot } from "./hudSnapshot";
|
||||
import { executeCompanionCommand } from "./dualScreenSync";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { normalizeUiPreferences } from "../game/uiLayout";
|
||||
import { BOSS_TESTING_CATALOG, createBossTestingRoom } from "../game/bossTesting";
|
||||
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
|
||||
describe("remote HUD presentation", () => {
|
||||
it("carries the isolated boss room to the lower display without simulation methods", () => {
|
||||
const original = useGameStore.getState();
|
||||
const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns")!;
|
||||
const room = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
|
||||
try {
|
||||
registerBossTestingDungeon(room.definition, boss.dungeonId);
|
||||
useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true });
|
||||
const snapshot = structuredClone(currentHudSnapshot());
|
||||
expect(snapshot.bossTestingDungeon?.definition).toEqual(room.definition);
|
||||
expect(snapshot.game.bossTest).toEqual(room.session);
|
||||
clearBossTestingDungeons();
|
||||
registerBossTestingDungeon(snapshot.bossTestingDungeon!.definition, snapshot.bossTestingDungeon!.sourceDungeonId);
|
||||
expect(requireDungeonDefinition(snapshot.game.activeDungeonId).staticSpawns).toHaveLength(1);
|
||||
} finally {
|
||||
useGameStore.setState(original);
|
||||
clearBossTestingDungeons();
|
||||
}
|
||||
});
|
||||
it("contains cloneable presentation data without simulation methods", () => {
|
||||
const snapshot = currentHudSnapshot();
|
||||
expect(() => structuredClone(snapshot)).not.toThrow();
|
||||
expect(snapshot.combat).not.toHaveProperty("tick");
|
||||
expect(snapshot.game).not.toHaveProperty("activateDungeon");
|
||||
expect(snapshot.party).not.toHaveProperty("recallParty");
|
||||
});
|
||||
it("accepts bounded moves only while the authoritative editor is open", () => {
|
||||
const originalUi = useUiLayoutStore.getState();
|
||||
const originalGame = useGameStore.getState();
|
||||
try {
|
||||
const draft = normalizeUiPreferences(null).current.mobile;
|
||||
useUiLayoutStore.setState({ draft });
|
||||
useGameStore.setState({ overlay: null });
|
||||
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: .8, y: .7 } } });
|
||||
expect(useUiLayoutStore.getState().draft).toBe(draft);
|
||||
useGameStore.setState({ overlay: "ui-layout" });
|
||||
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: 5, y: -1 }, width: 99999, screen: "hidden" } });
|
||||
expect(useUiLayoutStore.getState().draft?.elements.party).toMatchObject({ position: { x: 1, y: 0 }, width: 2000, visible: true });
|
||||
expect(useUiLayoutStore.getState().draft?.elements.party.screen).toBeUndefined();
|
||||
} finally {
|
||||
useUiLayoutStore.setState(originalUi);
|
||||
useGameStore.setState(originalGame);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { activeDungeonRunIsComplete } from "../game/dungeonEncounterRuntime";
|
||||
import { activeDungeonInteractionCandidate } from "../game/dungeonWorldInteraction";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
|
||||
function pick<T, K extends keyof T>(state: T, keys: readonly K[]): Pick<T, K> {
|
||||
return Object.fromEntries(keys.map((key) => [key, state[key]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
/** Presentation data only. The lower WebView never starts another simulation. */
|
||||
export function currentHudSnapshot() {
|
||||
const game = useGameStore.getState();
|
||||
const run = useDungeonRunStore.getState().progress;
|
||||
const candidate = game.overlay ? null : activeDungeonInteractionCandidate();
|
||||
return {
|
||||
game: pick(game, ["activeDungeonId", "gameMode", "gmTestMode", "bossTest", "areaName", "distanceTravelled", "playerPosition", "cameraYaw", "hasMoved", "cameraLookActive", "inputMode", "actionLayer", "assetStatus", "assetMessage", "overlay", "paused", "companionOpen"]),
|
||||
bossTestingDungeon: game.bossTest ? { definition: requireDungeonDefinition(game.activeDungeonId), sourceDungeonId: game.bossTest.sourceDungeonId } : null,
|
||||
combat: pick(useCombatStore.getState(), ["classId", "secondaryClassId", "specializationId", "level", "xp", "xpToNext", "health", "maxHealth", "shield", "shieldExpiresAt", "resource", "maxResource", "resourcePools", "maxResourcePools", "resourceName", "abilities", "talentRanks", "talentModifiers", "auras", "effects", "summons", "actors", "actionBindings", "cooldowns", "globalCooldownEndsAt", "activeCast", "pendingGroundTarget", "feedback", "selectedTargetId", "selectedFriendlyActorId", "mobs", "settings"]),
|
||||
party: pick(usePartyStore.getState(), ["members", "selectedMemberId", "selfSelected", "playerRole", "command", "routeBlocked"]),
|
||||
manastorm: pick(useManastormStore.getState(), ["currentEncounter", "resurrectionCharges"]),
|
||||
shell: pick(useShellStore.getState(), ["activeCharacter", "activeDungeonRole"]),
|
||||
onlineSession: useOnlineSessionStore.getState().snapshot,
|
||||
onlineGroup: pick(useOnlineGroupStore.getState(), ["snapshot", "activeActivity"]),
|
||||
ui: pick(useUiLayoutStore.getState(), ["preferences", "draft", "device", "selectedElement"]),
|
||||
run,
|
||||
dps: useDpsMeterStore.getState(),
|
||||
completion: game.gameMode === "dungeon" && (activeDungeonRunIsComplete(game.activeDungeonId) || (run?.dungeonId === game.activeDungeonId && run.finishedAt !== null)),
|
||||
interaction: candidate ? { prompt: candidate.object.interaction!.prompt, name: candidate.object.name, label: candidate.state.label } : null,
|
||||
};
|
||||
}
|
||||
export type HudSnapshot = ReturnType<typeof currentHudSnapshot>;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { THOR_DISPLAYS, THOR_UI_PIXELS_PER_INCH } from "./thorDisplayProfile";
|
||||
|
||||
describe("Thor physical display sizing", () => {
|
||||
it("uses millimetres for relative panel size, with equal physical touch targets", () => {
|
||||
const { top, bottom } = THOR_DISPLAYS;
|
||||
expect(top.widthMm).toBeCloseTo(132.83, 1);
|
||||
expect(bottom.widthMm).toBeCloseTo(75.12, 1);
|
||||
expect(bottom.cssWidth / top.cssWidth).toBeCloseTo(bottom.widthMm / top.widthMm, 2);
|
||||
expect(bottom.deviceScaleFactor).toBeGreaterThan(top.deviceScaleFactor);
|
||||
for (const panel of [top, bottom]) {
|
||||
expect(44 * panel.deviceScaleFactor / panel.pixelsPerInch * 25.4).toBeCloseTo(44 / THOR_UI_PIXELS_PER_INCH * 25.4);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/** AYN Thor panel dimensions, at a common physical UI density (160 CSS px/in). */
|
||||
export const THOR_UI_PIXELS_PER_INCH = 160;
|
||||
export function physicalDisplayProfile(width: number, height: number, diagonalInches: number) {
|
||||
const pixelsPerInch = Math.hypot(width, height) / diagonalInches;
|
||||
return {
|
||||
width, height, diagonalInches, pixelsPerInch,
|
||||
widthMm: width / pixelsPerInch * 25.4,
|
||||
heightMm: height / pixelsPerInch * 25.4,
|
||||
cssWidth: Math.round(width / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
|
||||
cssHeight: Math.round(height / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
|
||||
deviceScaleFactor: pixelsPerInch / THOR_UI_PIXELS_PER_INCH,
|
||||
};
|
||||
}
|
||||
export const THOR_DISPLAYS = {
|
||||
top: physicalDisplayProfile(1920, 1080, 6),
|
||||
bottom: physicalDisplayProfile(1240, 1080, 3.92),
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import type { DungeonDefinition } from "../game/dungeonTypes";
|
||||
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
|
||||
|
||||
export function BossTestingRoom({ definition, onReady }: { readonly definition: DungeonDefinition; readonly onReady?: () => void }) {
|
||||
const { min, max } = definition.bounds;
|
||||
const x = (min[0] + max[0]) / 2;
|
||||
const z = (min[2] + max[2]) / 2;
|
||||
const y = definition.bosses[0].position[1];
|
||||
const width = max[0] - min[0];
|
||||
const depth = max[2] - min[2];
|
||||
useEffect(() => {
|
||||
onReady?.();
|
||||
}, [onReady]);
|
||||
return <group name="boss-testing-room">
|
||||
<RigidBody type="fixed" colliders="cuboid" collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS} position={[x, y - 0.5, z]}>
|
||||
<mesh receiveShadow><boxGeometry args={[width, 1, depth]} /><meshStandardMaterial color="#535c68" roughness={0.9} /></mesh>
|
||||
</RigidBody>
|
||||
{([
|
||||
[min[0] - 0.5, y + 4, z, 1, 8, depth + 2], [max[0] + 0.5, y + 4, z, 1, 8, depth + 2],
|
||||
[x, y + 4, min[2] - 0.5, width, 8, 1], [x, y + 4, max[2] + 0.5, width, 8, 1],
|
||||
] as const).map(([px, py, pz, w, h, d], index) =>
|
||||
<RigidBody key={index} type="fixed" colliders="cuboid" collisionGroups={DUNGEON_WALL_COLLISION_GROUPS} position={[px, py, pz]}>
|
||||
<mesh receiveShadow><boxGeometry args={[w, h, d]} /><meshStandardMaterial color="#293441" roughness={0.95} /></mesh>
|
||||
</RigidBody>
|
||||
)}
|
||||
<gridHelper args={[Math.max(width, depth), Math.ceil(Math.max(width, depth) / 5), "#8794a5", "#626d7b"]} position={[x, y + 0.015, z]} />
|
||||
</group>;
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { ProductionDungeon } from "./ProductionDungeon";
|
||||
import { PrototypeDungeon } from "./PrototypeDungeon";
|
||||
import { BossTestingRoom } from "./BossTestingRoom";
|
||||
|
||||
export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const sessionRevision = useGameStore((state) => state.sessionRevision);
|
||||
const definition = requireDungeonDefinition(activeDungeonId);
|
||||
const manastormPackage = gameMode === "manastorm"
|
||||
@@ -31,6 +33,7 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
|
||||
&& assets.collision.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (bossTest) return;
|
||||
if (hasProductionAssets) return;
|
||||
const missing = [
|
||||
...definition.assets.visual.filter((_, index) => !assets.visual[index]).map((asset) => asset.fileName),
|
||||
@@ -38,8 +41,9 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
|
||||
];
|
||||
useGameStore.getState().setAssetStatus("proxy", `Waiting for ${missing.join(" and ") || "generated dungeon assets"}.`);
|
||||
onReady?.();
|
||||
}, [assets.collision, assets.visual, definition, hasProductionAssets, onReady]);
|
||||
}, [assets.collision, assets.visual, bossTest, definition, hasProductionAssets, onReady]);
|
||||
|
||||
if (bossTest) return <BossTestingRoom definition={definition} onReady={onReady} />;
|
||||
if (!hasProductionAssets) return <PrototypeDungeon definition={definition} />;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
+39
-2
@@ -28,6 +28,30 @@ html, body, #root {
|
||||
body { color: var(--ink); }
|
||||
button { font: inherit; }
|
||||
|
||||
.ability-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 24px);
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gold);
|
||||
border-radius: 8px 3px;
|
||||
background: var(--panel-solid);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ability-tooltip header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.ability-tooltip header strong { color: var(--gold); font: 600 16px Georgia, serif; }
|
||||
.ability-tooltip kbd { color: var(--muted); font-size: 10px; }
|
||||
.ability-tooltip p { margin: 8px 0; white-space: pre-line; overflow-wrap: anywhere; }
|
||||
.ability-tooltip footer { display: flex; flex-wrap: wrap; gap: 4px 12px; padding-top: 8px; border-top: 1px solid var(--edge); color: var(--moss-bright); font-size: 11px; }
|
||||
.ability-tooltip .ability-tooltip__lock { margin-bottom: 0; color: var(--gold); }
|
||||
|
||||
.game-shell, .scene-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -1408,7 +1432,19 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.option-audio-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.ability-bindings-panel { width: min(980px, 100%); max-height: calc(100vh - 28px); }
|
||||
.ability-bindings__layouts { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.ability-bindings__layouts button { flex: 1; min-height: 34px; padding: 6px 10px; border: 1px solid rgba(193,211,160,.18); border-radius: 8px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; }
|
||||
.ability-bindings__layouts button.is-active { border-color: rgba(196,221,144,.4); background: rgba(103,148,78,.25); }
|
||||
.ability-bindings__layers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 10px 0; }
|
||||
.ability-bindings__bars { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.ability-bindings__wow { display: flex; min-width: 0; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(190,210,157,.14); border-radius: 12px; background: rgba(4,12,8,.58); }
|
||||
.ability-bindings__wow-slots { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.ability-bindings__wow-slots .ability-binding-slot { min-height: 76px; }
|
||||
.ability-bindings__wow-slots .ability-binding-slot strong { font-size: 10px; }
|
||||
.ability-bindings__wow-slots .ability-binding-slot small { font-size: 8px; }
|
||||
.ability-bindings__wow-note { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.ability-bindings__wow .ability-bindings__restore { margin-top: auto; }
|
||||
.ability-bindings__wow + .ability-bindings__picker .ability-bindings__choices { max-height: min(280px, 36vh); overflow: auto; padding: 2px; }
|
||||
.ability-bindings__layers button { display: grid; min-width: 0; min-height: 42px; grid-template-columns: 31px minmax(0, 1fr); align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid rgba(193,211,160,.13); border-radius: 8px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; }
|
||||
.ability-bindings__layers button.is-active { border-color: rgba(196,221,144,.4); background: rgba(103,148,78,.25); }
|
||||
.ability-bindings__layers kbd { display: grid; width: 29px; height: 27px; place-items: center; border: 1px solid rgba(229,210,138,.24); border-radius: 7px; background: rgba(0,0,0,.28); color: #ecd98c; }
|
||||
@@ -1604,6 +1640,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.ability-bindings__layers button { grid-template-columns: 1fr; justify-items: center; padding: 4px; text-align: center; }
|
||||
.ability-bindings__layers kbd, .ability-bindings__layers small { display: none; }
|
||||
.ability-bindings__diagram { display: block; min-height: 0; padding: 8px; border-radius: 16px; }
|
||||
.ability-bindings__wow-slots { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.ability-bindings__shoulders { display: grid; grid-template-columns: repeat(2, 1fr); margin-bottom: 7px; }
|
||||
.ability-bindings__dpad { display: grid; max-width: 230px; margin: 0 auto 7px; }
|
||||
.ability-bindings__face { transform: scale(.9); margin-block: -10px; }
|
||||
@@ -2220,7 +2257,7 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
|
||||
.manastorm-spell-option b { overflow: hidden; font: 9px Georgia, serif; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.manastorm-spell-option small { color: var(--front-dim); font-size: 6px; }
|
||||
.manastorm-spell-option.is-active { border-color: rgba(142,231,239,.72); background: rgba(81,126,143,.28); color: #efffff; box-shadow: inset 0 0 12px rgba(100,210,225,.11); }
|
||||
.manastorm-spell-option:disabled { opacity: .42; }
|
||||
.manastorm-spell-option:is(:disabled, [aria-disabled="true"]) { opacity: .42; }
|
||||
.manastorm-role-label { display: block; margin-top: 11px; }
|
||||
.manastorm-role-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 5px; }
|
||||
.manastorm-role-grid .dungeon-role-option { min-height: 47px; padding: 6px; }
|
||||
@@ -3759,7 +3796,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
display: grid; justify-items: center; gap: .1rem; padding: .2rem;
|
||||
border: 1px solid #9dad8277; background: #08130feb; color: #fff;
|
||||
}
|
||||
.arena-personal-bars button:disabled { opacity: .55; }
|
||||
.arena-personal-bars button:is(:disabled, [aria-disabled="true"]) { opacity: .55; }
|
||||
.arena-personal-bars button kbd { position: absolute; top: .1rem; right: .2rem; z-index: 1; color: #fff5aa; font-size: .7rem; text-shadow: 1px 1px #000; }
|
||||
.arena-personal-bars button .action-slot__icon { width: 1.9rem; height: 1.9rem; }
|
||||
.arena-personal-bars button > span:last-of-type { font-size: .58rem; line-height: 1.15; overflow: hidden; }
|
||||
|
||||
+199
-64
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import {
|
||||
ACTION_CONTROLS,
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
type ActionBindingLayer,
|
||||
} from "../game/actionBindings";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
|
||||
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
|
||||
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip";
|
||||
|
||||
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -29,27 +33,38 @@ function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: stri
|
||||
}
|
||||
|
||||
type RestoreScope = "layer" | "all";
|
||||
type BindingLayout = "controller" | "wow";
|
||||
type BindingControl = ActionBindingControl | number;
|
||||
|
||||
interface RestoreRequest {
|
||||
scope: RestoreScope;
|
||||
layout: BindingLayout;
|
||||
layer: ActionBindingLayer;
|
||||
bar: number;
|
||||
cancelFocusId: string;
|
||||
completeFocusId: string;
|
||||
}
|
||||
|
||||
const layerControlId = (layer: ActionBindingLayer) => `binding-layer-${layer}`;
|
||||
const slotControlId = (control: ActionBindingControl) => `binding-slot-${control}`;
|
||||
const barControlId = (bar: number) => `binding-bar-${bar}`;
|
||||
const slotControlId = (control: BindingControl) => typeof control === "number" ? `binding-wow-slot-${control}` : `binding-slot-${control}`;
|
||||
const choiceControlId = (abilityId: string) => `binding-choice-${abilityId}`;
|
||||
|
||||
const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["neighbors"]>>> = {
|
||||
"binding-layout-controller": {
|
||||
up: "binding-restore-layer", down: "binding-layer-primary", left: "binding-layout-wow", right: "binding-layout-wow",
|
||||
},
|
||||
"binding-layout-wow": {
|
||||
up: "binding-restore-all", down: "binding-layer-tertiary", left: "binding-layout-controller", right: "binding-layout-controller",
|
||||
},
|
||||
"binding-layer-primary": {
|
||||
up: "binding-restore-layer", down: "binding-slot-dpad-left", left: "binding-layer-tertiary", right: "binding-layer-secondary",
|
||||
up: "binding-layout-controller", down: "binding-slot-dpad-left", left: "binding-layer-tertiary", right: "binding-layer-secondary",
|
||||
},
|
||||
"binding-layer-secondary": {
|
||||
up: "binding-restore-layer", down: "binding-slot-r1", left: "binding-layer-primary", right: "binding-layer-tertiary",
|
||||
up: "binding-layout-controller", down: "binding-slot-r1", left: "binding-layer-primary", right: "binding-layer-tertiary",
|
||||
},
|
||||
"binding-layer-tertiary": {
|
||||
up: "binding-restore-all", down: "binding-slot-r2", left: "binding-layer-secondary", right: "binding-layer-primary",
|
||||
up: "binding-layout-wow", down: "binding-slot-r2", left: "binding-layer-secondary", right: "binding-layer-primary",
|
||||
},
|
||||
"binding-slot-r1": {
|
||||
up: "binding-layer-secondary", down: "binding-slot-face-top", left: "binding-slot-dpad-right", right: "binding-slot-r2",
|
||||
@@ -83,20 +98,44 @@ const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["nei
|
||||
},
|
||||
};
|
||||
|
||||
function AbilityBindingsDialog() {
|
||||
function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const keyLabel = useInputBindingLabels();
|
||||
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
|
||||
if (!definition) return null;
|
||||
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
|
||||
const ability = abilityAtLevel(definition, level, talentRank);
|
||||
return {
|
||||
name: ability.name, description: ability.description, target: ability.target,
|
||||
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
||||
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
|
||||
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
||||
talentLocked: ability.talentEntryId !== undefined && !talentRank,
|
||||
};
|
||||
};
|
||||
const bindings = useCombatStore((state) => state.actionBindings);
|
||||
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
|
||||
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
|
||||
const [bar, setBar] = useState(0);
|
||||
const [layer, setLayer] = useState<ActionBindingLayer>("primary");
|
||||
const [editingControl, setEditingControl] = useState<ActionBindingControl | null>(null);
|
||||
const [editingControl, setEditingControl] = useState<BindingControl | null>(null);
|
||||
const [restoreRequest, setRestoreRequest] = useState<RestoreRequest | null>(null);
|
||||
const [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
|
||||
const classColor = classById(classId).color;
|
||||
const editingAbilityId = editingControl ? bindings[actionBindingId(layer, editingControl)] : null;
|
||||
const isWow = layout === "wow";
|
||||
const assignment = (control: BindingControl) => typeof control === "number"
|
||||
? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)];
|
||||
const controlLabel = (control: BindingControl) => typeof control === "number"
|
||||
? `Bar ${bar + 1} · Button ${control + 1}${pcLayout.bars[bar].slots[control].key ? ` · ${actionKeyLabel(pcLayout.bars[bar].slots[control].key)}` : ""}`
|
||||
: bindingChord(layer, control);
|
||||
const editingAbilityId = editingControl !== null ? assignment(editingControl) : null;
|
||||
const pickerAbilities = useMemo(() => {
|
||||
const ordered = abilities
|
||||
.filter((ability) => !ability.passive)
|
||||
@@ -108,7 +147,32 @@ function AbilityBindingsDialog() {
|
||||
]
|
||||
: ordered;
|
||||
}, [abilities, editingAbilityId]);
|
||||
const close = () => openOverlay("pause");
|
||||
const close = onClose ?? (() => openOverlay("pause"));
|
||||
const saveWowLayout = (update: (current: UiLayout) => UiLayout) => {
|
||||
try {
|
||||
const ui = useUiLayoutStore.getState();
|
||||
ui.persist({ ...ui.preferences, current: { ...ui.preferences.current, pc: update(ui.preferences.current.pc) } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "Could not save WoW ability bindings.");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const setBinding = (control: BindingControl, abilityId: string | null) => {
|
||||
if (typeof control === "number") return saveWowLayout((current) => assignDesktopAbility(current, bar, control, abilityId));
|
||||
useCombatStore.getState().setActionBinding(layer, control, abilityId);
|
||||
return true;
|
||||
};
|
||||
const chooseLayout = (next: BindingLayout) => {
|
||||
setLayout(next); setEditingControl(null); setRestoreRequest(null);
|
||||
setNotice(`Choose a ${next === "wow" ? "WoW bar" : "controller"} button, then choose its ability.`);
|
||||
controller.select(`binding-layout-${next}`);
|
||||
};
|
||||
const chooseBar = (next: number) => {
|
||||
setBar(next); setEditingControl(null); setRestoreRequest(null);
|
||||
setNotice(`Bar ${next + 1} selected.`);
|
||||
controller.select(barControlId(next));
|
||||
};
|
||||
|
||||
const chooseLayer = (next: ActionBindingLayer) => {
|
||||
setLayer(next);
|
||||
@@ -117,40 +181,42 @@ function AbilityBindingsDialog() {
|
||||
setNotice(`${ACTION_LAYER_LABELS[next]} layer selected.`);
|
||||
controller.select(layerControlId(next));
|
||||
};
|
||||
const beginEditing = (control: ActionBindingControl) => {
|
||||
const abilityId = bindings[actionBindingId(layer, control)];
|
||||
const beginEditing = (control: BindingControl) => {
|
||||
const abilityId = assignment(control);
|
||||
setEditingControl(control);
|
||||
setRestoreRequest(null);
|
||||
controller.select(abilityId ? choiceControlId(abilityId) : "binding-choice-empty");
|
||||
};
|
||||
const cancelEditing = () => {
|
||||
if (!editingControl) return;
|
||||
if (editingControl === null) return;
|
||||
const control = editingControl;
|
||||
setEditingControl(null);
|
||||
controller.select(slotControlId(control));
|
||||
};
|
||||
const assign = (abilityId: string | null) => {
|
||||
if (!editingControl) return;
|
||||
if (editingControl === null) return;
|
||||
const control = editingControl;
|
||||
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
|
||||
useCombatStore.getState().setActionBinding(layer, control, abilityId);
|
||||
setNotice(`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}.`);
|
||||
if (!setBinding(control, abilityId)) return;
|
||||
setNotice(`${controlLabel(control)}: ${ability?.name ?? "Empty"}. Saved.`);
|
||||
setEditingControl(null);
|
||||
controller.select(slotControlId(control));
|
||||
};
|
||||
const clearSelected = (control: ActionBindingControl | null = editingControl) => {
|
||||
if (!control) return;
|
||||
useCombatStore.getState().setActionBinding(layer, control, null);
|
||||
setNotice(`${bindingChord(layer, control)} cleared.`);
|
||||
const clearSelected = (control: BindingControl | null = editingControl) => {
|
||||
if (control === null) return;
|
||||
if (!setBinding(control, null)) return;
|
||||
setNotice(`${controlLabel(control)} cleared. Saved.`);
|
||||
setEditingControl(null);
|
||||
controller.select(slotControlId(control));
|
||||
};
|
||||
const requestRestore = (scope: RestoreScope, focusId: string) => {
|
||||
setRestoreRequest({
|
||||
scope,
|
||||
layout,
|
||||
layer,
|
||||
bar,
|
||||
cancelFocusId: focusId,
|
||||
completeFocusId: editingControl ? slotControlId(editingControl) : focusId,
|
||||
completeFocusId: editingControl !== null ? slotControlId(editingControl) : focusId,
|
||||
});
|
||||
controller.select("binding-confirm-cancel");
|
||||
};
|
||||
@@ -163,7 +229,11 @@ function AbilityBindingsDialog() {
|
||||
const confirmRestore = () => {
|
||||
if (!restoreRequest) return;
|
||||
const request = restoreRequest;
|
||||
if (request.scope === "layer") {
|
||||
if (request.layout === "wow") {
|
||||
const defaults = defaultActionBarForCharacter(classId, secondaryClassId, level);
|
||||
if (!saveWowLayout((current) => resetDesktopAbilities(current, defaults, request.scope === "layer" ? request.bar : undefined))) return;
|
||||
setNotice(request.scope === "layer" ? `Bar ${request.bar + 1} abilities restored. Saved.` : "All WoW bar abilities restored. Saved.");
|
||||
} else if (request.scope === "layer") {
|
||||
useCombatStore.getState().resetActionBindingLayer(request.layer);
|
||||
setNotice(`${ACTION_LAYER_LABELS[request.layer]} layer restored.`);
|
||||
} else {
|
||||
@@ -190,53 +260,72 @@ function AbilityBindingsDialog() {
|
||||
},
|
||||
];
|
||||
}
|
||||
if (editingControl) {
|
||||
if (editingControl !== null) {
|
||||
return [
|
||||
...pickerAbilities.map((ability) => ({ id: choiceControlId(ability.id), run: () => assign(ability.id) })),
|
||||
{ id: "binding-choice-empty", run: () => assign(null) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...ACTION_LAYERS.map((nextLayer) => {
|
||||
const id = layerControlId(nextLayer);
|
||||
return { id, run: () => chooseLayer(nextLayer), neighbors: MAIN_ACTION_NEIGHBORS[id] };
|
||||
}),
|
||||
...ACTION_CONTROLS.map((control) => {
|
||||
const id = slotControlId(control);
|
||||
return { id, run: () => beginEditing(control), neighbors: MAIN_ACTION_NEIGHBORS[id] };
|
||||
}),
|
||||
...(["controller", "wow"] as const).map((next) => ({
|
||||
id: `binding-layout-${next}`, run: () => chooseLayout(next),
|
||||
neighbors: isWow ? { up: "binding-restore-all", down: barControlId(bar), left: `binding-layout-${next === "wow" ? "controller" : "wow"}`, right: `binding-layout-${next === "wow" ? "controller" : "wow"}` } : MAIN_ACTION_NEIGHBORS[`binding-layout-${next}`],
|
||||
})),
|
||||
...(isWow ? [
|
||||
...pcLayout.bars.map((_, index) => ({ id: barControlId(index), run: () => chooseBar(index), neighbors: {
|
||||
up: "binding-layout-wow", down: slotControlId(index), left: barControlId((index + 4) % 5), right: barControlId((index + 1) % 5),
|
||||
} })),
|
||||
...pcLayout.bars[bar].slots.map((_, slot) => ({ id: slotControlId(slot), run: () => beginEditing(slot), neighbors: {
|
||||
up: slot < 4 ? barControlId(bar) : slotControlId(slot - 4),
|
||||
down: slot >= 8 ? "binding-restore-layer" : slotControlId(slot + 4),
|
||||
left: slotControlId((slot + 11) % 12), right: slotControlId((slot + 1) % 12),
|
||||
} })),
|
||||
] : [
|
||||
...ACTION_LAYERS.map((nextLayer) => {
|
||||
const id = layerControlId(nextLayer);
|
||||
return { id, run: () => chooseLayer(nextLayer), neighbors: MAIN_ACTION_NEIGHBORS[id] };
|
||||
}),
|
||||
...ACTION_CONTROLS.map((control) => {
|
||||
const id = slotControlId(control);
|
||||
return { id, run: () => beginEditing(control), neighbors: MAIN_ACTION_NEIGHBORS[id] };
|
||||
}),
|
||||
]),
|
||||
{
|
||||
id: "binding-restore-layer",
|
||||
run: () => requestRestore("layer", "binding-restore-layer"),
|
||||
neighbors: MAIN_ACTION_NEIGHBORS["binding-restore-layer"],
|
||||
neighbors: isWow ? { up: slotControlId(8), down: "binding-layout-controller", left: "binding-restore-all", right: "binding-restore-all" } : MAIN_ACTION_NEIGHBORS["binding-restore-layer"],
|
||||
},
|
||||
{
|
||||
id: "binding-restore-all",
|
||||
run: () => requestRestore("all", "binding-restore-all"),
|
||||
neighbors: MAIN_ACTION_NEIGHBORS["binding-restore-all"],
|
||||
neighbors: isWow ? { up: slotControlId(11), down: "binding-layout-wow", left: "binding-restore-layer", right: "binding-restore-layer" } : MAIN_ACTION_NEIGHBORS["binding-restore-all"],
|
||||
},
|
||||
];
|
||||
}, [abilities, bindings, editingControl, layer, pickerAbilities, restoreRequest]);
|
||||
}, [abilities, bindings, classId, secondaryClassId, level, editingControl, layer, layout, bar, pcLayout, pickerAbilities, restoreRequest]);
|
||||
const controller = useMenuController(actions, {
|
||||
columns: restoreRequest ? 2 : editingControl ? 1 : 3,
|
||||
columns: restoreRequest ? 2 : editingControl !== null ? 1 : isWow ? 4 : 3,
|
||||
initialId: restoreRequest
|
||||
? "binding-confirm-cancel"
|
||||
: editingControl
|
||||
: editingControl !== null
|
||||
? editingAbilityId ? choiceControlId(editingAbilityId) : "binding-choice-empty"
|
||||
: layerControlId(layer),
|
||||
onBack: restoreRequest ? cancelRestore : editingControl ? cancelEditing : close,
|
||||
: isWow ? barControlId(bar) : layerControlId(layer),
|
||||
onBack: restoreRequest ? cancelRestore : editingControl !== null ? cancelEditing : close,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
panel.current?.querySelector(".is-controller-selected")?.scrollIntoView({ block: "nearest" });
|
||||
}, [controller.selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
const run = (id: string | undefined) => {
|
||||
if (restoreRequest) return;
|
||||
if (id === "editor-layer-2") chooseLayer("secondary");
|
||||
else if (id === "editor-layer-3") chooseLayer("tertiary");
|
||||
if (id === "editor-layer-2") { if (isWow) chooseBar(1); else chooseLayer("secondary"); }
|
||||
else if (id === "editor-layer-3") { if (isWow) chooseBar(2); else chooseLayer("tertiary"); }
|
||||
else if (id === "editor-clear") {
|
||||
if (editingControl) clearSelected(editingControl);
|
||||
if (editingControl !== null) clearSelected(editingControl);
|
||||
else {
|
||||
const prefix = "binding-slot-";
|
||||
if (controller.selectedId.startsWith(prefix)) clearSelected(controller.selectedId.slice(prefix.length) as ActionBindingControl);
|
||||
const prefix = isWow ? "binding-wow-slot-" : "binding-slot-";
|
||||
if (controller.selectedId.startsWith(prefix)) clearSelected(isWow ? Number(controller.selectedId.slice(prefix.length)) : controller.selectedId.slice(prefix.length) as ActionBindingControl);
|
||||
}
|
||||
} else if (id === "editor-reset") requestRestore("layer", controller.selectedId);
|
||||
};
|
||||
@@ -252,7 +341,7 @@ function AbilityBindingsDialog() {
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => { unsubscribe(); window.removeEventListener("keydown", onKey); };
|
||||
}, [controller.selectedId, editingControl, layer, restoreRequest]);
|
||||
}, [controller.selectedId, editingControl, layer, layout, bar, pcLayout, restoreRequest]);
|
||||
|
||||
const bindingSlot = (control: ActionBindingControl) => {
|
||||
const definition = ACTION_CONTROL_DEFINITIONS[control];
|
||||
@@ -260,6 +349,7 @@ function AbilityBindingsDialog() {
|
||||
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
|
||||
const locked = Boolean(ability && ability.unlockLevel > level);
|
||||
return (
|
||||
<AbilityTooltipTarget key={control} ability={tooltipData(ability)} bindingLabel={bindingChord(layer, control)}>
|
||||
<ControllerButton
|
||||
key={control}
|
||||
controlId={slotControlId(control)}
|
||||
@@ -277,21 +367,38 @@ function AbilityBindingsDialog() {
|
||||
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
|
||||
<span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop game-panel-backdrop" role="presentation">
|
||||
<section className="game-panel ability-bindings-panel" role="dialog" aria-modal="true" aria-labelledby="ability-bindings-title">
|
||||
<section ref={panel} className="game-panel ability-bindings-panel" role="dialog" aria-modal="true" aria-labelledby="ability-bindings-title">
|
||||
<header className="game-panel__header">
|
||||
<div>
|
||||
<p className="eyebrow">Controller loadout · 24 customizable slots</p>
|
||||
<p className="eyebrow">{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}</p>
|
||||
<h2 id="ability-bindings-title">Ability controls</h2>
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button>
|
||||
<button type="button" className="icon-button" onClick={close} aria-label="Close button assignments">×</button>
|
||||
</header>
|
||||
|
||||
<nav className="ability-bindings__layers" aria-label="Ability binding layers">
|
||||
<nav className="ability-bindings__layouts" aria-label="Action bar style">
|
||||
{(["controller", "wow"] as const).map((next) => (
|
||||
<ControllerButton key={next} controlId={`binding-layout-${next}`} selectedId={controller.selectedId} select={controller.select}
|
||||
type="button" aria-pressed={layout === next} className={layout === next ? "is-active" : ""} onClick={() => chooseLayout(next)}>
|
||||
{next === "wow" ? "WoW bars" : "Controller bars"}
|
||||
</ControllerButton>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{isWow ? <nav className="ability-bindings__layers ability-bindings__bars" aria-label="WoW action bars">
|
||||
{pcLayout.bars.map((_, index) => (
|
||||
<ControllerButton key={index} controlId={barControlId(index)} selectedId={controller.selectedId} select={controller.select}
|
||||
type="button" aria-pressed={bar === index} className={bar === index ? "is-active" : ""} onClick={() => chooseBar(index)}>
|
||||
<kbd>{index + 1}</kbd><span><strong>Bar {index + 1}</strong><small>{pcLayout.wowBars && index < pcLayout.barCount ? "Enabled" : "Inactive"}</small></span>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</nav> : <nav className="ability-bindings__layers" aria-label="Ability binding layers">
|
||||
{ACTION_LAYERS.map((nextLayer) => (
|
||||
<ControllerButton
|
||||
key={nextLayer}
|
||||
@@ -307,10 +414,32 @@ function AbilityBindingsDialog() {
|
||||
<span><strong>{ACTION_LAYER_LABELS[nextLayer]}</strong><small>{nextLayer === "primary" ? "No modifier" : `Hold ${ACTION_LAYER_LABELS[nextLayer]}`}</small></span>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</nav>
|
||||
</nav>}
|
||||
|
||||
<div className="ability-bindings__layout">
|
||||
<section className="ability-bindings__diagram" aria-label={`${ACTION_LAYER_LABELS[layer]} controller layout`}>
|
||||
{isWow ? <section className="ability-bindings__wow" aria-label={`Bar ${bar + 1} ability assignments`}>
|
||||
<div className="ability-bindings__wow-slots">
|
||||
{pcLayout.bars[bar].slots.map((slot, index) => {
|
||||
const ability = abilities.find((candidate) => candidate.id === slot.abilityId);
|
||||
const locked = Boolean(ability && ability.unlockLevel > level);
|
||||
const name = ability?.name ?? (slot.abilityId ? "Unavailable for this character" : "Empty");
|
||||
return <AbilityTooltipTarget key={index} ability={tooltipData(ability)} bindingLabel={controlLabel(index)}>
|
||||
<ControllerButton controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
|
||||
type="button" className={`ability-binding-slot ${locked ? "is-locked" : ""} ${editingControl === index ? "is-editing" : ""}`}
|
||||
aria-label={`${controlLabel(index)}: ${name}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
|
||||
aria-pressed={editingControl === index} title={`${controlLabel(index)}: ${name}`} onClick={() => beginEditing(index)}>
|
||||
<kbd>{actionKeyLabel(slot.key) || "—"}</kbd>
|
||||
{ability ? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} /> : <span className="ability-bindings__empty" aria-hidden="true">+</span>}
|
||||
<span><strong>Button {index + 1}</strong><small>{locked ? `${name} · Level ${ability!.unlockLevel}` : name}</small></span>
|
||||
</ControllerButton></AbilityTooltipTarget>;
|
||||
})}
|
||||
</div>
|
||||
<p className="ability-bindings__wow-note">{!pcLayout.wowBars ? "Enable WoW action bars in UI layout to use these assignments on PC." : bar >= pcLayout.barCount ? "This bar is inactive. Increase the visible bar count in UI layout to use it." : "Choose a button to change its ability. Changes save immediately."}</p>
|
||||
<div className="ability-bindings__restore">
|
||||
<ControllerButton controlId="binding-restore-layer" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("layer", "binding-restore-layer")}>Restore Bar {bar + 1}</ControllerButton>
|
||||
<ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all WoW bars</ControllerButton>
|
||||
</div>
|
||||
</section> : <section className="ability-bindings__diagram" aria-label={`${ACTION_LAYER_LABELS[layer]} controller layout`}>
|
||||
<div className="ability-bindings__shoulders">
|
||||
{bindingSlot("r1")}
|
||||
{bindingSlot("r2")}
|
||||
@@ -330,16 +459,20 @@ function AbilityBindingsDialog() {
|
||||
<ControllerButton controlId="binding-restore-layer" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("layer", "binding-restore-layer")}>Restore {ACTION_LAYER_LABELS[layer]}</ControllerButton>
|
||||
<ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all</ControllerButton>
|
||||
</div>
|
||||
</section>
|
||||
</section>}
|
||||
|
||||
<aside className="ability-bindings__picker" aria-live="polite">
|
||||
{restoreRequest ? (
|
||||
<>
|
||||
<p className="eyebrow">Confirm controller reset</p>
|
||||
<h3>{restoreRequest.scope === "all"
|
||||
<p className="eyebrow">Confirm {restoreRequest.layout === "wow" ? "WoW bar" : "controller"} reset</p>
|
||||
<h3>{restoreRequest.layout === "wow"
|
||||
? restoreRequest.scope === "all" ? "Restore all WoW bar abilities?" : `Restore Bar ${restoreRequest.bar + 1} abilities?`
|
||||
: restoreRequest.scope === "all"
|
||||
? "Restore all ability controls?"
|
||||
: `Restore the ${ACTION_LAYER_LABELS[restoreRequest.layer]} layer?`}</h3>
|
||||
<p className="ability-bindings__confirm-warning">{restoreRequest.scope === "all"
|
||||
<p className="ability-bindings__confirm-warning">{restoreRequest.layout === "wow"
|
||||
? `This replaces ${restoreRequest.scope === "all" ? "all 60 WoW slots" : "this bar's 12 slots"} with the class defaults. Keyboard shortcuts and bar layout are kept.`
|
||||
: restoreRequest.scope === "all"
|
||||
? "This replaces every customized controller slot with the class defaults."
|
||||
: `This replaces all eight ${ACTION_LAYER_LABELS[restoreRequest.layer]} slots with their class defaults.`}</p>
|
||||
<div className="ability-bindings__choices ability-bindings__choices--confirm">
|
||||
@@ -353,16 +486,18 @@ function AbilityBindingsDialog() {
|
||||
</ControllerButton>
|
||||
</div>
|
||||
</>
|
||||
) : editingControl ? (
|
||||
) : editingControl !== null ? (
|
||||
<>
|
||||
<p className="eyebrow">Assign {bindingChord(layer, editingControl)}</p>
|
||||
<p className="eyebrow">Assign {controlLabel(editingControl)}</p>
|
||||
<h3>Choose an ability</h3>
|
||||
<div className="ability-bindings__choices">
|
||||
{pickerAbilities.map((ability) => (
|
||||
<ControllerButton key={ability.id} controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
|
||||
<AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
|
||||
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
|
||||
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
|
||||
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
))}
|
||||
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}>
|
||||
<span className="ability-bindings__empty" aria-hidden="true">×</span>
|
||||
@@ -372,26 +507,26 @@ function AbilityBindingsDialog() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="eyebrow">{ACTION_LAYER_LABELS[layer]} layer</p>
|
||||
<h3>Select a controller button</h3>
|
||||
<p>Each layer has the four face buttons, R1, R2, and both horizontal D-pad directions. Abilities may be reused on more than one button.</p>
|
||||
<p className="ability-bindings__notice" role="status">{notice}</p>
|
||||
<p className="eyebrow">{isWow ? `Bar ${bar + 1}` : `${ACTION_LAYER_LABELS[layer]} layer`}</p>
|
||||
<h3>Select a {isWow ? "WoW bar" : "controller"} button</h3>
|
||||
<p>{isWow ? "Each WoW bar has 12 buttons. Assign abilities on any of the five bars, including inactive bars. Change keyboard shortcuts in Options → Keybinds." : "Each layer has the four face buttons, R1, R2, and both horizontal D-pad directions. Abilities may be reused on more than one button."}</p>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<p className="ability-bindings__notice" role="status">{notice}</p>
|
||||
<footer className="game-panel__footer">
|
||||
{restoreRequest
|
||||
? <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Choose</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Cancel</span></>
|
||||
: <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Assign</span><span><kbd>{keyLabel("controller", "editor-clear")}</kbd> Clear</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Back</span><span><kbd>{keyLabel("controller", "editor-reset")}</kbd> Restore layer</span></>}
|
||||
: <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Assign</span><span><kbd>{keyLabel("controller", "editor-clear")}</kbd> Clear</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Back</span><span><kbd>{keyLabel("controller", "editor-reset")}</kbd> Restore {isWow ? "bar" : "layer"}</span></>}
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AbilityBindingsPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "bindings");
|
||||
return open ? <AbilityBindingsDialog /> : null;
|
||||
export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
|
||||
const storeOpen = useGameStore((state) => state.overlay === "bindings");
|
||||
return (open ?? storeOpen) ? <AbilityBindingsDialog onClose={onClose} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AbilityTooltipContent, AbilityTooltipTarget, abilityTooltipFacts, abilityTooltipPosition,
|
||||
type AbilityTooltipData,
|
||||
} from "./AbilityTooltip";
|
||||
|
||||
const ability: AbilityTooltipData = {
|
||||
name: "Healing Wave", description: "Heals a friendly target for 120 health.",
|
||||
resourceCost: 15, resourceLabel: "mana", cooldownSeconds: 6,
|
||||
castSeconds: 1.5, range: 40, target: "friendly",
|
||||
};
|
||||
|
||||
describe("ability tooltips", () => {
|
||||
it("shows the ability's description, binding, and gameplay facts", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, { ability, bindingLabel: "Shift+1" }));
|
||||
for (const text of [ability.name, ability.description!, "Shift+1", "15 mana", "1.5 sec cast", "6 sec cooldown", "40 m range", "Selected ally or self"]) {
|
||||
expect(markup).toContain(text);
|
||||
}
|
||||
});
|
||||
|
||||
it("explains free, instant abilities and distinguishes no cooldown from missing data", () => {
|
||||
expect(abilityTooltipFacts({ name: "Free", resourceCost: 0, castSeconds: 0, cooldownSeconds: 0 }))
|
||||
.toEqual(["No cost", "Instant", "No cooldown"]);
|
||||
expect(abilityTooltipFacts({ name: "Unknown" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows unlock and talent requirements without hiding the description", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, {
|
||||
ability: { ...ability, lockedUntilLevel: 20, talentLocked: true },
|
||||
}));
|
||||
expect(markup).toContain(ability.description);
|
||||
expect(markup).toContain("Unlocks at level 20");
|
||||
expect(markup).toContain("Learn in talents");
|
||||
});
|
||||
|
||||
it("does not present active cast facts for passive abilities", () => {
|
||||
expect(abilityTooltipFacts({ ...ability, passive: true, rank: 2 })).toEqual(["Passive", "Rank 2"]);
|
||||
});
|
||||
|
||||
it("preserves inspectable unavailable controls and creates no layout wrapper", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
|
||||
ability,
|
||||
children: createElement("button", { "aria-disabled": true, title: "Old browser tooltip" }, "Healing Wave"),
|
||||
}));
|
||||
expect(markup).toBe('<button aria-disabled="true">Healing Wave</button>');
|
||||
});
|
||||
|
||||
it("keeps the empty-slot browser hint without creating an ability tooltip", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
|
||||
ability: null,
|
||||
children: createElement("button", { title: "Empty binding" }, "Empty"),
|
||||
}));
|
||||
expect(markup).toBe('<button title="Empty binding">Empty</button>');
|
||||
});
|
||||
|
||||
it("places a bottom-bar tooltip above its control", () => {
|
||||
expect(abilityTooltipPosition({ left: 400, right: 440, top: 700, bottom: 740 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
|
||||
.toEqual({ left: 270, top: 512 });
|
||||
});
|
||||
|
||||
it("flips below top-edge controls and clamps to either horizontal edge", () => {
|
||||
expect(abilityTooltipPosition({ left: 0, right: 40, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
|
||||
.toEqual({ left: 12, top: 68 });
|
||||
expect(abilityTooltipPosition({ left: 960, right: 1000, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
|
||||
.toEqual({ left: 688, top: 68 });
|
||||
});
|
||||
|
||||
it("keeps a tall tooltip inside a narrow viewport", () => {
|
||||
expect(abilityTooltipPosition({ left: 200, right: 240, top: 120, bottom: 160 }, { width: 296, height: 400 }, { width: 320, height: 480 }))
|
||||
.toEqual({ left: 12, top: 68 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
cloneElement, useEffect, useId, useLayoutEffect, useRef, useState,
|
||||
type ButtonHTMLAttributes, type ReactElement,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { AbilityTarget } from "../game/abilityCatalog";
|
||||
|
||||
const INSPECT_EVENT = "healer-man:ability-tooltip-inspect";
|
||||
|
||||
export interface AbilityTooltipData {
|
||||
name: string;
|
||||
description?: string;
|
||||
resourceCost?: number;
|
||||
resourceLabel?: string;
|
||||
cooldownSeconds?: number;
|
||||
castSeconds?: number;
|
||||
range?: number;
|
||||
target?: AbilityTarget;
|
||||
passive?: boolean;
|
||||
rank?: number;
|
||||
lockedUntilLevel?: number;
|
||||
talentLocked?: boolean;
|
||||
}
|
||||
|
||||
export function abilityTooltipFacts(ability: AbilityTooltipData): string[] {
|
||||
const facts: string[] = [];
|
||||
if (ability.passive) facts.push("Passive");
|
||||
if (ability.rank !== undefined) facts.push(`Rank ${ability.rank}`);
|
||||
if (!ability.passive) {
|
||||
if (ability.resourceCost !== undefined) facts.push(ability.resourceCost > 0
|
||||
? `${Number(ability.resourceCost.toFixed(2))} ${ability.resourceLabel ?? "resource"}` : "No cost");
|
||||
if (ability.castSeconds !== undefined) facts.push(ability.castSeconds > 0 ? `${ability.castSeconds} sec cast` : "Instant");
|
||||
if (ability.cooldownSeconds !== undefined) facts.push(ability.cooldownSeconds > 0 ? `${ability.cooldownSeconds} sec cooldown` : "No cooldown");
|
||||
if (ability.range !== undefined && ability.range > 0) facts.push(`${ability.range} m range`);
|
||||
if (ability.target) facts.push({ friendly: "Selected ally or self", hostile: "Enemy target", any: "Enemy or ally", self: "Self" }[ability.target]);
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function abilityTooltipPosition(
|
||||
anchor: { left: number; right: number; top: number; bottom: number },
|
||||
size: { width: number; height: number },
|
||||
viewport: { width: number; height: number },
|
||||
): { left: number; top: number } {
|
||||
const margin = 12;
|
||||
const gap = 8;
|
||||
const clamp = (value: number, maximum: number) => Math.max(margin, Math.min(value, maximum - margin));
|
||||
const above = anchor.top - size.height - gap;
|
||||
return {
|
||||
left: clamp((anchor.left + anchor.right - size.width) / 2, viewport.width - size.width),
|
||||
top: clamp(above >= margin ? above : anchor.bottom + gap, viewport.height - size.height),
|
||||
};
|
||||
}
|
||||
|
||||
export function AbilityTooltipContent({ ability, bindingLabel }: { ability: AbilityTooltipData; bindingLabel?: string }) {
|
||||
return <>
|
||||
<header><strong>{ability.name}</strong>{bindingLabel && <kbd>{bindingLabel}</kbd>}</header>
|
||||
<p>{ability.description || "No description available for this ability."}</p>
|
||||
<footer>{abilityTooltipFacts(ability).map((fact) => <span key={fact}>{fact}</span>)}</footer>
|
||||
{ability.lockedUntilLevel !== undefined && <p className="ability-tooltip__lock">Unlocks at level {ability.lockedUntilLevel}</p>}
|
||||
{ability.talentLocked && <p className="ability-tooltip__lock">Learn in talents</p>}
|
||||
</>;
|
||||
}
|
||||
|
||||
/** Adds inspection without a layout wrapper or changing the control's activation behavior. */
|
||||
export function AbilityTooltipTarget({ ability, bindingLabel, children }: {
|
||||
ability: AbilityTooltipData | null | undefined;
|
||||
bindingLabel?: string;
|
||||
children: ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
const [position, setPosition] = useState({ left: 12, top: 12 });
|
||||
const tooltip = useRef<HTMLDivElement>(null);
|
||||
const hovering = useRef(false);
|
||||
const keyboardFocused = useRef(false);
|
||||
const visible = Boolean(anchor && ability);
|
||||
const inspect = (element: HTMLButtonElement) => {
|
||||
window.dispatchEvent(new CustomEvent(INSPECT_EVENT, { detail: id }));
|
||||
setAnchor(element);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !tooltip.current || !ability) return;
|
||||
setPosition(abilityTooltipPosition(anchor.getBoundingClientRect(), tooltip.current.getBoundingClientRect(), {
|
||||
width: window.innerWidth, height: window.innerHeight,
|
||||
}));
|
||||
}, [anchor, ability, bindingLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const dismiss = () => {
|
||||
hovering.current = false;
|
||||
keyboardFocused.current = false;
|
||||
setAnchor(null);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); };
|
||||
const onInspect = (event: Event) => { if ((event as CustomEvent<string>).detail !== id) dismiss(); };
|
||||
window.addEventListener(INSPECT_EVENT, onInspect);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", dismiss);
|
||||
window.addEventListener("resize", dismiss);
|
||||
window.addEventListener("scroll", dismiss, true);
|
||||
return () => {
|
||||
window.removeEventListener(INSPECT_EVENT, onInspect);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", dismiss);
|
||||
window.removeEventListener("resize", dismiss);
|
||||
window.removeEventListener("scroll", dismiss, true);
|
||||
};
|
||||
}, [visible, id]);
|
||||
|
||||
const original = children.props;
|
||||
return <>
|
||||
{cloneElement(children, {
|
||||
title: ability ? undefined : original.title,
|
||||
"aria-describedby": [original["aria-describedby"], visible ? id : undefined].filter(Boolean).join(" ") || undefined,
|
||||
onPointerEnter: (event) => {
|
||||
original.onPointerEnter?.(event);
|
||||
if (event.pointerType === "touch" || !ability) return;
|
||||
hovering.current = true;
|
||||
inspect(event.currentTarget);
|
||||
},
|
||||
onPointerLeave: (event) => {
|
||||
original.onPointerLeave?.(event);
|
||||
hovering.current = false;
|
||||
if (!keyboardFocused.current) setAnchor(null);
|
||||
},
|
||||
onFocus: (event) => {
|
||||
original.onFocus?.(event);
|
||||
keyboardFocused.current = event.currentTarget.matches(":focus-visible");
|
||||
if (keyboardFocused.current && ability) inspect(event.currentTarget);
|
||||
},
|
||||
onBlur: (event) => {
|
||||
original.onBlur?.(event);
|
||||
keyboardFocused.current = false;
|
||||
if (!hovering.current) setAnchor(null);
|
||||
},
|
||||
})}
|
||||
{visible && createPortal(
|
||||
<div ref={tooltip} id={id} role="tooltip" className="ability-tooltip" style={position}>
|
||||
<AbilityTooltipContent ability={ability!} bindingLabel={bindingLabel} />
|
||||
</div>, document.body,
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import type { AbilityTarget } from "../game/abilityCatalog";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import {
|
||||
ACTION_LAYER_LABELS,
|
||||
@@ -21,6 +22,10 @@ export interface ActionBarItem {
|
||||
disabled?: boolean;
|
||||
lockedUntilLevel?: number;
|
||||
target?: AbilityTarget;
|
||||
cooldownSeconds?: number;
|
||||
castSeconds?: number;
|
||||
range?: number;
|
||||
talentLocked?: boolean;
|
||||
}
|
||||
|
||||
export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>;
|
||||
@@ -94,6 +99,7 @@ function ActionSlot({
|
||||
: "";
|
||||
|
||||
return (
|
||||
<AbilityTooltipTarget ability={item} bindingLabel={chord}>
|
||||
<button
|
||||
type="button"
|
||||
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
|
||||
@@ -124,6 +130,7 @@ function ActionSlot({
|
||||
<em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em>
|
||||
)}
|
||||
</button>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useArenaStore } from "../game/arenaStore";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
|
||||
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
|
||||
|
||||
@@ -38,6 +39,7 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
|
||||
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
|
||||
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
|
||||
cooldownRemaining: remaining,
|
||||
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max,
|
||||
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
|
||||
lockedUntilLevel: learned ? undefined : ability.unlockLevel,
|
||||
}];
|
||||
@@ -65,12 +67,13 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
|
||||
{desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}>
|
||||
{bar.slots.map((slot, slotIndex) => {
|
||||
const item = slot.abilityId ? items[slot.abilityId] : null;
|
||||
return <button type="button" key={slotIndex} disabled={!item || item.disabled}
|
||||
return <AbilityTooltipTarget key={slotIndex} ability={item} bindingLabel={slot.label}>
|
||||
<button type="button" aria-disabled={!item || item.disabled}
|
||||
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
|
||||
onClick={() => item && onCast(item.id)}>
|
||||
onClick={() => item && !item.disabled && onCast(item.id)}>
|
||||
<kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
|
||||
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
|
||||
</button>;
|
||||
</button></AbilityTooltipTarget>;
|
||||
})}
|
||||
</section>)}
|
||||
</nav>
|
||||
|
||||
@@ -8,12 +8,13 @@ describe("boss card display", () => {
|
||||
it("shows per-rarity copy totals and every dungeon for the visible boss", () => {
|
||||
const boss = BOSS_CARD_CATALOG[0];
|
||||
const markup = renderToStaticMarkup(createElement(BossCardCollection, {
|
||||
onClose: () => {}, characterName: "Collector",
|
||||
onClose: () => {},
|
||||
collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} },
|
||||
}));
|
||||
expect(markup).toContain("7 copies");
|
||||
expect(markup).toContain("1 copy");
|
||||
expect(markup).toContain("8 total copies");
|
||||
expect(markup).toContain("Account-wide collection log");
|
||||
expect(markup).toContain("Group by dungeon");
|
||||
expect(markup).toContain("Group by rarity");
|
||||
for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog";
|
||||
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, emptyBossCardCollection, type BossCardCollection as Collection } from "../game/bossCards";
|
||||
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, type BossCardCollection as Collection } from "../game/bossCards";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
@@ -11,13 +10,11 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import "./bossCards.css";
|
||||
|
||||
const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
|
||||
const EMPTY_COLLECTION = emptyBossCardCollection();
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
export function BossCardCollection({ onClose, collection }: {
|
||||
readonly onClose: () => void;
|
||||
readonly collection: Collection;
|
||||
readonly characterName: string;
|
||||
}) {
|
||||
useContentManagerSnapshot();
|
||||
const [earnedOnly, setEarnedOnly] = useState(false);
|
||||
@@ -52,7 +49,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
|
||||
<header className="boss-collection__header">
|
||||
<div><p className="eyebrow">{characterName}’s collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
|
||||
<div><p className="eyebrow">Account-wide collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
|
||||
<ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
|
||||
</header>
|
||||
<div className="boss-collection__summary">
|
||||
@@ -100,11 +97,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
}
|
||||
|
||||
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
|
||||
const savedCharacter = useShellStore(selectedCharacter);
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const liveCollection = useCombatStore((state) => state.bossCards);
|
||||
const character = activeCharacter ?? savedCharacter;
|
||||
const collection = useShellStore((state) => state.bossCards);
|
||||
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())}
|
||||
collection={phase === "game" ? liveCollection : character?.bossCards ?? EMPTY_COLLECTION} characterName={character?.name ?? "Adventurer"} />;
|
||||
collection={collection} />;
|
||||
}
|
||||
@@ -10,11 +10,12 @@ const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVER
|
||||
|
||||
function BossGuideDialog() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === activeDungeonId) ?? GUIDE_CATALOG[0];
|
||||
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
|
||||
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
|
||||
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
|
||||
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses[0]?.id ?? "");
|
||||
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
|
||||
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
|
||||
?? selectedDungeon?.bosses[0];
|
||||
const close = () => openOverlay("pause");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { BOSS_TESTING_STATUSES, BOSS_TESTING_STATUS_LABELS, useBossTestingReviews, type BossTestingStatus } from "../app/bossTestingReviews";
|
||||
import { bossTestIsDefeated } from "../game/BossTestingBridge";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import "./bossTesting.css";
|
||||
|
||||
export function BossTestingReviewPanel() {
|
||||
const boss = useGameStore((state) => state.bossTest);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const review = useBossTestingReviews((state) => boss ? state.reviews[boss.key] : undefined);
|
||||
const save = useBossTestingReviews((state) => state.save);
|
||||
const refresh = useBossTestingReviews((state) => state.refresh);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const busy = useRef(false);
|
||||
const mark = async (status: BossTestingStatus) => {
|
||||
if (!boss || busy.current) return;
|
||||
busy.current = true; setSaving(true); setMessage("");
|
||||
const synced = await save(boss, status, session);
|
||||
setMessage(synced ? `Marked ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}.` : "Saved on this device. This label will sync when you reconnect or reopen Boss Testing.");
|
||||
busy.current = false; setSaving(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
void refresh(session);
|
||||
const reconnect = () => void refresh(session);
|
||||
window.addEventListener("online", reconnect);
|
||||
return () => window.removeEventListener("online", reconnect);
|
||||
}, [refresh, session]);
|
||||
const defeated = bossTestIsDefeated();
|
||||
const back = () => { if (!busy.current) useGameStore.getState().openOverlay("pause"); };
|
||||
const retry = () => useGameStore.getState().resetAtEntrance();
|
||||
const leave = () => useShellStore.getState().returnToMainMenu();
|
||||
const actions: MenuAction[] = [
|
||||
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !saving })),
|
||||
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !saving },
|
||||
{ id: "boss-review-retry", run: retry, enabled: !saving },
|
||||
{ id: "boss-review-leave", run: leave, enabled: !saving },
|
||||
{ id: "boss-review-back", run: back, enabled: !saving },
|
||||
];
|
||||
const controller = useMenuController(actions, { initialId: "boss-review-needs-testing", onBack: back });
|
||||
if (!boss) return null;
|
||||
const button = (controlId: string, label: string, run: () => void, pressed?: boolean) =>
|
||||
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
|
||||
type="button" className="button" onClick={run} disabled={saving} aria-pressed={pressed}>{label}</ControllerButton>;
|
||||
return <div className="modal-backdrop" role="presentation">
|
||||
<section className="pause-menu boss-testing-review" role="dialog" aria-modal="true" aria-labelledby="boss-review-title">
|
||||
<p className="eyebrow">{defeated ? "Boss defeated" : "Boss Testing paused"}</p><h2 id="boss-review-title">{boss.name}</h2>
|
||||
<p>Current readiness: <strong>{BOSS_TESTING_STATUS_LABELS[review?.status ?? "needs-testing"]}</strong></p>
|
||||
<div className="boss-testing-actions" aria-label="Boss readiness">{BOSS_TESTING_STATUSES.map((status) =>
|
||||
<span key={status}>{button(`boss-review-${status}`, `Mark ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}`, () => void mark(status), review?.status === status)}</span>
|
||||
)}</div>
|
||||
{message && <p role="status">{message}</p>}
|
||||
<div className="pause-menu__actions">
|
||||
{button("boss-review-report", "Submit a boss report", () => useGameStore.getState().openOverlay("report-issue"))}
|
||||
{button("boss-review-retry", "Fight this boss again", retry)}
|
||||
{button("boss-review-leave", "Return to main menu", leave)}
|
||||
{button("boss-review-back", "Back to pause menu", back)}
|
||||
</div>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { enterBossTesting, prepareBossTesting } from "../app/bossTestingRuntime";
|
||||
import { BOSS_TESTING_STATUS_LABELS, useBossTestingReviews } from "../app/bossTestingReviews";
|
||||
import { BOSS_TESTING_CATALOG, randomTestingBoss, type TestableBoss } from "../game/bossTesting";
|
||||
import { defaultPartyRoleForClass, partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyRoles";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { FrontSurface, BrandMark, ControllerLegend } from "./FrontSurface";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import "./bossTesting.css";
|
||||
|
||||
export function BossTestingScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const reviews = useBossTestingReviews((state) => state.reviews);
|
||||
const refresh = useBossTestingReviews((state) => state.refresh);
|
||||
const content = useContentManagerSnapshot();
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
const [preparing, setPreparing] = useState(false);
|
||||
const [prepared, setPrepared] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [role, setRole] = useState<PartyRole>(character ? defaultPartyRoleForClass(character.classId) : "damage");
|
||||
const busy = useRef(false);
|
||||
const alive = useRef(true);
|
||||
const bosses = useMemo(() => BOSS_TESTING_CATALOG.filter((boss) =>
|
||||
(category === "all" || boss.category === category)
|
||||
&& (status === "all" || (reviews[boss.key]?.status ?? "needs-testing") === status)
|
||||
&& `${boss.name} ${boss.dungeonTitle}`.toLowerCase().includes(query.trim().toLowerCase())
|
||||
), [category, query, reviews, status]);
|
||||
const selected = BOSS_TESTING_CATALOG.find((boss) => boss.key === selectedKey);
|
||||
const specialization = specializationById(character?.specializationId);
|
||||
const roles = character ? specialization ? [specialization.role] : partyRolesForClass(character.classId) : [];
|
||||
const effectiveRole = specialization?.role ?? role;
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void refresh(session);
|
||||
const reconnect = () => void refresh(session);
|
||||
window.addEventListener("online", reconnect);
|
||||
return () => { alive.current = false; window.removeEventListener("online", reconnect); };
|
||||
}, [refresh, session]);
|
||||
|
||||
const choose = async (boss: TestableBoss, start = false) => {
|
||||
if (busy.current || !character) return;
|
||||
busy.current = true;
|
||||
setSelectedKey(boss.key); setPreparing(true); setPrepared(false); setError("");
|
||||
try {
|
||||
await prepareBossTesting(boss);
|
||||
if (!alive.current) return;
|
||||
setPrepared(true);
|
||||
if (start) enterBossTesting(boss, effectiveRole);
|
||||
} catch (failure) {
|
||||
if (alive.current) setError(failure instanceof Error ? failure.message : "The boss could not be prepared. Please retry.");
|
||||
} finally {
|
||||
busy.current = false;
|
||||
if (alive.current) setPreparing(false);
|
||||
}
|
||||
};
|
||||
const testSelected = () => {
|
||||
if (!selected || preparing) return;
|
||||
if (!prepared) { void choose(selected, true); return; }
|
||||
try { enterBossTesting(selected, effectiveRole); } catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : "The test could not be started.");
|
||||
}
|
||||
};
|
||||
const random = () => {
|
||||
const boss = randomTestingBoss(bosses);
|
||||
if (boss) void choose(boss, true);
|
||||
};
|
||||
const actions: MenuAction[] = [
|
||||
{ id: "boss-testing-back", run: onClose },
|
||||
{ id: "boss-testing-category", run: () => setCategory(category === "all" ? "wow" : category === "wow" ? "rom" : "all"), enabled: !preparing },
|
||||
{ id: "boss-testing-status", run: () => { const values = ["all", "ready", "needs-testing", "not-ready"]; setStatus(values[(values.indexOf(status) + 1) % values.length]); }, enabled: !preparing },
|
||||
{ id: "boss-testing-picker", run: () => { const next = bosses[(bosses.findIndex((boss) => boss.key === selectedKey) + 1) % bosses.length]; if (next) void choose(next); }, enabled: !preparing && bosses.length > 0 },
|
||||
...roles.map((value) => ({ id: `boss-testing-role-${value}`, run: () => setRole(value), enabled: !preparing })),
|
||||
{ id: "boss-testing-start", run: testSelected, enabled: Boolean(selected) && !preparing },
|
||||
{ id: "boss-testing-random", run: random, enabled: bosses.length > 0 && !preparing },
|
||||
];
|
||||
const controller = useMenuController(actions, { initialId: "boss-testing-picker", onBack: onClose });
|
||||
const selectProps = (controlId: string) => ({ className: controller.selectedId === controlId ? "is-controller-selected" : "",
|
||||
onPointerEnter: () => controller.select(controlId), disabled: preparing });
|
||||
const button = (controlId: string, label: string, run: () => void, disabled = false) =>
|
||||
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" onClick={run} disabled={disabled}>{label}</ControllerButton>;
|
||||
return <DualDisplayFrame contextLabel="Boss Testing" top={
|
||||
<FrontSurface className="boss-testing-screen" ariaLabel="Boss Testing selection">
|
||||
<header className="boss-testing-header"><BrandMark compact /><div><span>Main menu / Mechanics laboratory</span><h1>Boss Testing</h1></div>
|
||||
{button("boss-testing-back", "Back to main menu", onClose)}</header>
|
||||
<main className="boss-testing-main">
|
||||
<p>Choose a boss to test with your party in an empty room. Missing content downloads automatically when you select a boss.</p>
|
||||
<div className="boss-testing-filters">
|
||||
<label>Search bosses<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Boss or dungeon name" disabled={preparing} /></label>
|
||||
<label>Game<select {...selectProps("boss-testing-category")} value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
<option value="all">All games</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
|
||||
<label>Readiness<select {...selectProps("boss-testing-status")} value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
<option value="all">All statuses</option>{Object.entries(BOSS_TESTING_STATUS_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
</div>
|
||||
<label className="boss-testing-picker">Choose a boss <small>{bosses.length} bosses</small>
|
||||
<select {...selectProps("boss-testing-picker")} value={bosses.some((boss) => boss.key === selectedKey) ? selectedKey : ""}
|
||||
onChange={(event) => { const boss = bosses.find((entry) => entry.key === event.target.value); if (boss) void choose(boss); }}>
|
||||
<option value="" disabled>Select a boss…</option>
|
||||
{bosses.map((boss) => <option key={boss.key} value={boss.key}>{boss.name} · {boss.dungeonTitle} · {BOSS_TESTING_STATUS_LABELS[reviews[boss.key]?.status ?? "needs-testing"]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{selected && <article className="boss-testing-selected"><p className="front-kicker">Selected encounter</p><h2>{selected.name}</h2><p>{selected.dungeonTitle}</p>
|
||||
<strong className={`boss-testing-status boss-testing-status--${reviews[selected.key]?.status ?? "needs-testing"}`}>{BOSS_TESTING_STATUS_LABELS[reviews[selected.key]?.status ?? "needs-testing"]}</strong>
|
||||
{reviews[selected.key]?.pending && <small>Readiness saved on this device; waiting to sync.</small>}
|
||||
</article>}
|
||||
{roles.length > 1 && <div className="boss-testing-actions" aria-label="Your party role">{roles.map((value) =>
|
||||
<ControllerButton key={value} controlId={`boss-testing-role-${value}`} selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" aria-pressed={effectiveRole === value} onClick={() => setRole(value)} disabled={preparing}>{PARTY_ROLE_LABELS[value]}</ControllerButton>
|
||||
)}</div>}
|
||||
{preparing && <div role="status" className="boss-testing-progress"><p>Preparing {selected?.name}… {content.installingPackId ? "Downloading content" : "Checking assets"}</p>
|
||||
{content.installingPackId && <><progress max={Math.max(1, content.totalBytes)} value={content.downloadedBytes} /><small>{Math.round(content.downloadedBytes / 1024 / 1024)} / {Math.round(content.totalBytes / 1024 / 1024)} MB</small></>}
|
||||
</div>}
|
||||
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
|
||||
{!bosses.length && <p role="status">No bosses match these filters.</p>}
|
||||
<div className="boss-testing-actions">{button("boss-testing-start", error ? "Retry and test selected boss" : "Test selected boss", testSelected, !selected || preparing)}
|
||||
{button("boss-testing-random", "Fight a random boss", random, !bosses.length || preparing)}</div>
|
||||
<p className="boss-testing-help">Mark readiness and submit a boss report from pause or after victory. Test fights do not award loot, XP, or boss cards.</p>
|
||||
</main><ControllerLegend back />
|
||||
</FrontSurface>
|
||||
} bottom={<LazyLocalCompanionPanel />} />;
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { DungeonCompanionHud } from "./DungeonCompanionHud";
|
||||
|
||||
function safePercent(current: number, maximum: number): number {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
||||
@@ -166,12 +168,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
<p className="companion-kicker">Ready on both screens</p>
|
||||
<h2>One roster. Two ways to play.</h2>
|
||||
<div className="companion-feature-grid">
|
||||
<article><b>01</b><span><strong>Local account</strong><small>Salted password verifier on this device</small></span></article>
|
||||
<article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
|
||||
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
|
||||
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
|
||||
</div>
|
||||
</main>
|
||||
<footer><span>Top screen owns account entry</span><b>THOR / 620 x 540</b></footer>
|
||||
<footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
@@ -297,6 +299,19 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
return <CompanionDungeonSelection selected={selected} send={send} />;
|
||||
}
|
||||
|
||||
if (snapshot.phase === "class-hall") {
|
||||
const definition = selected ? classById(selected.classId) : null;
|
||||
return <FrontSurface bottom className="companion-panel companion-create" ariaLabel="Class Hall companion screen">
|
||||
<header><BrandMark compact /><b>CLASS HALL</b></header>
|
||||
<main><section><p className="companion-kicker">{selected?.name ?? "Your adventurer"}</p>
|
||||
<h2>{definition?.name ?? "Choose your path"}</h2>
|
||||
<p>Choose your specialization on the top screen.</p>
|
||||
<p>Your specialization determines your role and starting equipment.</p>
|
||||
</section></main>
|
||||
<footer className="companion-actions"><button type="button" onClick={() => send({ name: "returnToMainMenu" })}>Back to main menu</button></footer>
|
||||
</FrontSurface>;
|
||||
}
|
||||
|
||||
if (snapshot.phase === "manastorms") {
|
||||
return <ShelvedCompanionPanel selected={selected} send={send} />;
|
||||
}
|
||||
@@ -510,11 +525,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
}
|
||||
|
||||
export function LocalCompanionPanel() {
|
||||
useShellStore();
|
||||
const shell = useShellStore();
|
||||
useGameStore();
|
||||
useCombatStore();
|
||||
usePartyStore();
|
||||
useUiLayoutStore();
|
||||
useDpsMeterStore();
|
||||
if (shell.phase === "game" && currentDisplayLayout() !== "single") return <DungeonCompanionHud send={executeCompanionCommand} />;
|
||||
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { ActionIcon, type ActionBarItem } from "./ActionBar";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
|
||||
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
|
||||
const ui = useUiLayoutStore();
|
||||
@@ -19,7 +20,8 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
|
||||
{bar.slots.map((button, s) => {
|
||||
const item = button.abilityId ? items[button.abilityId] : null;
|
||||
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
|
||||
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
|
||||
return <AbilityTooltipTarget key={s} ability={item} bindingLabel={actionKeyLabel(button.key)}>
|
||||
<button type="button" className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
|
||||
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
|
||||
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
|
||||
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
|
||||
@@ -30,7 +32,7 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
|
||||
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
|
||||
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
|
||||
{editing && !item && <small>{s + 1}</small>}
|
||||
</button>;
|
||||
</button></AbilityTooltipTarget>;
|
||||
})}
|
||||
</section>
|
||||
</EditableHudElement>; })}</>;
|
||||
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user