Update Healer Man server
This commit is contained in:
1 parent
f90ba7d989
commit
189b94d6b7
51 files changed
+3448
-154
No files matched your search
@@ -3717,3 +3717,50 @@ Vite's existing large-chunk advisory remains informational.
|
||||
testing, companion readiness, reports, and the authorized GM/server tools now
|
||||
live behind one controller-navigable GM Menu dialog; unauthorized accounts
|
||||
see GM Tools and Balance Lab as locked rather than losing the menu structure.
|
||||
|
||||
## Discipline Priest Holy Nova (2026-09-22)
|
||||
|
||||
- A level-80 Priest cast Holy Nova in a built Wailing Caverns scene. The live
|
||||
cast restored 770 health to the injured player. At 400 ms, one Nova burst was
|
||||
visible; by 850 ms, it had ended. Screenshots are in
|
||||
`playtest-artifacts/holy-nova/holy-nova-100ms.png`,
|
||||
`playtest-artifacts/holy-nova/holy-nova-400ms.png`, and
|
||||
`playtest-artifacts/holy-nova/holy-nova-850ms.png`.
|
||||
- The nearby-enemy damage and nearby-ally healing paths passed the focused
|
||||
combat-store test. The four focused suites, Ready-five certification,
|
||||
TypeScript build, production build, and release candidate check passed.
|
||||
|
||||
## RoM primary five validation (2026-09-22)
|
||||
|
||||
- A fresh local account created a Human RoM Rogue. Character creation showed
|
||||
level-based skill unlocks. The roster preview rendered the complete character
|
||||
body with a starter weapon silhouette after the 3D canvas loaded.
|
||||
- The same Rogue entered a live Wailing Caverns scene as DPS. The player and
|
||||
companions rendered bodies and equipped weapon or staff silhouettes; the
|
||||
browser reported no page errors. The left-hand Rogue dagger is not clear in
|
||||
these camera angles, so this is a body/equipment-presence check rather than
|
||||
visual proof of dual daggers. The resolved virtual loadout test checks both
|
||||
dagger slots.
|
||||
- Evidence: `artifacts/playtest/rom-five/roster-preview.png` and
|
||||
`artifacts/playtest/rom-five/dungeon-rogue.png`. The in-dungeon Skills and
|
||||
Spellbook panels also showed level unlocks without numbered skill ranks;
|
||||
evidence is `skills-no-ranks.png` and `spellbook-no-ranks.png` in that same
|
||||
directory. The repeatable flow is `node scripts/playtest-rom-five.cjs`.
|
||||
- At 1440 x 900 the dual-display "Roster details" toggle overlays the roster
|
||||
Continue button. The playtest used a programmatic click to continue; this
|
||||
existing layout collision should be addressed in the shell UI.
|
||||
|
||||
## Named RoM class pairs (2026-09-23)
|
||||
|
||||
- A fresh local account created a male RoM Dwarf Mage. Its live creation and
|
||||
roster previews rendered the complete body and starter staff silhouette.
|
||||
Evidence: `artifacts/playtest/rom-five/dwarf-mage-creator.png` and
|
||||
`artifacts/playtest/rom-five/dwarf-mage-roster.png`.
|
||||
- The same session entered Wailing Caverns with a RoM Rogue. The party frames
|
||||
showed paired-cohort NPC names Serenna, Caelan, and Veyra for that seed; the
|
||||
player and companions rendered bodies and weapon or staff silhouettes in the
|
||||
scene. The browser reported no page errors. Evidence:
|
||||
`artifacts/playtest/rom-five/dungeon-rogue.png`. The companion pair and race
|
||||
assignments, including Durnik as a dwarf, are covered by
|
||||
`src/game/romFiveValidation.test.ts`; this level-1 scene does not exercise
|
||||
the level-10 secondary-class unlock or elite-skill mechanics.
|
||||
@@ -0,0 +1,58 @@
|
||||
# RoM five class validation
|
||||
|
||||
Status: **Needs Testing**. This is a targeted diagnostic for Priest healer, Knight tank, Scout DPS, Rogue DPS, and Mage DPS. Elite-skill mechanics remain outside this pass.
|
||||
|
||||
## Cohort and rules
|
||||
|
||||
The five named builds and live NPCs use these race-legal pairings. Their secondary classes activate at level 10. RoM has no talent allocation; ordinary skills unlock by level, and the generated source's internal level variants do not create a player-facing spell-rank choice.
|
||||
|
||||
| Role | Class pair | NPC name | Race |
|
||||
| --- | --- | --- | --- |
|
||||
| Tank | Knight / Scout | Rorik | Human |
|
||||
| Healer | Priest / Knight | Serenna | Human |
|
||||
| DPS | Rogue / Warrior | Veyra | Human |
|
||||
| DPS | Scout / Rogue | Caelan | Elf |
|
||||
| DPS | Mage / Warlock | Durnik | Dwarf |
|
||||
|
||||
`src/game/romFiveIdentities.ts` owns these identities for the live NPC templates and targeted canonical builds. The manual party uses Priest as the active healer and the other four classes as companions. This explicit cohort does not change Ready-only random parties, bracket certification, or the companion readiness registry. Elite skills can appear in a paired kit through the existing RoM catalog, but their mechanics have not yet been validated for this cohort.
|
||||
|
||||
`npm run test:rom-five` checks the named pairings, legal races, level-10 unlock, role-specific heirloom layouts, ordinary ability coverage, and focused runtime mechanics. Knight is a tank seat in RoM generated parties. Rogue uses dual daggers, Knight a weapon and shield, and Scout a bow.
|
||||
|
||||
## Findings and fixes in this pass
|
||||
|
||||
- Player and companion casts now respect source out-of-combat restrictions for Resurrection and Rogue openers. Sneak Attack and Blind Spot carry behind-target rules for spatial combat.
|
||||
- Rogue Throw executes one projectile hit instead of two identical source attack components. Scout Shot executes its primary hit instead of five alternate attack components and unrelated target stat effects.
|
||||
- RoM character creation, Skills, and Spellbook screens present level-based skill unlocks without talent-point or spell-rank choices.
|
||||
- The RoM Knight party template is assigned to the tank pool rather than the damage pool.
|
||||
- Conditional combat payloads now resolve once per cast for both player and companion actors. Knight Holy Seals cap at four on each target, their bleed follows the stack count, and Punishment, Threaten, Shock, and Truth Shield Bash use the corresponding seal branch. Punishment consumes the seal bleed. Rogue Low Blow, Wound Attack, Shadowstab, and Sneak Attack use active self-applied bleeds and the equipped main-hand weapon; Wound Attack no longer invents a bleed. Mage Electric Explosion executes either its normal or Charged hit.
|
||||
- Rogue Premeditation now guarantees the next offensive critical hit and increases its critical power. Poison supplies the Poisonous state needed by Numbing Dagger. Vanish permits an out-of-combat Rogue opener while its stealth state lasts. Shadow Step moves behind the target; Knight Charge closes to melee range.
|
||||
- Priest Regenerate has one heal-over-time effect. Holy Candle and Scout Blood Arrow drain the caster's HP on their periodic ticks. Mage Flame and Fireball execute one hit, Meteor Shower five, and Thunderstorm three. Source helper summons and zero-damage effects no longer create extra combat actions for these skills.
|
||||
- Scout Lasso, Rogue Numbing Dagger, and Mage Static Field apply one break-on-damage control effect to one target at a time. Scout Joint Blow only slows; Mage Discharge applies its stun without unrelated control effects. Knight Shield of Discipline absorbs the documented damage types and reflects a spell hit after the caster is struck, not on cast. Shield-dependent Knight skills require an equipped shield.
|
||||
|
||||
## Combat diagnostic
|
||||
|
||||
The following historical runs predate secondary-class assignment. They used `scripts/balance_lab.py` with `--source rom --party-mode manual`, exactly the five canonical build IDs for that level, and 180-second fights. Reports are under `artifacts/balance-lab/rom-five-primary-*`.
|
||||
|
||||
| Level | Encounter set | Runs defeated | Death events | Median time to defeat |
|
||||
| ---: | --- | ---: | ---: | ---: |
|
||||
| 20 | Cayus, two seeds | 2/2 | 0 | 70.2 s |
|
||||
| 40 | Cayus, two seeds | 2/2 | 0 | 79.4 s |
|
||||
| 60 | All 62 RoM bosses, one seed | 62/62 | 0 | 101.5 s |
|
||||
| 60 | Shadoj, three seeds | 3/3 | 0 | 106.6 s |
|
||||
| 80 | Cayus, two seeds | 2/2 | 0 | 122.0 s |
|
||||
|
||||
The table above also predates the conditional mechanics changes. The level-60 all-boss report measured approximately 1,036 Knight tank DPS, 831 Rogue DPS, 374 Mage DPS, 223 Scout DPS, and 35 Priest HPS. These are screening measurements, not balance targets: this cohort has only one healer seat, one tank seat, and one seed per boss. Ten bosses dealt no effective damage and therefore did not exercise healing.
|
||||
|
||||
After the mechanics corrections, the targeted Shadoj run at level 60 with the same five manual builds and seeds 1, 2, and 3 defeated the boss in 3/3 fights with no deaths. Its mean defeat time was 115.7 seconds. The report at `artifacts/balance-lab/rom-five-primary-l60-mechanics-final/` measured about 1,186 Rogue DPS, 750 Knight DPS, 237 Scout DPS, 186 Mage DPS, and 334 Priest HPS. The changed output is expected from removing duplicate hits and enforcing source conditions; it is a diagnostic, not a promotion or tuning target.
|
||||
|
||||
## Before Ready promotion
|
||||
|
||||
1. Resolve the persistent damage spread: Rogue is now far ahead of Scout and Mage, while Knight tank damage remains above both. Review source formulas, resource cadence, and rotations before tuning coefficients.
|
||||
2. Finish source-specific restrictions that have no current inventory or creature-family authority. Numbing Dagger's projectile consumption and living/Humanoid target rule still need those systems. A source-row audit should continue beyond the corrected conditional skills; basic semantic coverage does not prove full tooltip parity.
|
||||
3. Exercise behind-target requirements in a live spatial scene. The headless balance runner has no spatial target provider, so its Rogue damage can include casts that a live scene would reject.
|
||||
4. Validate Priest throughput against encounters that actually demand sustained healing, and run RoM NPC seat tests after the mechanics above are resolved. The current manual run leaves Priest player-controlled.
|
||||
5. Verify both Rogue daggers visually from an unobstructed angle. The live roster and dungeon screenshots in `artifacts/playtest/rom-five/` confirm rendered bodies and weapon silhouettes, but the off-hand dagger is unclear at their camera angles. Keep RoM entries at Needs Testing until the full class mechanics and role checks pass.
|
||||
|
||||
The RoM source catalog and generated data remain authoritative for ability descriptions and components. The recorded combat results cover primary-only builds; rerun targeted diagnostics before drawing conclusions about the new pairings. Elite skill validation is deferred.
|
||||
|
||||
RoM stat and equipment fidelity is a separate prerequisite for balance certification. `docs/ROM_STATS_AND_GEARING.md` records the source conversion coefficients, current WoW-shaped gear adapter, native item import gaps, and the players-only RoM online group boundary.
|
||||
@@ -0,0 +1,55 @@
|
||||
# RoM stat weights and gearing audit
|
||||
|
||||
Status: **source model identified; runtime conversion and equipment import pending**. This audit covers the Priest/Knight healer, Knight/Scout tank, Scout/Rogue DPS, Rogue/Warrior DPS, and Mage/Warlock DPS. It does not assign balance weights to unimplemented RoM gear.
|
||||
|
||||
## Current runtime
|
||||
|
||||
- `src/game/romStats.ts` reads primary-class level growth from `romPlayerCatalog.generated.json` and gives the secondary class 10% of its base stats **at the same level as the primary**. This shared level is the game's progression rule, even though the historical source allowed separate class levels. The runtime also omits source race and sex additions.
|
||||
- `src/game/wotlkStats.ts` maps RoM Wisdom into WoW Spirit, then derives HP, Mana, attack power, spell power, avoidance, and ratings with WoW rules. This is a compatibility adapter, not RoM stat conversion. In particular, the global Mana multiplier, Stamina-to-HP rule, and `0.35 * Intellect + 0.15 * Spirit` spell-power rule are not source RoM formulas.
|
||||
- `src/game/specializationCatalog.ts` selects one of the WoW gear archetype weight tables for RoM. `src/game/heirloomEquipment.ts` uses WoW budgets and compares against WoW boss gear to create RoM heirlooms. The Scout currently receives a two-hand mace alongside a bow, although source Scout equipment allows one-hand swords, daggers, bows, and crossbows. `src/game/equipment.ts` also lacks the source Priest shield permission.
|
||||
- `src/game/runewakerLootCatalog.ts` keeps imported RoM drops as non-equippable `misc` items with zero stats. The loot importer preserves names and probabilities but does not decode native equipment slots, base stats, or enhancements. `src/game/lootPolicy.ts` suppresses **all** boss item drops during leveling; boss cards are awarded through a separate path. At level 80, imported RoM boss items still lack usable equipment data.
|
||||
|
||||
## Source stat model
|
||||
|
||||
The historical RuneWaker source is under `../rom-pvt-server-backups/Runewaker/`. `SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp` builds each conversion argument from race + vocation + sex. `Resource/data/voctable.db`, `racetable.db`, and `sextable.db` hold those terms; `ObjectStruct.h` defines the argument layout. The player vocation record is the second record of each class pair, as already used by `scripts/runewaker-pipeline/vocation-growth.mjs`.
|
||||
|
||||
After base growth and equipment attribute bonuses, source `RoleAttr.cpp` computes:
|
||||
|
||||
| Native result | Source dependency before rate modifiers |
|
||||
| --- | --- |
|
||||
| Max HP | base HP + Strength × `HpStrArg` + Stamina × `HpStaArg` + flat equipment HP |
|
||||
| Max MP | base MP + Intelligence × `MPIntArg` + Wisdom × `MpMndArg` + flat equipment MP |
|
||||
| Physical ATK | Strength × `AtkStrArg` + Intelligence × `AtkIntArg` + Agility × `AtkAgiArg` + flat equipment ATK |
|
||||
| DEF / MDEF | Stamina × `DefArg` + armor/flat DEF; Wisdom × `MDefArg` + flat MDEF |
|
||||
| Magical ATK | Intelligence × `MAtkArg` + flat equipment MATK |
|
||||
| HP / MP regeneration | base rate + Stamina × `ReHpArg`; base rate + Wisdom × `ReMpArg` |
|
||||
| Physical hit / dodge | Agility plus corresponding equipment values and fixed arguments |
|
||||
| Magical hit / dodge | Wisdom, with Agility also contributing to magical dodge |
|
||||
|
||||
The table below shows **vocation terms only** from the player records of `voctable.db`. Race and sex terms are additive, so these are not final character coefficients and must not be pasted into a gear-score formula as complete weights.
|
||||
|
||||
| Primary role | Relevant source vocation terms | Gear consequence to validate |
|
||||
| --- | --- | --- |
|
||||
| Knight tank | Stamina → HP 5, DEF 1.5; Wisdom → MP 5, MDEF 3.2 | Value shield/armor DEF, Stamina, MDEF, and survival against actual encounter damage. A WoW defense-rating budget is not a substitute. |
|
||||
| Priest healer | Intelligence → MATK 2, MP 1; Wisdom → MP 5, MDEF 3, MP regeneration 0.3 | Keep healing effect power, Mana sustain, and Wisdom distinct from WoW Spirit and generic spell power. |
|
||||
| Scout DPS | Strength → physical ATK 2; Agility contributes to hit/dodge through fixed arguments | Bow damage and physical ATK need source conversion before a Strength/Agility score can be trusted. |
|
||||
| Rogue DPS | Strength → physical ATK 1; Agility → physical ATK 1 and hit/dodge | Evaluate both attributes and equipped dagger damage, including the off hand. |
|
||||
| Mage DPS | Intelligence → MATK 2; Wisdom → MP 5 and MDEF 2.3 | MATK, magical weapon damage, and sustain need separate scoring. |
|
||||
|
||||
Priest healing cannot be scored by MATK alone: the source healing path also uses magic-power rate and heal-point inputs. Critical chance, weapon damage, resistance, and encounter mix further change the value of a stat. These conversion coefficients are mechanics, not final DPS/HPS weights.
|
||||
|
||||
## Equipment model to implement
|
||||
|
||||
RoM equipment has base item properties plus additional attribute sources. [Gameforge's class guide](https://archive.runesofmagic.gameforge.com/en/classes.html) lists native class weapon/armor permissions. Its [rune guide](https://archive.runesofmagic.gameforge.com/us/runes.html) describes socketed runes and up to four rune slots; its [Arcane Transmutor guide](https://archive.runesofmagic.gameforge.com/en/arcanetransmutor.html) describes transferring attributes through Fusion and Mana Stones. These changes must be represented as item-instance bonuses rather than a WoW item-level budget. Source `RoleAttr.cpp` already distinguishes flat equipment contributions, attribute contributions, and multiplicative rates.
|
||||
|
||||
1. Decode `armorobject.db` and `weaponobject.db` into native slot, armor/weapon type, base defense/damage, attribute, and level fields. Preserve source object IDs and validate the binary layout against source structures and known items before allowing package equipment to equip.
|
||||
2. Add a RoM item-instance bonus layer for native flat stats, transferred attributes, rune sockets, and later enhancement/tier data. Sum those bonuses into a native stat block. Keep WoW `ItemStats` and heirloom scoring unchanged for WoW characters.
|
||||
3. Extend the generated class data with race and sex arguments and the vocation conversion coefficients. Keep secondary growth tied to the primary's level, including after the level-10 secondary unlock. Convert the complete RoM stat block to combat inputs through a RoM-specific adapter, including ATK, MATK, DEF, MDEF, and separate healing inputs.
|
||||
4. Use legal starter equipment as a temporary bridge, then source-backed quest or item-shop equipment packages for leveling. The client snapshot confirms four armor-choice quest reward families at levels 50, 55, 60, and 65; see `docs/ROM_LEVELING_GEAR_PACKAGES.md`. Resolve the package expansion data and missing earlier/later tiers before granting source items. Dungeon bosses award cards during leveling, not equipment or miscellaneous items. Do not tune a RoM heirloom to a WoW drop score or apply WoW rating conversions to it.
|
||||
5. Derive role-specific *upgrade scores* from the marginal combat effect of each native stat at the current level and gear state. Measure Knight survival/threat, Priest effective HPS and sustain, and each DPS build's damage on the same targeted encounter set. Source coefficients seed that model; targeted combat measurements establish practical weights and catch caps or nonlinear effects.
|
||||
|
||||
## Online and validation boundary
|
||||
|
||||
`server/database.mjs` rejects a RoM/WoW source mixture when a grouped player selects a character and checks again when an activity starts. Until RoM AI companions are separately ready, online RoM groups launch players-only; the WoW-only randomized Bite-Sized Stockades activity is unavailable to them. This does not change local manual RoM party diagnostics. The five RoM profiles remain **Needs Testing** and elite skills remain deferred.
|
||||
|
||||
Validate each import with source-record fixture tests, legal slot/equip tests for all five builds, direct native-stat conversion cases, then `npm run test:rom-five` and targeted level/encounter diagnostics. Compare package upgrades by native stat deltas and combat outcomes before changing dungeon tuning. Keep a common encounter definition with source-specific stat translation and measured balance coefficients; no duplicate WoW/RoM dungeon catalogs are required by this audit.
|
||||
@@ -125,6 +125,7 @@
|
||||
"balance:lab:playtest": "node scripts/playtest-balance-lab.cjs",
|
||||
"readiness:playtest": "node scripts/playtest-companion-readiness.cjs",
|
||||
"test:ready-five": "vitest run src/game/companionSpecReadiness.test.ts src/game/readyFiveCertification.test.ts && python -m unittest scripts.test_balance_lab scripts.test_balance_lab_brackets",
|
||||
"test:rom-five": "vitest run src/game/romFiveValidation.test.ts src/game/romCombatRuntime.test.ts src/game/romCombatMechanics.test.ts src/game/romAbilityCatalogSemantics.test.ts src/game/romPlayerCatalog.test.ts src/game/specializationCatalog.test.ts",
|
||||
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
||||
"prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
|
||||
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
||||
|
||||
@@ -216,6 +216,8 @@ function catalogPayload(source: PartyCatalogSource, level: number) {
|
||||
id: build.id,
|
||||
source: build.source,
|
||||
classId: build.classId,
|
||||
raceId: build.raceId ?? null,
|
||||
secondaryClassId: build.secondaryClassId ?? null,
|
||||
className: classById(build.classId).name,
|
||||
role: build.role,
|
||||
specialization: build.orientation,
|
||||
@@ -229,7 +231,9 @@ function catalogPayload(source: PartyCatalogSource, level: number) {
|
||||
talentBuildSourcePointsRetained: build.talentBuildSourcePointsRetained ?? null,
|
||||
talentBuildFallbackPointCount: build.talentBuildFallbackPointCount ?? null,
|
||||
defaultTalentBuild: build.defaultTalentBuild ?? true,
|
||||
label: `${classById(build.classId).name} — ${build.orientation} (${build.role})${build.talentBuildName ? ` — ${build.talentBuildName}` : ""}`,
|
||||
label: build.secondaryClassId
|
||||
? `${classById(build.classId).name}/${classById(build.secondaryClassId).name} — ${build.characterName ?? build.orientation} (${build.role})`
|
||||
: `${classById(build.classId).name} — ${build.orientation} (${build.role})${build.talentBuildName ? ` — ${build.talentBuildName}` : ""}`,
|
||||
gear: resolvedGearSummary(build),
|
||||
})),
|
||||
bosses: bossCatalogRows(),
|
||||
@@ -268,6 +272,7 @@ function configuredCompanion(
|
||||
return createConfiguredPartyMember({
|
||||
name: build.characterName ?? `${classById(build.classId).name} ${build.orientation}`,
|
||||
classId: build.classId,
|
||||
raceId: build.raceId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
specialization: build.orientation,
|
||||
specializationId: specialization?.id ?? null,
|
||||
@@ -362,6 +367,8 @@ function serializedMember(
|
||||
talentBuildSourcePointsRetained: metrics.build.talentBuildSourcePointsRetained ?? null,
|
||||
talentBuildFallbackPointCount: metrics.build.talentBuildFallbackPointCount ?? null,
|
||||
classId: metrics.build.classId,
|
||||
raceId: metrics.build.raceId ?? null,
|
||||
secondaryClassId: metrics.build.secondaryClassId ?? null,
|
||||
className: classById(metrics.build.classId).name,
|
||||
specialization: metrics.build.orientation,
|
||||
role: metrics.build.role,
|
||||
@@ -563,6 +570,7 @@ async function runBoss(
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: activeBuild.classId,
|
||||
raceId: activeBuild.raceId,
|
||||
secondaryClassId: activeBuild.secondaryClassId,
|
||||
specializationId: specialization?.id,
|
||||
role: activeBuild.role,
|
||||
@@ -776,6 +784,8 @@ async function simulateConfig(config: BalanceLabConfig, output: string): Promise
|
||||
talentBuildSourcePointsRetained: build.talentBuildSourcePointsRetained ?? null,
|
||||
talentBuildFallbackPointCount: build.talentBuildFallbackPointCount ?? null,
|
||||
classId: build.classId,
|
||||
raceId: build.raceId ?? null,
|
||||
secondaryClassId: build.secondaryClassId ?? null,
|
||||
className: classById(build.classId).name,
|
||||
specialization: build.orientation,
|
||||
role: build.role,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const directory = path.resolve(process.env.ROM_FIVE_PLAYTEST_DIR || 'artifacts/playtest/rom-five');
|
||||
const port = Number(process.env.ROM_FIVE_PLAYTEST_PORT || 4193);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-five-ui-'));
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(port, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(120_000);
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
|
||||
try {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByLabel('Username').fill('RomFiveQA');
|
||||
await page.getByLabel('Password').fill('rom-five-playtest');
|
||||
await page.getByRole('button', { name: /Create account/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
|
||||
await page.locator('.class-option').filter({ hasText: 'Rogue' }).click();
|
||||
await page.getByText('Skills unlock by level').waitFor();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('RomRogueQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
|
||||
await page.waitForTimeout(3_000);
|
||||
await page.screenshot({ path: path.join(directory, 'roster-preview.png') });
|
||||
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
|
||||
await page.locator('.race-option').filter({ hasText: 'Dwarf' }).click();
|
||||
await page.getByRole('button', { name: 'Mage Damage' }).click();
|
||||
await page.locator('.creator-preview-stage canvas').waitFor({ timeout: 180_000 });
|
||||
await page.waitForTimeout(3_000);
|
||||
await page.screenshot({ path: path.join(directory, 'dwarf-mage-creator.png') });
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('RomDwarfMageQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
|
||||
await page.waitForTimeout(3_000);
|
||||
await page.screenshot({ path: path.join(directory, 'dwarf-mage-roster.png') });
|
||||
await page.locator('.roster-character').filter({ hasText: 'RomRogueQA' }).click();
|
||||
|
||||
// The dual-display toggle overlays the narrow roster footer at this viewport.
|
||||
await page.locator('.roster-enter').evaluate((button) => button.click());
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await page.getByRole('button', { name: /Dungeons/ }).click();
|
||||
await page.locator('.dungeon-select-screen').waitFor();
|
||||
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
|
||||
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
|
||||
await page.getByRole('button', { name: /Choose role/ }).click();
|
||||
await page.locator('.dungeon-role-option--damage').click();
|
||||
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
|
||||
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 });
|
||||
assert.equal(await page.locator('.party-frame').count(), 5);
|
||||
await page.screenshot({ path: path.join(directory, 'dungeon-rogue.png') });
|
||||
await page.getByRole('button', { name: /Tactical/ }).click();
|
||||
await page.getByRole('button', { name: 'Skills', exact: true }).click();
|
||||
await page.locator('.rom-skills-panel').waitFor();
|
||||
assert.doesNotMatch(await page.locator('.rom-skills-panel').innerText(), /\bRank\s+\d+\b/);
|
||||
await page.screenshot({ path: path.join(directory, 'skills-no-ranks.png') });
|
||||
await page.locator('.rom-skills-panel').getByRole('button', { name: 'Close' }).click();
|
||||
await page.getByRole('button', { name: /Tactical/ }).click();
|
||||
await page.getByRole('button', { name: 'Spells', exact: true }).click();
|
||||
await page.locator('.spellbook-panel').waitFor();
|
||||
assert.doesNotMatch(await page.locator('.spellbook-panel').innerText(), /\bRank\s+\d+\b/);
|
||||
await page.screenshot({ path: path.join(directory, 'spellbook-no-ranks.png') });
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log(`RoM Rogue roster and dungeon screenshots: ${directory}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
fs.rmSync(runtimeData, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { projectRoot, sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const sourceRoot = sourceRootFor({
|
||||
source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker" },
|
||||
});
|
||||
const dataRoot = path.join(sourceRoot, "Resource", "data");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "romLevelingGearPackages.json");
|
||||
const files = ["questdetailobject.db", "itemobject.db", "armorobject.db", "suitobject.db", "string_enus.db"];
|
||||
const buffers = Object.fromEntries(await Promise.all(files.map(async (name) => (
|
||||
[name, await readFile(path.join(dataRoot, name))]
|
||||
))));
|
||||
|
||||
const DATA_START = 140;
|
||||
const COUNT_OFFSET = 132;
|
||||
const layouts = {
|
||||
"questdetailobject.db": { size: 1656, type: 7 },
|
||||
"itemobject.db": { size: 1776, type: 2 },
|
||||
"armorobject.db": { size: 1776, type: 2 },
|
||||
"suitobject.db": { size: 1284, type: 17 },
|
||||
};
|
||||
const indexes = Object.fromEntries(Object.entries(layouts).map(([name, layout]) => {
|
||||
const buffer = buffers[name];
|
||||
const count = buffer.readInt32LE(COUNT_OFFSET);
|
||||
if (count < 1 || DATA_START + count * layout.size > buffer.length) {
|
||||
throw new Error(`${name} record count or layout drifted.`);
|
||||
}
|
||||
const index = new Map();
|
||||
for (let row = 0; row < count; row += 1) {
|
||||
const offset = DATA_START + row * layout.size;
|
||||
if (buffer.readInt32LE(offset + 4) !== layout.type) {
|
||||
throw new Error(`${name} object type drifted at row ${row}.`);
|
||||
}
|
||||
index.set(buffer.readInt32LE(offset), offset);
|
||||
}
|
||||
return [name, index];
|
||||
}));
|
||||
|
||||
function record(name, id) {
|
||||
const offset = indexes[name].get(id);
|
||||
if (offset === undefined) throw new Error(`${name} is missing source ID ${id}.`);
|
||||
return buffers[name].subarray(offset, offset + layouts[name].size);
|
||||
}
|
||||
|
||||
const names = new Map();
|
||||
function sourceName(id) {
|
||||
if (names.has(id)) return names.get(id);
|
||||
const buffer = buffers["string_enus.db"];
|
||||
const key = Buffer.from(`Sys${id}_name\0`, "ascii");
|
||||
const position = buffer.indexOf(key);
|
||||
if (position < 0) throw new Error(`Missing English name for source ID ${id}.`);
|
||||
for (let offset = position + key.length; offset < Math.min(buffer.length - 4, position + key.length + 600); offset += 1) {
|
||||
const length = buffer.readInt32LE(offset);
|
||||
if (length < 2 || length > 300 || offset + 4 + length > buffer.length) continue;
|
||||
const value = buffer.subarray(offset + 4, offset + 3 + length);
|
||||
if (buffer[offset + 3 + length] !== 0 || value.includes(0)) continue;
|
||||
try {
|
||||
const name = new TextDecoder("utf-8", { fatal: true }).decode(value);
|
||||
names.set(id, name);
|
||||
return name;
|
||||
} catch { /* continue past metadata */ }
|
||||
}
|
||||
throw new Error(`Could not decode English name for source ID ${id}.`);
|
||||
}
|
||||
|
||||
// The quest rewards are source-linked. Suit matches are curated from the
|
||||
// source's armor category, required levels, names, and suit membership. Package
|
||||
// expansion itself is not present in these DB records and remains unverified.
|
||||
const milestones = [
|
||||
{ level: 50, questId: 424596, choices: [
|
||||
["plate", 240384, 240385, 240386, 610363],
|
||||
["chain", 240387, 240388, 240389, 610364],
|
||||
["leather", 240390, 240391, 240392, 610365],
|
||||
["cloth", 240393, 240394, 240395, 610366],
|
||||
] },
|
||||
{ level: 55, questId: 424597, choices: [
|
||||
["plate", 240353, 240354, 240355, 610395],
|
||||
["chain", 240356, 240357, 240358, 610396],
|
||||
["leather", 240359, 240360, 240361, 610397],
|
||||
["cloth", 240362, 240363, 240364, 610398],
|
||||
] },
|
||||
{ level: 60, questId: 424598, choices: [
|
||||
["plate", 240341, 240342, 240343, 610391],
|
||||
["chain", 240344, 240345, 240346, 610392],
|
||||
["leather", 240347, 240348, 240349, 610393],
|
||||
["cloth", 240350, 240351, 240352, 610394],
|
||||
] },
|
||||
{ level: 65, questId: 424213, choices: [
|
||||
["plate", 240304, 240302, 240303, 610387],
|
||||
["chain", 240307, 240305, 240306, 610388],
|
||||
["leather", 240310, 240308, 240309, 610389],
|
||||
["cloth", 240313, 240311, 240312, 610390],
|
||||
] },
|
||||
];
|
||||
|
||||
const extracted = milestones.map(({ level, questId, choices }) => {
|
||||
const quest = record("questdetailobject.db", questId);
|
||||
if (quest.readInt32LE(0xc0) !== level) throw new Error(`Quest ${questId} level drifted.`);
|
||||
const rewardIds = Array.from({ length: 4 }, (_, index) => quest.readInt32LE(0x34c + index * 4));
|
||||
const packages = choices.map(([armorCategory, packageId, weaponPackageId, armorPackageId, matchingSuitId], index) => {
|
||||
if (rewardIds[index] !== packageId) throw new Error(`Quest ${questId} reward order drifted.`);
|
||||
for (const id of [packageId, weaponPackageId, armorPackageId]) record("itemobject.db", id);
|
||||
const suit = record("suitobject.db", matchingSuitId);
|
||||
const matchingArmorItems = Array.from({ length: 10 }, (_, member) => suit.readInt32LE(1224 + member * 4))
|
||||
.filter((id) => id > 0)
|
||||
.map((itemId) => {
|
||||
const item = record("armorobject.db", itemId);
|
||||
if (item.readInt32LE(0x1a8) !== matchingSuitId) {
|
||||
throw new Error(`Armor ${itemId} lost suit ${matchingSuitId}.`);
|
||||
}
|
||||
const requiredLevel = item.readInt32LE(0x78);
|
||||
if (requiredLevel < 1 || requiredLevel > level) {
|
||||
throw new Error(`Armor ${itemId} has unexpected required level ${requiredLevel}.`);
|
||||
}
|
||||
return { itemId, name: sourceName(itemId), requiredLevel };
|
||||
});
|
||||
if (matchingArmorItems.length !== 7) throw new Error(`Suit ${matchingSuitId} should have seven armor pieces.`);
|
||||
return {
|
||||
armorCategory,
|
||||
packageId,
|
||||
packageName: sourceName(packageId),
|
||||
weaponPackageId,
|
||||
weaponPackageName: sourceName(weaponPackageId),
|
||||
armorPackageId,
|
||||
armorPackageName: sourceName(armorPackageId),
|
||||
matchingSuitId,
|
||||
matchingSuitName: sourceName(matchingSuitId),
|
||||
matchingArmorItems,
|
||||
};
|
||||
});
|
||||
return { level, questId, questName: sourceName(questId), packages };
|
||||
});
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
contentStatus: "quest-rewards-confirmed-package-expansion-unverified",
|
||||
sourceFiles: Object.fromEntries(files.map((name) => [name, createHash("sha256").update(buffers[name]).digest("hex")])),
|
||||
milestones: extracted,
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Exported ${extracted.length} RoM leveling milestones and ${extracted.flatMap((entry) => entry.packages).length} package choices.`);
|
||||
+24
-4
@@ -699,16 +699,29 @@ export function openGameDatabase(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function onlineGroupCharacterSource(character) {
|
||||
return character.classId.startsWith("rom-") || character.categoryId === "rom" ? "rom" : "wow";
|
||||
}
|
||||
|
||||
function updateOnlineGroupMember(accountId, character, role) {
|
||||
const group = groupMembership(accountId);
|
||||
if (!group) throw new GameDatabaseError("Join or create a group first.", 409, "group_required");
|
||||
if (!["tank", "healer", "damage"].includes(role)) {
|
||||
throw new GameDatabaseError("Choose a valid party role.", 400, "invalid_party_role");
|
||||
}
|
||||
const selectedCharacter = normalizeGroupCharacter(character);
|
||||
const otherMembers = database.prepare(`
|
||||
SELECT character_json FROM online_group_members
|
||||
WHERE group_id = ? AND account_id != ? AND character_json IS NOT NULL
|
||||
`).all(group.id, accountId);
|
||||
if (otherMembers.some((member) => onlineGroupCharacterSource(JSON.parse(member.character_json))
|
||||
!== onlineGroupCharacterSource(selectedCharacter))) {
|
||||
throw new GameDatabaseError("RoM characters cannot join a WoW group, or vice versa.", 409, "mixed_group_sources");
|
||||
}
|
||||
database.prepare(`
|
||||
UPDATE online_group_members SET character_json = ?, role = ?
|
||||
WHERE group_id = ? AND account_id = ?
|
||||
`).run(JSON.stringify(normalizeGroupCharacter(character)), role, group.id, accountId);
|
||||
`).run(JSON.stringify(selectedCharacter), role, group.id, accountId);
|
||||
database.prepare("UPDATE online_groups SET updated_at = ? WHERE id = ?").run(Date.now(), group.id);
|
||||
return onlineGroupState(accountId);
|
||||
}
|
||||
@@ -739,19 +752,26 @@ export function openGameDatabase(options = {}) {
|
||||
if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) {
|
||||
throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready");
|
||||
}
|
||||
if (new Set(members.map((member) => onlineGroupCharacterSource(JSON.parse(member.character_json)))).size > 1) {
|
||||
throw new GameDatabaseError("RoM characters cannot join a WoW group, or vice versa.", 409, "mixed_group_sources");
|
||||
}
|
||||
const romGroup = onlineGroupCharacterSource(JSON.parse(members[0].character_json)) === "rom";
|
||||
const randomizedBiteSized = type === "dungeon"
|
||||
&& contentId === "randomized-bite-sized-stormwind-stockade";
|
||||
const fillWithAi = randomizedBiteSized || input?.fillWithAi !== false;
|
||||
if (romGroup && randomizedBiteSized) {
|
||||
throw new GameDatabaseError("This WoW-only activity is unavailable to RoM groups.", 409, "group_source_incompatible");
|
||||
}
|
||||
const fillWithAi = !romGroup && (randomizedBiteSized || input?.fillWithAi !== false);
|
||||
if (input?.includeBetaSpecs !== undefined && typeof input.includeBetaSpecs !== "boolean") {
|
||||
throw new GameDatabaseError("includeBetaSpecs must be boolean.", 400, "invalid_beta_preference");
|
||||
}
|
||||
const includeBetaSpecs = randomizedBiteSized ? false : input?.includeBetaSpecs === true;
|
||||
const includeBetaSpecs = romGroup || randomizedBiteSized ? false : input?.includeBetaSpecs === true;
|
||||
const requestedSize = randomizedBiteSized
|
||||
? 5
|
||||
: Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
|
||||
const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
|
||||
const readiness = getCompanionSpecReadiness();
|
||||
const companionSpecializationIds = Object.entries(readiness.config.statuses)
|
||||
const companionSpecializationIds = romGroup ? [] : Object.entries(readiness.config.statuses)
|
||||
.filter(([, status]) => status === "ready" || (includeBetaSpecs && status === "beta"))
|
||||
.map(([specializationId]) => specializationId);
|
||||
const activity = {
|
||||
|
||||
@@ -320,6 +320,37 @@ test("creates online groups through invitations and transfers leadership when th
|
||||
assert.deepEqual(transferred.group.members.map((member) => member.username), ["GroupHealer"]);
|
||||
}));
|
||||
|
||||
test("keeps RoM and WoW characters in separate online groups", () => withDatabase(async (database) => {
|
||||
const leaderAuth = await database.register("SourceLeader", "group-pass");
|
||||
const memberAuth = await database.register("SourceMember", "member-pass");
|
||||
const leader = database.authenticate(leaderAuth.token);
|
||||
const member = database.authenticate(memberAuth.token);
|
||||
database.inviteOnlineGroupMember(leader, member.username);
|
||||
const invite = database.onlineGroupState(member.id).invitations[0];
|
||||
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
||||
|
||||
const wowCharacter = { id: "wow-priest", name: "Wow Priest", classId: "priest", categoryId: "wow", level: 20 };
|
||||
const romCharacter = { id: "rom-priest", name: "RoM Priest", classId: "rom-priest", categoryId: "rom", level: 20 };
|
||||
database.updateOnlineGroupMember(leader.id, wowCharacter, "healer");
|
||||
assert.throws(() => database.updateOnlineGroupMember(member.id, romCharacter, "healer"),
|
||||
(error) => error instanceof GameDatabaseError && error.code === "mixed_group_sources");
|
||||
assert.equal(database.onlineGroupState(member.id).group.members[1].character, null);
|
||||
|
||||
database.updateOnlineGroupMember(leader.id, romCharacter, "healer");
|
||||
assert.throws(() => database.updateOnlineGroupMember(member.id, wowCharacter, "healer"),
|
||||
(error) => error instanceof GameDatabaseError && error.code === "mixed_group_sources");
|
||||
database.updateOnlineGroupMember(member.id, { ...romCharacter, id: "rom-mage", name: "RoM Mage", classId: "rom-mage" }, "damage");
|
||||
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "randomized-bite-sized-stormwind-stockade", fillWithAi: true, partySize: 5,
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "group_source_incompatible");
|
||||
const started = database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||
});
|
||||
assert.equal(started.group.activity.partySize, 2);
|
||||
assert.equal(started.group.activity.fillWithAi, false);
|
||||
assert.deepEqual(started.group.activity.companionSpecializationIds, []);
|
||||
}));
|
||||
|
||||
test("launches players-only and AI-filled group activities only when every member is ready", () => withDatabase(async (database) => {
|
||||
const leaderAuth = await database.register("ReadyLeader", "group-pass");
|
||||
const memberAuth = await database.register("ReadyMember", "member-pass");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { installDungeonShellRuntime } from "./dungeonShellRuntime";
|
||||
import { installManastormShellRuntime } from "./manastormShellRuntime";
|
||||
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
|
||||
import { dungeonById } from "../game/dungeonCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { createEmptyManastormProgress, recordManastormCheckpoint } from "../game/manastormProgress";
|
||||
@@ -94,6 +95,7 @@ describe("shell store", () => {
|
||||
selectedCharacterId: null,
|
||||
activeCharacter: null,
|
||||
activeDungeonRole: "healer",
|
||||
dungeonSelectionId: null,
|
||||
notice: "",
|
||||
});
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
@@ -135,6 +137,24 @@ describe("shell store", () => {
|
||||
expect(useGameStore.getState().distanceTravelled).toBe(42);
|
||||
});
|
||||
|
||||
it("opens random full and authored Bite-Sized dungeons in the existing selector", () => {
|
||||
prepareSelectedCharacter();
|
||||
useShellStore.getState().openMainMenu();
|
||||
|
||||
expect(useShellStore.getState().openDungeons("random")).toBe(true);
|
||||
const fullDungeon = dungeonById(useShellStore.getState().dungeonSelectionId ?? "");
|
||||
expect(fullDungeon?.available).toBe(true);
|
||||
expect(["wow", "rom"]).toContain(fullDungeon?.category);
|
||||
|
||||
useShellStore.getState().returnToMainMenu();
|
||||
expect(useShellStore.getState().openDungeons("random-bite-sized")).toBe(true);
|
||||
expect(useShellStore.getState().dungeonSelectionId).toBe("bite-sized-stormwind-stockade");
|
||||
|
||||
useShellStore.getState().returnToMainMenu();
|
||||
expect(useShellStore.getState().openDungeons()).toBe(true);
|
||||
expect(useShellStore.getState().dungeonSelectionId).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the game and resets it only after choosing the available dungeon", () => {
|
||||
prepareSelectedCharacter();
|
||||
useShellStore.getState().openMainMenu();
|
||||
|
||||
+16
-3
@@ -38,6 +38,7 @@ import {
|
||||
import type { ContentCategoryId } from "./contentCategories";
|
||||
import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from "./types";
|
||||
import type { DungeonId } from "../game/dungeonRegistry";
|
||||
import { randomDungeonByCategory, randomRegularDungeon } from "../game/dungeonCatalog";
|
||||
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
@@ -77,6 +78,7 @@ export interface ShellState {
|
||||
draft: CharacterDraft;
|
||||
notice: string;
|
||||
activeDungeonId: DungeonId;
|
||||
dungeonSelectionId: DungeonId | null;
|
||||
activeDungeonRole: PartyRole;
|
||||
combatPresentationSettings: CombatPresentationSettings;
|
||||
beginSession: (session: PlayerSession) => void;
|
||||
@@ -90,7 +92,7 @@ export interface ShellState {
|
||||
createDraftCharacter: () => boolean;
|
||||
deleteSelectedCharacter: () => void;
|
||||
openMainMenu: () => boolean;
|
||||
openDungeons: () => boolean;
|
||||
openDungeons: (selection?: "random" | "random-bite-sized") => boolean;
|
||||
openManastorms: () => boolean;
|
||||
openArenas: () => boolean;
|
||||
openGmAdmin: () => boolean;
|
||||
@@ -166,6 +168,7 @@ const restored = restoredState();
|
||||
export const useShellStore = create<ShellState>((set, get) => ({
|
||||
...restored,
|
||||
activeDungeonId: DEFAULT_DUNGEON_ID,
|
||||
dungeonSelectionId: null,
|
||||
activeDungeonRole: "healer",
|
||||
activeCharacter: null,
|
||||
draft: defaultDraft(),
|
||||
@@ -319,7 +322,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: "main-menu", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openDungeons: () => {
|
||||
openDungeons: (selection) => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
|
||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||
@@ -330,7 +333,17 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "dungeons", activeCharacter: null, notice: "" });
|
||||
const dungeon = selection === "random" ? randomRegularDungeon()
|
||||
: selection === "random-bite-sized" ? randomDungeonByCategory("bite-sized")
|
||||
: null;
|
||||
if (selection && !dungeon) {
|
||||
set({ notice: selection === "random"
|
||||
? "No regular dungeon is ready to enter."
|
||||
: "No regular Bite-Sized dungeon is ready to enter." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "dungeons", activeCharacter: null, dungeonSelectionId: dungeon?.id ?? null,
|
||||
notice: dungeon ? `Randomly selected ${dungeon.title}.` : "" });
|
||||
return true;
|
||||
},
|
||||
openManastorms: () => {
|
||||
|
||||
@@ -157,7 +157,7 @@ export interface AbilityDamageResource {
|
||||
}
|
||||
|
||||
export type AbilityEffect =
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| {
|
||||
readonly kind: "finisher-damage";
|
||||
readonly baseCoefficient: number;
|
||||
|
||||
@@ -14,7 +14,7 @@ const CONE_ABILITIES = new Set([
|
||||
"cone of cold", "dragon's breath", "dragons breath", "shockwave", "cleave",
|
||||
"swipe", "swipe (cat)", "whirlwind",
|
||||
]);
|
||||
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce"]);
|
||||
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce", "blind spot", "sneak attack"]);
|
||||
const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker's grace"]);
|
||||
const EMERGENCY_HEAL_ABILITIES = new Set([
|
||||
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
|
||||
@@ -71,6 +71,11 @@ export function canAbilityCastWhileMoving(ability: AbilityDefinition): boolean {
|
||||
return ability.castRules?.canCastWhileMoving ?? MOVEMENT_CAST_ABILITIES.has(ability.name.toLowerCase());
|
||||
}
|
||||
|
||||
export function abilityRequiresOutOfCombat(ability: AbilityDefinition): boolean {
|
||||
return ability.source === "runewaker"
|
||||
&& /\bcannot be used (?:when already )?in combat\b/i.test(ability.description);
|
||||
}
|
||||
|
||||
export function abilityPushbackResistance(ability: AbilityDefinition): number {
|
||||
if (ability.castRules?.pushbackImmune) return 1;
|
||||
return Math.max(0, Math.min(1, ability.castRules?.pushbackResistance ?? 0));
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
type TalentUiTree,
|
||||
} from "../talentUiCatalog";
|
||||
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
|
||||
import { romFiveIdentity } from "../romFiveIdentities";
|
||||
import { ROM_SECONDARY_UNLOCK_LEVEL } from "../../app/romDualClass";
|
||||
import {
|
||||
READY_COMPANION_SPECIALIZATION_IDS,
|
||||
readinessStatusFor,
|
||||
@@ -401,8 +403,11 @@ function romBuild(
|
||||
role: PartyRole,
|
||||
level: number,
|
||||
): BenchmarkBuildDefinition {
|
||||
const identity = romFiveIdentity(classId, role);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, role, level),
|
||||
secondaryClassId: identity && level >= ROM_SECONDARY_UNLOCK_LEVEL ? identity.secondaryClassId : null,
|
||||
...(identity ? { raceId: identity.raceId, characterName: identity.name } : {}),
|
||||
orientation: "Primary",
|
||||
talentRanks: EMPTY_TALENT_RANKS,
|
||||
});
|
||||
|
||||
@@ -279,6 +279,7 @@ export async function runCombatBenchmark(
|
||||
.find((specialization) => specialization.displayName === build.orientation)?.id;
|
||||
combat.initializeCharacter({
|
||||
classId: build.classId,
|
||||
raceId: build.raceId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
specializationId,
|
||||
level: build.level,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClassId } from "../../app/characterCatalog";
|
||||
import type { ClassId, RaceId } from "../../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../app/contentCategories";
|
||||
import type { EquipmentAssignments } from "../equipment";
|
||||
import type { ItemStats } from "../itemStats";
|
||||
@@ -47,6 +47,7 @@ export interface BenchmarkBuildDefinition {
|
||||
readonly id: string;
|
||||
readonly source: ContentCategoryId;
|
||||
readonly classId: ClassId;
|
||||
readonly raceId?: RaceId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
|
||||
@@ -24,7 +24,7 @@ const scenario: BenchmarkScenarioDefinition = {
|
||||
export async function runDungeonBossBenchmark(
|
||||
build: BenchmarkBuildDefinition, bossId: string, seed: string, mechanics = false,
|
||||
) {
|
||||
await preloadAbilityCatalogsForClasses([build.classId]);
|
||||
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
@@ -56,7 +56,7 @@ export async function runDungeonBossBenchmark(
|
||||
});
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({ classId: build.classId, secondaryClassId: build.secondaryClassId,
|
||||
combat.initializeCharacter({ classId: build.classId, raceId: build.raceId, secondaryClassId: build.secondaryClassId,
|
||||
specializationId: specialization?.id, role: build.role, level: build.level,
|
||||
talentRanks: build.talentRanks, inventory: build.inventory, equipment: build.equipment });
|
||||
combat.setPlayerPosition([-2, 0, 0]);
|
||||
|
||||
@@ -50,10 +50,10 @@ describe("boss card rewards", () => {
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
|
||||
it.each([1, 40, 79, 80])("preserves non-equipment while allowing equipment only at 80 (level %i)", (level) => {
|
||||
it.each([1, 40, 79, 80])("grants boss items only after leveling (level %i)", (level) => {
|
||||
const item = { instanceId: "i", itemId: 1, itemKey: "item", name: "Item", quality: "rare" as const, inventorySlot: "waist", armorType: "cloth", weaponType: null, itemLevel: level, sourceItemLevel: 1, stats: EMPTY_ITEM_STATS, quantity: 1, sourceBossKey: "boss", sourceBossName: "Boss", acquiredAt: 1 };
|
||||
const misc = { ...item, instanceId: "m", inventorySlot: "misc" };
|
||||
expect(allowedLootDrops([item, misc], level)).toEqual(level === 80 ? [item, misc] : [misc]);
|
||||
expect(allowedLootDrops([item, misc], level)).toEqual(level === 80 ? [item, misc] : []);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-1
@@ -142,6 +142,8 @@ export interface AuraDefinition {
|
||||
/** Null means the aura persists until explicitly removed. */
|
||||
readonly durationMs: number | null;
|
||||
readonly maxStacks?: number;
|
||||
/** Initial stack count for charged buffs such as Inner Fire. */
|
||||
readonly initialStacks?: number;
|
||||
readonly stackBehavior?: AuraStackBehavior;
|
||||
/** Defaults to true. False makes applications from all sources share one aura. */
|
||||
readonly uniqueBySource?: boolean;
|
||||
@@ -172,6 +174,8 @@ export interface ActiveAura {
|
||||
readonly stacks: number;
|
||||
readonly absorbs: readonly AuraAbsorbState[];
|
||||
readonly procStates: readonly AuraProcState[];
|
||||
/** Number of Fire critical hits landed during the current Combustion buff. */
|
||||
readonly combustionCriticalHits?: number;
|
||||
}
|
||||
|
||||
export interface ApplyAuraRequest {
|
||||
@@ -319,13 +323,18 @@ export function applyAura(
|
||||
): AuraApplicationResult {
|
||||
const { definition } = request;
|
||||
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
||||
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
||||
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks ?? definition.initialStacks, 1));
|
||||
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
|
||||
// Mage armors have several DBC effects that coexist until another armor
|
||||
// replaces them. Other exclusive groups retain their single-aura behavior.
|
||||
const exclusiveSpellName = definition.name.replace(/, Aura \d+\)$/, ")");
|
||||
const exclusive = definition.exclusiveGroup
|
||||
? current.filter((aura) => (
|
||||
aura.targetId === request.targetId
|
||||
&& aura.definition.exclusiveGroup === definition.exclusiveGroup
|
||||
&& aura.instanceId !== instanceId
|
||||
&& (definition.exclusiveGroup !== "mage:armor"
|
||||
|| aura.definition.name.replace(/, Aura \d+\)$/, ")") !== exclusiveSpellName)
|
||||
))
|
||||
: [];
|
||||
const exclusiveIds = new Set(exclusive.map((aura) => aura.instanceId));
|
||||
|
||||
@@ -140,6 +140,20 @@ describe("combat presentation", () => {
|
||||
expect(originalCombatPhaseAssets(entry, "release")).toEqual([]);
|
||||
});
|
||||
|
||||
it("plays the original Holy Nova burst once at impact", () => {
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
path.resolve("public/assets/game/combat-effects/manifest.json"),
|
||||
"utf8",
|
||||
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
|
||||
for (const spellId of [15237, 15430, 15431, 27799, 27800, 27801, 25331, 48077, 48078]) {
|
||||
const entry = manifest.entries.find((candidate) => candidate.spellId === spellId && candidate.source === "wow335");
|
||||
expect(originalCombatPhaseAssets(entry, "cast-start", "wow335-priest-holy-nova")).toEqual([]);
|
||||
expect(originalCombatPhaseAssets(entry, "release", "wow335-priest-holy-nova")).toEqual([]);
|
||||
expect(originalCombatPhaseAssets(entry, "impact", "wow335-priest-holy-nova").map((asset) => asset.model))
|
||||
.toEqual(["spells/holynova_impact_base.mdx"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every source-backed instant WoW rank on a renderable phase", () => {
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
path.resolve("public/assets/game/combat-effects/manifest.json"),
|
||||
|
||||
@@ -247,8 +247,13 @@ export function combatEffectManifestEntry(
|
||||
export function originalCombatPhaseAssets(
|
||||
entry: CombatEffectManifestEntry | null | undefined,
|
||||
phase: CombatPresentationPhase,
|
||||
abilityId?: string,
|
||||
): readonly CombatEffectPhaseAsset[] {
|
||||
return (entry?.phases?.[phase] ?? []).filter((asset) => (
|
||||
const sourcePhase = abilityId === "wow335-priest-holy-nova" && phase === "impact"
|
||||
? "cast-start"
|
||||
: phase;
|
||||
if (abilityId === "wow335-priest-holy-nova" && phase !== "impact") return [];
|
||||
return (entry?.phases?.[sourcePhase] ?? []).filter((asset) => (
|
||||
typeof asset.effectGraphUrl === "string"
|
||||
&& asset.effectGraphUrl.startsWith("/assets/game/combat-effects/original/")
|
||||
&& (
|
||||
@@ -257,15 +262,20 @@ export function originalCombatPhaseAssets(
|
||||
|| asset.effectKind === "wow-m2-static-mesh"
|
||||
|| asset.effectKind === "runewaker-particles"
|
||||
)
|
||||
&& (abilityId !== "wow335-priest-holy-nova"
|
||||
|| /(?:^|\/)holynova_impact_base\.mdx$/i.test(asset.model ?? ""))
|
||||
));
|
||||
}
|
||||
|
||||
export function combatOriginalPhaseAssets(
|
||||
event: Pick<CombatPresentationEvent, "phase" | "source" | "sourceSpellId">,
|
||||
event: Pick<CombatPresentationEvent, "phase" | "source" | "sourceSpellId" | "abilityId">,
|
||||
): readonly CombatEffectPhaseAsset[] {
|
||||
// The imported Holy Nova instant kit puts the burst alongside two precast
|
||||
// flashes. Route only that burst to impact so it plays once per cast.
|
||||
return originalCombatPhaseAssets(
|
||||
combatEffectManifestEntry(event.sourceSpellId, event.source),
|
||||
event.phase,
|
||||
event.abilityId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -562,7 +562,10 @@ function scoreAbility(
|
||||
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
|
||||
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
|
||||
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
|
||||
if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
|
||||
// Shiv may still be needed to add a missing poison dose at five points;
|
||||
// partyRuntime's dose gate decides whether that maintenance hit is useful.
|
||||
if (isComboBuilder(ability) && ability.name !== "Shiv"
|
||||
&& context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
|
||||
|
||||
let score = hints.priority ?? 0;
|
||||
let ruleId = "semantic-fallback";
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
} from "./combatStore";
|
||||
import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { abilitiesForClass, abilityAtLevel, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { defaultActionBindings } from "./actionBindings";
|
||||
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
import { applyAura as applyCombatAura } from "./combatAuras";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
@@ -50,6 +52,13 @@ function registerTarget(
|
||||
useCombatStore.getState().selectMob(id);
|
||||
}
|
||||
|
||||
function unlockReadySpell(id: string): void {
|
||||
const ability = useCombatStore.getState().abilities.find((entry) => entry.id === id);
|
||||
expect(ability, id).toBeDefined();
|
||||
const nodeId = abilityTalentNodeId(ability!);
|
||||
if (nodeId) useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId]: 1 } }));
|
||||
}
|
||||
|
||||
function setPartyMemberHealth(id: string, health: number): void {
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === id
|
||||
@@ -198,6 +207,34 @@ describe("combat store", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("damages nearby enemies and heals nearby allies with one Holy Nova cast", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
registerTarget("nova-near", [3, 0, 0], 100_000, 0);
|
||||
useCombatStore.getState().registerMob("nova-far", { name: "nova-far", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("nova-far", [15, 0, 0]);
|
||||
const nearbyAlly = preparePartyMember(0, [3, 0, 0], 1);
|
||||
const start = useCombatStore.getState();
|
||||
useCombatStore.setState({ health: start.maxHealth - 1_000 });
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event" && message.event.abilityId === "wow335-priest-holy-nova") {
|
||||
events.push(message.event);
|
||||
}
|
||||
});
|
||||
|
||||
const result = useCombatStore.getState().castAbility("wow335-priest-holy-nova", [0, 0, 0], 1_000);
|
||||
unsubscribe();
|
||||
|
||||
expect(result).toMatchObject({ ok: true, affectedMobIds: ["nova-near"] });
|
||||
expect(result.damage).toBeGreaterThan(0);
|
||||
expect(result.healing).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().mobs["nova-near"].health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().mobs["nova-far"].health).toBe(100_000);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(start.maxHealth - 1_000);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === nearbyAlly)?.health).toBeGreaterThan(1);
|
||||
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
|
||||
});
|
||||
|
||||
it("identifies the player as the target of a self-cast aura presentation", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
@@ -1734,4 +1771,309 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().mobs["storm-boss"].pendingLoot).toHaveLength(0);
|
||||
expect(useCombatStore.getState().mobs["storm-boss"].bossCardDrop?.rarities).toContain("normal");
|
||||
});
|
||||
|
||||
it("stacks Sunder through Devastate and grants Last Stand temporary maximum health", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
|
||||
unlockReadySpell("wow335-warrior-devastate");
|
||||
unlockReadySpell("wow335-warrior-last-stand");
|
||||
useCombatStore.setState((state) => ({ resource: 100, resourcePools: { ...state.resourcePools, rage: 100 } }));
|
||||
registerTarget("sunder-target", [2, 0, 0], 100_000, 0);
|
||||
expect(useCombatStore.getState().castAbility("wow335-warrior-devastate", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "sunder-target"
|
||||
&& aura.definition.id === "wow335:warrior:sunder-armor")?.stacks).toBe(1);
|
||||
expect(useCombatStore.getState().castAbility("wow335-warrior-devastate", [0, 0, 0], 3_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "sunder-target"
|
||||
&& aura.definition.id === "wow335:warrior:sunder-armor")?.stacks).toBe(2);
|
||||
const before = useCombatStore.getState().maxHealth;
|
||||
expect(useCombatStore.getState().castAbility("wow335-warrior-last-stand", [0, 0, 0], 5_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().maxHealth).toBeGreaterThan(before * 1.29);
|
||||
useCombatStore.getState().tick(0, 24_999);
|
||||
expect(useCombatStore.getState().maxHealth).toBeGreaterThan(before * 1.29);
|
||||
useCombatStore.getState().tick(0, 26_000);
|
||||
expect(useCombatStore.getState().maxHealth).toBe(before);
|
||||
});
|
||||
|
||||
it("requires and consumes Enrage for Enraged Regeneration", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
|
||||
unlockReadySpell("wow335-warrior-enraged-regeneration");
|
||||
useCombatStore.setState((state) => ({ resource: 100, resourcePools: { ...state.resourcePools, rage: 100 } }));
|
||||
expect(useCombatStore.getState().castAbility("wow335-warrior-enraged-regeneration", [0, 0, 0], 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
const enrage = applyCombatAura(useCombatStore.getState().auras, {
|
||||
definition: { id: "test:enrage", name: "Enrage", disposition: "buff", dispelCategory: "enrage", durationMs: 12_000 },
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
});
|
||||
useCombatStore.setState({ auras: enrage.auras });
|
||||
expect(useCombatStore.getState().castAbility("wow335-warrior-enraged-regeneration", [0, 0, 0], 2_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "test:enrage")).toBe(false);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
|
||||
&& effect.sourceAbilityId === "wow335-warrior-enraged-regeneration")).toBe(true);
|
||||
});
|
||||
|
||||
it("prevents recasting Power Word: Shield until Weakened Soul expires", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === "wow335:priest:weakened-soul")?.expiresAt)
|
||||
.toBe(31_000);
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 10_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
useCombatStore.getState().tick(0, 31_000);
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 31_000).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("grants Inner Fire armor for 20 landed physical hits", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-inner-fire", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const charges = () => useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.name.startsWith("Inner Fire (DBC ") && aura.definition.modifiers?.some((modifier) => (
|
||||
modifier.kind === "stat" && modifier.stat === "armor"
|
||||
)))?.stacks;
|
||||
expect(charges()).toBe(20);
|
||||
registerTarget("inner-fire-attacker", [2, 0, 0], 100_000, 0);
|
||||
useCombatStore.getState().damageMob("inner-fire-attacker", 1, 1_000);
|
||||
for (let now = 1_450; now < 15_000 && charges() === 20; now += 1_900) {
|
||||
useCombatStore.getState().advanceMobCombat(now);
|
||||
}
|
||||
expect(charges()).toBe(19);
|
||||
expect(useCombatStore.getState().lastCombatResult?.effectiveArmor).toBeGreaterThan(2_000);
|
||||
});
|
||||
|
||||
it("makes Evasion reduce incoming ranged hit chance", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
||||
expect(useCombatStore.getState().castAbility("wow335-rogue-evasion", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
useCombatStore.getState().registerMob("evasion-attacker", {
|
||||
name: "Evasion attacker", level: 80, maxHealth: 100_000, xpReward: 0,
|
||||
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "evasion-arrow", delivery: "projectile", range: 30 }],
|
||||
});
|
||||
setMobPosition("evasion-attacker", [8, 0, 0]);
|
||||
useCombatStore.getState().damageMob("evasion-attacker", 1, 1_000);
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
expect(useCombatStore.getState().lastCombatResult?.attackTable.missPercent).toBe(30);
|
||||
});
|
||||
|
||||
it("summons one Shadowfiend and restores mana only after its attacks", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
registerTarget("fiend-target", [3, 0, 0], 100_000, 0);
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: Math.round(state.maxResource / 2),
|
||||
resourcePools: { ...state.resourcePools, mana: Math.round(state.maxResource / 2) },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-shadowfiend", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().summons.filter((summon) => summon.sourceAbilityId === "wow335-priest-shadowfiend"))
|
||||
.toHaveLength(1);
|
||||
const manaBeforeAttack = useCombatStore.getState().resource;
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
expect(useCombatStore.getState().mobs["fiend-target"].health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().resource).toBeGreaterThanOrEqual(manaBeforeAttack + Math.floor(useCombatStore.getState().maxResource * 0.01));
|
||||
});
|
||||
|
||||
it("casts Kill Shot only below 20 percent health and resolves one direct hit", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
||||
registerTarget("kill-shot-target", [10, 0, 0], 100_000, 0);
|
||||
expect(useCombatStore.getState().castAbility("wow335-hunter-kill-shot", [0, 0, 0], 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
useCombatStore.setState((state) => ({ mobs: {
|
||||
...state.mobs,
|
||||
"kill-shot-target": { ...state.mobs["kill-shot-target"], health: 20_000 },
|
||||
} }));
|
||||
expect(useCombatStore.getState().castAbility("wow335-hunter-kill-shot", [0, 0, 0], 3_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
||||
&& event.abilityId === "wow335-hunter-kill-shot")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("summons three Mirror Images and gives Combustion Fire hit stacks", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
unlockReadySpell("wow335-mage-combustion");
|
||||
registerTarget("fire-target", [8, 0, 0], 100_000, 0);
|
||||
expect(useCombatStore.getState().castAbility("wow335-mage-mirror-image", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().summons.filter((summon) => summon.sourceAbilityId === "wow335-mage-mirror-image"))
|
||||
.toHaveLength(3);
|
||||
expect(useCombatStore.getState().castAbility("wow335-mage-combustion", [0, 0, 0], 3_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().mobs["fire-target"].health).toBe(100_000);
|
||||
const combustionId = "wow335:mage:combustion";
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === combustionId)?.stacks).toBe(1);
|
||||
const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!;
|
||||
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 5_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === combustionId)?.stacks).toBe(2);
|
||||
useCombatStore.setState((state) => ({ auras: state.auras.map((aura) => aura.definition.id === combustionId
|
||||
? { ...aura, stacks: 100, combustionCriticalHits: 2 }
|
||||
: aura) }));
|
||||
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 14_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === combustionId)).toBe(false);
|
||||
});
|
||||
|
||||
it("scales Rupture duration and ticks with combo points", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
||||
registerTarget("rupture-target", [2, 0, 0], 100_000, 0);
|
||||
useCombatStore.setState({ comboPoints: 1, comboTargetId: "rupture-target" });
|
||||
expect(useCombatStore.getState().castAbility("wow335-rogue-rupture", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === "wow335-rogue-rupture")?.remainingTicks)
|
||||
.toBe(4);
|
||||
const firstAura = useCombatStore.getState().auras.find((aura) => aura.targetId === "rupture-target"
|
||||
&& aura.definition.name.startsWith("Rupture"));
|
||||
expect(firstAura?.expiresAt).toBe(9_000);
|
||||
useCombatStore.setState({ comboPoints: 5, comboTargetId: "rupture-target", resource: 100 });
|
||||
expect(useCombatStore.getState().castAbility("wow335-rogue-rupture", [0, 0, 0], 3_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === "wow335-rogue-rupture")?.remainingTicks)
|
||||
.toBe(8);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "rupture-target"
|
||||
&& aura.definition.name.startsWith("Rupture"))?.expiresAt).toBe(19_000);
|
||||
});
|
||||
|
||||
it("lets Penance damage an enemy and continue pulsing", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
unlockReadySpell("wow335-priest-penance");
|
||||
registerTarget("penance-target", [8, 0, 0], 100_000, 0);
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const first = useCombatStore.getState().mobs["penance-target"].health;
|
||||
expect(first).toBeLessThan(100_000);
|
||||
useCombatStore.getState().tick(0, 2_000);
|
||||
expect(useCombatStore.getState().mobs["penance-target"].health).toBeLessThan(first);
|
||||
useCombatStore.getState().tick(0, 3_000);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "wow335-priest-penance")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets Penance heal a friendly target immediately and twice afterward", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
unlockReadySpell("wow335-priest-penance");
|
||||
usePartyStore.getState().selectSelf();
|
||||
useCombatStore.getState().damagePlayer(2_000);
|
||||
const before = useCombatStore.getState().health;
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const first = useCombatStore.getState().health;
|
||||
expect(first).toBeGreaterThan(before);
|
||||
useCombatStore.getState().tick(0, 2_000);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(first);
|
||||
useCombatStore.getState().tick(0, 3_000);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "wow335-priest-penance")).toBe(false);
|
||||
});
|
||||
|
||||
it("selects only the three lowest-health nearby Divine Hymn targets per two-second pulse", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
usePartyStore.getState().initializeParty("hymn-cap-test", 80, useCombatStore.getState().health);
|
||||
const ids = [0, 1, 2, 3].map((index) => preparePartyMember(index,
|
||||
[4 + index * 2, 0, 0], index + 1));
|
||||
const before = usePartyStore.getState().members
|
||||
.filter((member) => ids.includes(member.id))
|
||||
.map((member) => ({ id: member.id, health: member.health, fraction: member.health / member.maxHealth }))
|
||||
.sort((left, right) => left.fraction - right.fraction || left.id.localeCompare(right.id));
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-divine-hymn", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().activeCast?.totalTicks).toBe(4);
|
||||
useCombatStore.getState().tick(0, 3_000);
|
||||
const members = usePartyStore.getState().members;
|
||||
expect(before.slice(0, 3).every((entry) => members.find((member) => member.id === entry.id)!.health > entry.health)).toBe(true);
|
||||
expect(members.find((member) => member.id === before[3].id)!.health).toBe(before[3].health);
|
||||
});
|
||||
|
||||
it("refreshes Serpent Sting and triggers its Chimera Shot damage", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
||||
unlockReadySpell("wow335-hunter-chimera-shot");
|
||||
registerTarget("sting-target", [10, 0, 0], 100_000, 0);
|
||||
const stingId = useCombatStore.getState().abilities.find((ability) => ability.name === "Serpent Sting")!.id;
|
||||
expect(useCombatStore.getState().castAbility(stingId, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const dotBefore = useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === stingId);
|
||||
expect(dotBefore).toBeDefined();
|
||||
expect(useCombatStore.getState().castAbility("wow335-hunter-chimera-shot", [0, 0, 0], 3_000).ok).toBe(true);
|
||||
const dotAfter = useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === stingId);
|
||||
expect(dotAfter?.nextTickAt).toBe(3_000 + dotBefore!.intervalMs);
|
||||
expect(dotAfter?.remainingTicks).toBeGreaterThanOrEqual(dotBefore!.remainingTicks);
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "wow335-hunter-chimera-shot"
|
||||
&& event.kind === "damage")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("restores mana from Viper Sting and limits Scorpid Sting disarm to once per minute", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
||||
registerTarget("sting-utility-target", [10, 0, 0], 100_000, 0);
|
||||
const viperId = useCombatStore.getState().abilities.find((ability) => ability.name === "Viper Sting")!.id;
|
||||
expect(useCombatStore.getState().castAbility(viperId, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: state.maxResource / 2,
|
||||
resourcePools: { ...state.resourcePools, mana: state.maxResource / 2 },
|
||||
}));
|
||||
const manaBefore = useCombatStore.getState().resource;
|
||||
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "sting-utility-target", "wow335-hunter-chimera-shot", 2_000);
|
||||
expect(useCombatStore.getState().resource).toBeGreaterThan(manaBefore);
|
||||
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
||||
registerTarget("scorpid-target", [10, 0, 0], 100_000, 0);
|
||||
const scorpidId = useCombatStore.getState().abilities.find((ability) => ability.name === "Scorpid Sting")!.id;
|
||||
expect(useCombatStore.getState().castAbility(scorpidId, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "scorpid-target", "wow335-hunter-chimera-shot", 2_000);
|
||||
const disarm = useCombatStore.getState().mobs["scorpid-target"].statuses.find((status) => status.kind === "disarm");
|
||||
expect(disarm?.endsAt).toBe(12_000);
|
||||
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "scorpid-target", "wow335-hunter-chimera-shot", 3_000);
|
||||
expect(useCombatStore.getState().mobs["scorpid-target"].statuses.find((status) => status.kind === "disarm")?.endsAt).toBe(12_000);
|
||||
});
|
||||
|
||||
it("explodes Living Bomb onto nearby enemies when its last tick completes", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
unlockReadySpell("wow335-mage-living-bomb");
|
||||
registerTarget("bomb-target", [5, 0, 0], 100_000, 0);
|
||||
useCombatStore.getState().registerMob("bomb-neighbor", { name: "bomb-neighbor", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("bomb-neighbor", [7, 0, 0]);
|
||||
expect(useCombatStore.getState().castAbility("wow335-mage-living-bomb", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().mobs["bomb-neighbor"].health).toBe(100_000);
|
||||
useCombatStore.getState().tick(0, 13_000);
|
||||
expect(useCombatStore.getState().mobs["bomb-neighbor"].health).toBeLessThan(100_000);
|
||||
});
|
||||
|
||||
it("detonates Living Bomb once when its magic aura is dispelled", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
unlockReadySpell("wow335-mage-living-bomb");
|
||||
registerTarget("dispel-bomb", [5, 0, 0], 100_000, 0);
|
||||
useCombatStore.getState().registerMob("dispel-neighbor", { name: "dispel-neighbor", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("dispel-neighbor", [7, 0, 0]);
|
||||
expect(useCombatStore.getState().castAbility("wow335-mage-living-bomb", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().dispelActor("dispel-bomb", ["magic"], 1, 2_000)).toBe(1);
|
||||
expect(useCombatStore.getState().mobs["dispel-neighbor"].health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.targetId === "dispel-bomb"
|
||||
&& effect.sourceAbilityId === "wow335-mage-living-bomb")).toBe(false);
|
||||
});
|
||||
|
||||
it("casts Molten Armor on self without initial damage", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
registerTarget("armor-target", [8, 0, 0], 100_000, 0);
|
||||
expect(useCombatStore.getState().castAbility("wow335-mage-molten-armor", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().mobs["armor-target"].health).toBe(100_000);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.name.startsWith("Molten Armor"))).toBe(true);
|
||||
});
|
||||
|
||||
it("applies Molten Armor's Spirit crit conversion and incoming crit reduction", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
registerTarget("molten-crit-target", [8, 0, 0], 100_000, 0);
|
||||
const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!;
|
||||
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 3_000).ok).toBe(true);
|
||||
const baseCrit = useCombatStore.getState().lastCombatResult!.attackTable.criticalPercent;
|
||||
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
registerTarget("molten-crit-target", [8, 0, 0], 100_000, 0);
|
||||
expect(useCombatStore.getState().castAbility("wow335-mage-molten-armor", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 3_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().lastCombatResult!.attackTable.criticalPercent).toBeGreaterThan(baseCrit);
|
||||
|
||||
useCombatStore.getState().registerMob("molten-attacker", {
|
||||
name: "Molten attacker", level: 80, maxHealth: 100_000, xpReward: 0,
|
||||
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "molten-arrow", delivery: "projectile", range: 30 }],
|
||||
});
|
||||
setMobPosition("molten-attacker", [8, 0, 0]);
|
||||
useCombatStore.getState().damageMob("molten-attacker", 1, 3_000);
|
||||
useCombatStore.getState().advanceMobCombat(3_450);
|
||||
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBe(0);
|
||||
});
|
||||
|
||||
it("awards Shiv's combo point and consumes Cold Blood on the next strike", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
||||
unlockReadySpell("wow335-rogue-cold-blood");
|
||||
registerTarget("rogue-target", [2, 0, 0], 100_000, 0);
|
||||
expect(useCombatStore.getState().castAbility("wow335-rogue-shiv", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().comboPoints).toBe(1);
|
||||
expect(useCombatStore.getState().castAbility("wow335-rogue-cold-blood", [0, 0, 0], 3_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("next-offensive-critical"))).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", [0, 0, 0], 5_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("next-offensive-critical"))).toBe(false);
|
||||
expect(useCombatStore.getState().lastCombatResult?.outcome).toBe("crit");
|
||||
});
|
||||
});
|
||||
+567
-45
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ import {
|
||||
dungeonById,
|
||||
dungeonsByCategory,
|
||||
randomDungeonByCategory,
|
||||
randomRegularDungeon,
|
||||
randomizedBiteSizedDungeons,
|
||||
randomRandomizedBiteSizedDungeon,
|
||||
} from "./dungeonCatalog";
|
||||
@@ -61,6 +62,17 @@ describe("dungeon catalog", () => {
|
||||
.not.toBe("stormwind-stockade");
|
||||
});
|
||||
|
||||
it("selects regular full dungeons without entering either Bite-Sized pool", () => {
|
||||
const first = randomRegularDungeon(undefined, () => 0);
|
||||
const last = randomRegularDungeon(undefined, () => 0.999999);
|
||||
for (const dungeon of [first, last]) {
|
||||
expect(dungeon?.available).toBe(true);
|
||||
expect(["wow", "rom"]).toContain(dungeon?.category);
|
||||
}
|
||||
expect(randomRegularDungeon(first?.id, () => 0)?.id).not.toBe(first?.id);
|
||||
expect(randomRegularDungeon(undefined, () => 0, () => false)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps randomized replay in the separate randomized Bite-Sized location pool", () => {
|
||||
expect(randomizedBiteSizedDungeons().map((dungeon) => dungeon.id))
|
||||
.toEqual(["randomized-bite-sized-stormwind-stockade"]);
|
||||
|
||||
@@ -103,6 +103,20 @@ export function randomDungeonByCategory(
|
||||
return randomAvailableDungeon(dungeonsByCategory(category), currentDungeonId, random, eligible);
|
||||
}
|
||||
|
||||
/** Selects an enterable full-length WoW or RoM dungeon, excluding both Bite-Sized modes. */
|
||||
export function randomRegularDungeon(
|
||||
currentDungeonId?: string,
|
||||
random: () => number = Math.random,
|
||||
eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true,
|
||||
): DungeonCatalogEntry | null {
|
||||
return randomAvailableDungeon(
|
||||
DUNGEON_CATALOG.filter((dungeon) => dungeon.category !== "bite-sized"),
|
||||
currentDungeonId,
|
||||
random,
|
||||
eligible,
|
||||
);
|
||||
}
|
||||
|
||||
/** Selects the next location without crossing from randomized mode into authored Bite-Sized mode. */
|
||||
export function randomRandomizedBiteSizedDungeon(
|
||||
currentDungeonId?: string,
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contentStatus": "quest-rewards-confirmed-package-expansion-unverified",
|
||||
"sourceFiles": {
|
||||
"questdetailobject.db": "52950d1df2e0094144064298ab36554b2db424acfb23d142cce7d3087e4cfa7f",
|
||||
"itemobject.db": "00e082b4c18d561d29c893a6400bfe62e3daf90ae539728cb05d1c2893a69880",
|
||||
"armorobject.db": "a2f0017c4772165d562ebb08ca0ac51625cdc4453e3670a1501df7d741d2bad3",
|
||||
"suitobject.db": "9c9b0f83f4c6a26fda1bade4c8771f95ec5d6d93da3998d187f14abfb840dbef",
|
||||
"string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
|
||||
},
|
||||
"milestones": [
|
||||
{
|
||||
"level": 50,
|
||||
"questId": 424596,
|
||||
"questName": "Appropriate Support",
|
||||
"packages": [
|
||||
{
|
||||
"armorCategory": "plate",
|
||||
"packageId": 240384,
|
||||
"packageName": "Order of Dark Glory Gift (Plate package)",
|
||||
"weaponPackageId": 240385,
|
||||
"weaponPackageName": "Plate Weapons Package",
|
||||
"armorPackageId": 240386,
|
||||
"armorPackageName": "Plate Armor Package",
|
||||
"matchingSuitId": 610363,
|
||||
"matchingSuitName": "Issac's Protection",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 228366,
|
||||
"name": "Battle Boots of Earth Exploration",
|
||||
"requiredLevel": 41
|
||||
},
|
||||
{
|
||||
"itemId": 228370,
|
||||
"name": "Shoulder Guards of Shadow Lurking",
|
||||
"requiredLevel": 41
|
||||
},
|
||||
{
|
||||
"itemId": 228589,
|
||||
"name": "Victory Horn Helmet",
|
||||
"requiredLevel": 50
|
||||
},
|
||||
{
|
||||
"itemId": 228605,
|
||||
"name": "Smile of the Dead Plate Pants",
|
||||
"requiredLevel": 45
|
||||
},
|
||||
{
|
||||
"itemId": 228617,
|
||||
"name": "Killer Instinct Belt",
|
||||
"requiredLevel": 43
|
||||
},
|
||||
{
|
||||
"itemId": 228621,
|
||||
"name": "Adieu Gloves",
|
||||
"requiredLevel": 48
|
||||
},
|
||||
{
|
||||
"itemId": 228627,
|
||||
"name": "Reservist's Plate Armor",
|
||||
"requiredLevel": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "chain",
|
||||
"packageId": 240387,
|
||||
"packageName": "Order of Dark Glory Gift (Chain package)",
|
||||
"weaponPackageId": 240388,
|
||||
"weaponPackageName": "Chain Weapons Package",
|
||||
"armorPackageId": 240389,
|
||||
"armorPackageName": "Chain Armor Package",
|
||||
"matchingSuitId": 610364,
|
||||
"matchingSuitName": "Issac's Blessing",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 228365,
|
||||
"name": "Battle Boots of Crossroad Guidance",
|
||||
"requiredLevel": 41
|
||||
},
|
||||
{
|
||||
"itemId": 228369,
|
||||
"name": "Shoulder Guards of Suspicions",
|
||||
"requiredLevel": 41
|
||||
},
|
||||
{
|
||||
"itemId": 228588,
|
||||
"name": "Bloodbath Chainmail Helmet",
|
||||
"requiredLevel": 50
|
||||
},
|
||||
{
|
||||
"itemId": 228604,
|
||||
"name": "Murmuring Bones Chainmail Pants",
|
||||
"requiredLevel": 45
|
||||
},
|
||||
{
|
||||
"itemId": 228616,
|
||||
"name": "Conscripts Belt",
|
||||
"requiredLevel": 43
|
||||
},
|
||||
{
|
||||
"itemId": 228620,
|
||||
"name": "Parting Gloves",
|
||||
"requiredLevel": 48
|
||||
},
|
||||
{
|
||||
"itemId": 228626,
|
||||
"name": "Messenger's Chainmail",
|
||||
"requiredLevel": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "leather",
|
||||
"packageId": 240390,
|
||||
"packageName": "Order of Dark Glory Gift (Leather package)",
|
||||
"weaponPackageId": 240391,
|
||||
"weaponPackageName": "Leather Weapons Package",
|
||||
"armorPackageId": 240392,
|
||||
"armorPackageName": "Leather Armor Package",
|
||||
"matchingSuitId": 610365,
|
||||
"matchingSuitName": "Issac's Power",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 228364,
|
||||
"name": "Leather Boots of Path Guiding Wind",
|
||||
"requiredLevel": 41
|
||||
},
|
||||
{
|
||||
"itemId": 228368,
|
||||
"name": "Shoulder Guards of Dense Fog",
|
||||
"requiredLevel": 41
|
||||
},
|
||||
{
|
||||
"itemId": 228587,
|
||||
"name": "Arrowbreak Tiara",
|
||||
"requiredLevel": 50
|
||||
},
|
||||
{
|
||||
"itemId": 228603,
|
||||
"name": "Boneless Leather Pants",
|
||||
"requiredLevel": 45
|
||||
},
|
||||
{
|
||||
"itemId": 228615,
|
||||
"name": "Desperate Valor Leather Belt",
|
||||
"requiredLevel": 43
|
||||
},
|
||||
{
|
||||
"itemId": 228619,
|
||||
"name": "Entomb Touch",
|
||||
"requiredLevel": 48
|
||||
},
|
||||
{
|
||||
"itemId": 228625,
|
||||
"name": "Gardener's Dirty Leather Shirt",
|
||||
"requiredLevel": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "cloth",
|
||||
"packageId": 240393,
|
||||
"packageName": "Order of Dark Glory Gift (Cloth package)",
|
||||
"weaponPackageId": 240394,
|
||||
"weaponPackageName": "Cloth Weapons Package",
|
||||
"armorPackageId": 240395,
|
||||
"armorPackageName": "Cloth Armor Package",
|
||||
"matchingSuitId": 610366,
|
||||
"matchingSuitName": "Issac's Prayer",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 228363,
|
||||
"name": "Boots of Memory's Road",
|
||||
"requiredLevel": 41
|
||||
},
|
||||
{
|
||||
"itemId": 228367,
|
||||
"name": "Shoulder Armor of Dark Shadows",
|
||||
"requiredLevel": 41
|
||||
},
|
||||
{
|
||||
"itemId": 228586,
|
||||
"name": "Thinking Cap",
|
||||
"requiredLevel": 50
|
||||
},
|
||||
{
|
||||
"itemId": 228602,
|
||||
"name": "Revelation Long Pants",
|
||||
"requiredLevel": 45
|
||||
},
|
||||
{
|
||||
"itemId": 228614,
|
||||
"name": "Call of the Frontline Belt",
|
||||
"requiredLevel": 43
|
||||
},
|
||||
{
|
||||
"itemId": 228618,
|
||||
"name": "Pacification Gloves",
|
||||
"requiredLevel": 48
|
||||
},
|
||||
{
|
||||
"itemId": 228624,
|
||||
"name": "Inferno Gardens Guard Robe",
|
||||
"requiredLevel": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": 55,
|
||||
"questId": 424597,
|
||||
"questName": "Rewards for Students",
|
||||
"packages": [
|
||||
{
|
||||
"armorCategory": "plate",
|
||||
"packageId": 240353,
|
||||
"packageName": "Bodo Treasure (Plate Package)",
|
||||
"weaponPackageId": 240354,
|
||||
"weaponPackageName": "Plate Weapons Package",
|
||||
"armorPackageId": 240355,
|
||||
"armorPackageName": "Plate Armor Package",
|
||||
"matchingSuitId": 610395,
|
||||
"matchingSuitName": "Haz Set",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229252,
|
||||
"name": "Hazka Helmet",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229253,
|
||||
"name": "Hazka Plate Gauntlets",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229254,
|
||||
"name": "Hazka Boots",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229255,
|
||||
"name": "Hazka Plate Armor",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229256,
|
||||
"name": "Hazka Plate Leggings",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229257,
|
||||
"name": "Hazka Belt",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229258,
|
||||
"name": "Hazka Plate Shoulderplates",
|
||||
"requiredLevel": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "chain",
|
||||
"packageId": 240356,
|
||||
"packageName": "Bodo Treasure (Chain Package)",
|
||||
"weaponPackageId": 240357,
|
||||
"weaponPackageName": "Chain Weapons Package",
|
||||
"armorPackageId": 240358,
|
||||
"armorPackageName": "Chain Armor Package",
|
||||
"matchingSuitId": 610396,
|
||||
"matchingSuitName": "Eduth Set",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229259,
|
||||
"name": "Eduth Light Helm",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229260,
|
||||
"name": "Eduth Chain Gloves",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229261,
|
||||
"name": "Eduth Guard Boots",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229262,
|
||||
"name": "Eduth Chainmail",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229263,
|
||||
"name": "Eduth Chainmail Pants",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229264,
|
||||
"name": "Eduth Belt",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229265,
|
||||
"name": "Eduth Chain Shoulderplates",
|
||||
"requiredLevel": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "leather",
|
||||
"packageId": 240359,
|
||||
"packageName": "Bodo Treasure (Leather Package)",
|
||||
"weaponPackageId": 240360,
|
||||
"weaponPackageName": "Leather Weapons Package",
|
||||
"armorPackageId": 240361,
|
||||
"armorPackageName": "Leather Armor Package",
|
||||
"matchingSuitId": 610397,
|
||||
"matchingSuitName": "Luzan Set",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229266,
|
||||
"name": "Luzan Leather Helmet",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229267,
|
||||
"name": "Luzan Leather Gloves",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229268,
|
||||
"name": "Luzan Footwear",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229269,
|
||||
"name": "Luzan Leather Armor",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229270,
|
||||
"name": "Luzan Leather Pants",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229271,
|
||||
"name": "Luzan Belt",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229272,
|
||||
"name": "Luzan Leather Shoulder Armor",
|
||||
"requiredLevel": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "cloth",
|
||||
"packageId": 240362,
|
||||
"packageName": "Bodo Treasure (Cloth Package)",
|
||||
"weaponPackageId": 240363,
|
||||
"weaponPackageName": "Cloth Weapons Package",
|
||||
"armorPackageId": 240364,
|
||||
"armorPackageName": "Cloth Armor Package",
|
||||
"matchingSuitId": 610398,
|
||||
"matchingSuitName": "Rufa Set",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229273,
|
||||
"name": "Rufa Headdress",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229274,
|
||||
"name": "Rufa Handguards",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229275,
|
||||
"name": "Rufa Cloth Shoes",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229276,
|
||||
"name": "Rufa Robes",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229277,
|
||||
"name": "Rufa Leggings",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229278,
|
||||
"name": "Rufa Belt",
|
||||
"requiredLevel": 55
|
||||
},
|
||||
{
|
||||
"itemId": 229279,
|
||||
"name": "Rufa Cloth Shoulder Guards",
|
||||
"requiredLevel": 55
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": 60,
|
||||
"questId": 424598,
|
||||
"questName": "Lance's Support",
|
||||
"packages": [
|
||||
{
|
||||
"armorCategory": "plate",
|
||||
"packageId": 240341,
|
||||
"packageName": "Jungle Vanquisher (Plate Package)",
|
||||
"weaponPackageId": 240342,
|
||||
"weaponPackageName": "Plate Weapons Package",
|
||||
"armorPackageId": 240343,
|
||||
"armorPackageName": "Plate Armor Package",
|
||||
"matchingSuitId": 610391,
|
||||
"matchingSuitName": "Rh'anka Set",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229206,
|
||||
"name": "Rh'anka Helmet",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229207,
|
||||
"name": "Rh'anka Plate Gauntlets",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229208,
|
||||
"name": "Rh'anka Boots",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229209,
|
||||
"name": "Rh'anka Plate Armor",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229210,
|
||||
"name": "Rh'anka Plate Leggings",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229211,
|
||||
"name": "Rh'anka Belt",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229212,
|
||||
"name": "Rh'anka Plate Shoulderplates",
|
||||
"requiredLevel": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "chain",
|
||||
"packageId": 240344,
|
||||
"packageName": "Jungle Vanquisher (Chain Package)",
|
||||
"weaponPackageId": 240345,
|
||||
"weaponPackageName": "Chain Weapons Package",
|
||||
"armorPackageId": 240346,
|
||||
"armorPackageName": "Chain Armor Package",
|
||||
"matchingSuitId": 610392,
|
||||
"matchingSuitName": "Sley Set",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229213,
|
||||
"name": "Sley Light Helm",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229214,
|
||||
"name": "Sley Chain Gloves",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229215,
|
||||
"name": "Sley Chain Boots",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229216,
|
||||
"name": "Sley Chainmail",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229217,
|
||||
"name": "Sley Chainmail Pants",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229218,
|
||||
"name": "Sley Belt",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229219,
|
||||
"name": "Sley Chain Shoulderplates",
|
||||
"requiredLevel": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "leather",
|
||||
"packageId": 240347,
|
||||
"packageName": "Jungle Vanquisher (Leather Package)",
|
||||
"weaponPackageId": 240348,
|
||||
"weaponPackageName": "Leather Weapons Package",
|
||||
"armorPackageId": 240349,
|
||||
"armorPackageName": "Leather Armor Package",
|
||||
"matchingSuitId": 610393,
|
||||
"matchingSuitName": "Tempest Set",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229220,
|
||||
"name": "Gale Leather Helmet",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229221,
|
||||
"name": "Gale Leather Gloves",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229222,
|
||||
"name": "Gale Footwear",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229223,
|
||||
"name": "Gale Leather Armor",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229224,
|
||||
"name": "Gale Leather Pants",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229225,
|
||||
"name": "Gale Belt",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229226,
|
||||
"name": "Gale Leather Shoulder Armor",
|
||||
"requiredLevel": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "cloth",
|
||||
"packageId": 240350,
|
||||
"packageName": "Jungle Vanquisher (Cloth Package)",
|
||||
"weaponPackageId": 240351,
|
||||
"weaponPackageName": "Cloth Weapons Package",
|
||||
"armorPackageId": 240352,
|
||||
"armorPackageName": "Cloth Armor Package",
|
||||
"matchingSuitId": 610394,
|
||||
"matchingSuitName": "Croso Set",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229227,
|
||||
"name": "Croso Headdress",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229228,
|
||||
"name": "Croso Handguards",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229229,
|
||||
"name": "Croso Cloth Shoes",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229230,
|
||||
"name": "Croso Robes",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229231,
|
||||
"name": "Croso Leggings",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229232,
|
||||
"name": "Croso Belt",
|
||||
"requiredLevel": 60
|
||||
},
|
||||
{
|
||||
"itemId": 229233,
|
||||
"name": "Croso Cloth Shoulder Guards",
|
||||
"requiredLevel": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": 65,
|
||||
"questId": 424213,
|
||||
"questName": "Return to Dalanis",
|
||||
"packages": [
|
||||
{
|
||||
"armorCategory": "plate",
|
||||
"packageId": 240304,
|
||||
"packageName": "Redhill Mountains Pioneer (Plate package)",
|
||||
"weaponPackageId": 240302,
|
||||
"weaponPackageName": "Plate Weapons Package",
|
||||
"armorPackageId": 240303,
|
||||
"armorPackageName": "Plate Armor Package",
|
||||
"matchingSuitId": 610387,
|
||||
"matchingSuitName": "Forbidden Modification Technique",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229140,
|
||||
"name": "Transformation Helmet",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229141,
|
||||
"name": "Transformation Gauntlets",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229142,
|
||||
"name": "Transformation Long Boots",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229143,
|
||||
"name": "Transformation Plate Armor",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229144,
|
||||
"name": "Transformation Plate Pants",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229145,
|
||||
"name": "Transformation Belt",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229146,
|
||||
"name": "Transformation Shoulder Armor",
|
||||
"requiredLevel": 65
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "chain",
|
||||
"packageId": 240307,
|
||||
"packageName": "Redhill Mountains Pioneer (Chain package)",
|
||||
"weaponPackageId": 240305,
|
||||
"weaponPackageName": "Chain Weapons Package",
|
||||
"armorPackageId": 240306,
|
||||
"armorPackageName": "Chain Armor Package",
|
||||
"matchingSuitId": 610388,
|
||||
"matchingSuitName": "Forsaken Wrath",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229147,
|
||||
"name": "Forsaken Light Helm",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229148,
|
||||
"name": "Forsaken Chain Gloves",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229149,
|
||||
"name": "Forsaken Foot Guards",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229150,
|
||||
"name": "Forsaken Chain Armor",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229151,
|
||||
"name": "Forsaken Chain Pants",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229152,
|
||||
"name": "Forsaken Belt",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229153,
|
||||
"name": "Forsaken Chain Shoulders",
|
||||
"requiredLevel": 65
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "leather",
|
||||
"packageId": 240310,
|
||||
"packageName": "Redhill Mountains Pioneer (Leather package)",
|
||||
"weaponPackageId": 240308,
|
||||
"weaponPackageName": "Leather Weapons Package",
|
||||
"armorPackageId": 240309,
|
||||
"armorPackageName": "Leather Armor Package",
|
||||
"matchingSuitId": 610389,
|
||||
"matchingSuitName": "Forgotten Loathing",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229154,
|
||||
"name": "Forgotten Leather Helmet",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229155,
|
||||
"name": "Forgotten Gloves",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229156,
|
||||
"name": "Forgotten Shoes",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229157,
|
||||
"name": "Forgotten Leather Coat",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229158,
|
||||
"name": "Forgotten Leather Leggings",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229159,
|
||||
"name": "Forgotten Belt",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229160,
|
||||
"name": "Forgotten Leather Shoulder Armor",
|
||||
"requiredLevel": 65
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"armorCategory": "cloth",
|
||||
"packageId": 240313,
|
||||
"packageName": "Redhill Mountains Pioneer (Cloth package)",
|
||||
"weaponPackageId": 240311,
|
||||
"weaponPackageName": "Cloth Weapons Package",
|
||||
"armorPackageId": 240312,
|
||||
"armorPackageName": "Cloth Armor Package",
|
||||
"matchingSuitId": 610390,
|
||||
"matchingSuitName": "Lake Energy",
|
||||
"matchingArmorItems": [
|
||||
{
|
||||
"itemId": 229161,
|
||||
"name": "Headdress of the Lake",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229162,
|
||||
"name": "Hand Guards of the Lake",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229163,
|
||||
"name": "Cloth Boots of the Lake",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229164,
|
||||
"name": "Magic Robe of the Lake",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229165,
|
||||
"name": "Magic Leggings of the Lake",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229166,
|
||||
"name": "Belt of the Lake",
|
||||
"requiredLevel": 65
|
||||
},
|
||||
{
|
||||
"itemId": 229167,
|
||||
"name": "Cloth Shoulder Guards of the Lake",
|
||||
"requiredLevel": 65
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { equipmentSlotForItem } from "./equipment";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
|
||||
/** Heirlooms cover leveling gear. Keep authored non-equipment rewards at every level. */
|
||||
/** Leveling dungeon bosses award cards through the separate boss-card path. */
|
||||
export function allowedLootDrops(drops: readonly InventoryItem[], playerLevel: number): readonly InventoryItem[] {
|
||||
return Number.isFinite(playerLevel) && playerLevel >= 80
|
||||
? drops
|
||||
: drops.filter((item) => equipmentSlotForItem(item) === null);
|
||||
: [];
|
||||
}
|
||||
@@ -270,5 +270,5 @@ export function partyAbilityAmount(
|
||||
member.gearStats,
|
||||
member.classId,
|
||||
ability,
|
||||
);
|
||||
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
|
||||
}
|
||||
+143
-18
@@ -13,6 +13,7 @@ import {
|
||||
} from "./abilityCatalog";
|
||||
import {
|
||||
aiHintsForAbility,
|
||||
abilityRequiresOutOfCombat,
|
||||
canAbilityCastWhileMoving,
|
||||
targetingRulesForAbility,
|
||||
} from "./abilityRules";
|
||||
@@ -21,7 +22,7 @@ import { useCombatStore } from "./combatStore";
|
||||
import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
|
||||
import type { MobVector3 } from "./mobPopulation";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { getMobPosition } from "./mobRuntimeRegistry";
|
||||
import { getMobPosition, getMobRuntimeTransform } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
getPartyRuntimePosition,
|
||||
partyRuntimeIsRejoining,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
type WotlkAttackKind,
|
||||
} from "./wotlkStats";
|
||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
||||
import {
|
||||
emitCombatPresentation,
|
||||
presentationDeliveryForAbility,
|
||||
@@ -79,6 +81,7 @@ import {
|
||||
ROGUE_DEADLY_POISON_DOSE_TAG,
|
||||
ROGUE_ENVENOM_BUFF_TAG,
|
||||
} from "./roguePoison";
|
||||
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics";
|
||||
|
||||
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
|
||||
const ATTACK_ACQUISITION_RANGE = 30;
|
||||
@@ -753,6 +756,34 @@ function partyAbilityTargetIsValid(
|
||||
if (castOrigin && !canAbilityCastWhileMoving(ability) && distanceSquared(origin, castOrigin) > 0.04) return false;
|
||||
const mob = combat.mobs[targetId];
|
||||
const hostile = Boolean(mob);
|
||||
if (abilityRequiresOutOfCombat(ability)
|
||||
&& !(ability.source === "runewaker" && member.classId === "rom-rogue"
|
||||
&& combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("rom-vanish")))
|
||||
&& Object.values(combat.mobs).some((candidate) => candidate.engaged && !candidate.dead)) return false;
|
||||
if (ability.name === "Kill Shot" && mob && mob.health / Math.max(1, mob.maxHealth) > 0.2) return false;
|
||||
if (ability.name === "Power Word: Shield" && combat.auras.some((aura) => aura.targetId === targetId
|
||||
&& aura.definition.id === "wow335:priest:weakened-soul")) return false;
|
||||
if (ability.name === "Enraged Regeneration" && !combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.disposition === "buff" && aura.definition.dispelCategory === "enrage"
|
||||
&& !aura.definition.name.startsWith("Enraged Regeneration"))) return false;
|
||||
if (ability.source === "runewaker") {
|
||||
if (ability.dbcSpellId === 490151 && (member.comboTargetId !== targetId || member.comboPoints <= 0)) return false;
|
||||
if (ability.dbcSpellId === 490347 && !combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("rom-poisonous"))) return false;
|
||||
if (ability.dbcSpellId === 491171 && !combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("rom-charged"))) return false;
|
||||
if (ability.dbcSpellId === 491172 && !combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("rom-static-field-charge"))) return false;
|
||||
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) {
|
||||
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
|
||||
ownerId: member.id, ownerKind: "party", classId: member.classId,
|
||||
specializationId: member.specializationId, role: member.role, level: member.level,
|
||||
});
|
||||
if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") return false;
|
||||
if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") return false;
|
||||
}
|
||||
}
|
||||
const targetPosition = hostile ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
|
||||
if (!targetPosition || mob?.dead) return false;
|
||||
const targetActor = combat.actors[targetId];
|
||||
@@ -1100,10 +1131,21 @@ function executePartyAbility(
|
||||
castStartAlreadyEmitted = false,
|
||||
): PartyAbilityExecutionResult {
|
||||
const combat = useCombatStore.getState();
|
||||
if (ability.name === "Enraged Regeneration" && !combat.consumeActorEnrage(member.id, now)) {
|
||||
return { member, effectiveAmount: 0 };
|
||||
}
|
||||
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
|
||||
const hostileTargets = combat.mobs[targetId] ? partyHostileTargets(ability, targetId, origin, member.id) : [];
|
||||
const friendlyTargets = combat.mobs[targetId] ? [] : partyHealingTargets(ability, targetId, members);
|
||||
const consumesColdBlood = ability.name !== "Cold Blood" && hostileTargets.length > 0
|
||||
&& ability.effects.some((effect) => ["damage", "dot", "finisher-damage"].includes(effect.kind))
|
||||
&& combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("next-offensive-critical"));
|
||||
const spellSchool = presentationSchoolForAbility(ability);
|
||||
const usesCombustion = spellSchool === "fire" && hostileTargets.length > 0
|
||||
&& ability.effects.some((effect) => effect.kind === "damage")
|
||||
&& combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("fire-crit-stacking"));
|
||||
const groundAreaCreated = targetingRulesForAbility(ability).shape === "ground"
|
||||
? Boolean(useCombatStore.getState().createActorAreaEffect(
|
||||
member.id,
|
||||
@@ -1148,12 +1190,41 @@ function executePartyAbility(
|
||||
}
|
||||
let runtimeMember = member;
|
||||
let effectiveAmount = 0;
|
||||
for (const effect of ability.effects) {
|
||||
const castEffects = romEffectsForCast(ability, {
|
||||
sealStacks: member.comboTargetId === targetId ? member.comboPoints : 0,
|
||||
bleedCount: romTargetBleedCount(combat.effects, member.id, targetId),
|
||||
charged: combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("rom-charged")),
|
||||
mainHandDagger: member.classId === "rom-rogue" && resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
|
||||
ownerId: member.id, ownerKind: "party", classId: member.classId,
|
||||
specializationId: member.specializationId, role: member.role, level: member.level,
|
||||
})["main-hand"]?.weaponType === "dagger",
|
||||
casterMaxHealth: member.maxHealth,
|
||||
});
|
||||
for (const effect of castEffects) {
|
||||
if (ability.name === "Penance" && combat.mobs[targetId]
|
||||
&& (effect.kind === "heal" || effect.kind === "hot")) continue;
|
||||
if (ability.name === "Penance" && !combat.mobs[targetId]
|
||||
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
|
||||
if (effect.kind === "damage" || effect.kind === "dot" || effect.kind === "finisher-damage") {
|
||||
if (effect.kind === "dot" && romSelfDrainPercent(ability.dbcSpellId) > 0) {
|
||||
combat.applyTimedActorEffect(member.id, member.id, ability, effect, now);
|
||||
continue;
|
||||
}
|
||||
for (const hostileTargetId of hostileTargets) {
|
||||
if (effect.kind === "dot") {
|
||||
if (!groundAreaCreated) combat.applyTimedActorEffect(member.id, hostileTargetId, ability, effect, now);
|
||||
if (!groundAreaCreated) combat.applyTimedActorEffect(
|
||||
member.id, hostileTargetId, ability,
|
||||
ability.name === "Rupture"
|
||||
? { ...effect, ticks: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) }
|
||||
: effect,
|
||||
now,
|
||||
);
|
||||
} else {
|
||||
const sunderStacks = effect.kind === "damage" && effect.bonusPerSunderStack
|
||||
? useCombatStore.getState().auras.find((aura) => aura.targetId === hostileTargetId
|
||||
&& aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
|
||||
: 0;
|
||||
const result = combat.damageMob(
|
||||
hostileTargetId,
|
||||
partyAbilityAmount(
|
||||
@@ -1161,10 +1232,16 @@ function executePartyAbility(
|
||||
ability,
|
||||
effect,
|
||||
envenomDosesByTarget.get(hostileTargetId) ?? 0,
|
||||
),
|
||||
) + sunderStacks * (effect.kind === "damage" ? effect.bonusPerSunderStack ?? 0 : 0),
|
||||
now,
|
||||
{ actorId: member.id, role: member.role, abilityId: ability.id },
|
||||
spellSchool,
|
||||
consumesColdBlood || usesCombustion ? {
|
||||
attackKind: consumesColdBlood ? "melee" : "spell", abilityId: ability.id,
|
||||
seed: `${member.id}:${ability.id}:${hostileTargetId}:${now}:special-crit`,
|
||||
criticalChanceBonusPercent: consumesColdBlood ? 100 : 0,
|
||||
...(ability.name === "Shiv" ? { rules: { canCrit: false } } : {}),
|
||||
} : undefined,
|
||||
);
|
||||
effectiveAmount += result.amount;
|
||||
if (
|
||||
@@ -1207,6 +1284,17 @@ function executePartyAbility(
|
||||
now,
|
||||
});
|
||||
effectiveAmount += result.effectiveAmount;
|
||||
if (ability.name === "Divine Hymn") {
|
||||
useCombatStore.getState().applyActorAura(member.id, friendlyTargetId, ability, {
|
||||
kind: "apply-aura", recipient: "ability-target",
|
||||
aura: {
|
||||
id: "wow335:priest:divine-hymn-healing", name: "Divine Hymn", disposition: "buff",
|
||||
durationMs: 8_000, maxStacks: 1, stackBehavior: "refresh",
|
||||
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: 0.03 }],
|
||||
},
|
||||
sourceEffectType: 6, sourceAuraType: 118, miscValue: 0, triggerSpellId: 0,
|
||||
}, now);
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "hot") {
|
||||
if (!groundAreaCreated) for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors,
|
||||
@@ -1254,10 +1342,11 @@ function executePartyAbility(
|
||||
}
|
||||
} else if (effect.kind === "status") {
|
||||
for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) {
|
||||
combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now);
|
||||
combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now,
|
||||
effect.breakOnDamage, member.id);
|
||||
}
|
||||
} else if (effect.kind === "apply-aura") {
|
||||
if (ability.name === "Inner Focus") continue;
|
||||
if (ability.name === "Inner Focus" || ability.name === "Divine Hymn") continue;
|
||||
const recipients = effect.scope === "party"
|
||||
? [
|
||||
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
|
||||
@@ -1267,7 +1356,9 @@ function executePartyAbility(
|
||||
? [member.id]
|
||||
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
|
||||
for (const recipientId of recipients) {
|
||||
const appliedEffect = ability.name === "Envenom"
|
||||
const appliedEffect = ability.name === "Rupture"
|
||||
? { ...effect, aura: { ...effect.aura, durationMs: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) * 2_000 } }
|
||||
: ability.name === "Envenom"
|
||||
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
|
||||
? {
|
||||
...effect,
|
||||
@@ -1316,8 +1407,15 @@ function executePartyAbility(
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "combo") {
|
||||
if (ability.dbcSpellId === 490151) {
|
||||
useCombatStore.setState((state) => ({
|
||||
effects: state.effects.filter((entry) => !(entry.kind === "dot"
|
||||
&& entry.tag === "rom-holy-seal" && entry.sourceActorId === member.id
|
||||
&& entry.targetId === targetId)),
|
||||
}));
|
||||
}
|
||||
const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0;
|
||||
const comboPoints = Math.max(0, Math.min(5, current + effect.amount));
|
||||
const comboPoints = Math.max(0, Math.min(member.classId === "rom-knight" ? 4 : 5, current + effect.amount));
|
||||
runtimeMember = {
|
||||
...runtimeMember,
|
||||
comboPoints,
|
||||
@@ -1345,7 +1443,21 @@ function executePartyAbility(
|
||||
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
|
||||
} else if (effect.kind === "movement") {
|
||||
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
|
||||
if (effect.toward === "away") {
|
||||
if (ability.dbcSpellId === 490344) {
|
||||
const target = getMobRuntimeTransform(targetId);
|
||||
if (target) updatePartyRuntimePosition(member.id,
|
||||
target.position[0] - Math.sin(target.yaw) * 1.5,
|
||||
target.position[1],
|
||||
target.position[2] - Math.cos(target.yaw) * 1.5);
|
||||
} else if (ability.dbcSpellId === 490150 && targetPosition) {
|
||||
const dx = targetPosition[0] - origin[0];
|
||||
const dz = targetPosition[2] - origin[2];
|
||||
const length = Math.max(0.001, Math.hypot(dx, dz));
|
||||
updatePartyRuntimePosition(member.id,
|
||||
targetPosition[0] - dx / length * 1.5,
|
||||
targetPosition[1],
|
||||
targetPosition[2] - dz / length * 1.5);
|
||||
} else if (effect.toward === "away") {
|
||||
const danger = Object.values(combat.mobs)
|
||||
.filter((mob) => mob.engaged && !mob.dead)
|
||||
.sort((left, right) => (
|
||||
@@ -1417,6 +1529,12 @@ function executePartyAbility(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ability.name === "Chimera Shot" && effectiveAmount > 0 && combat.mobs[targetId]) {
|
||||
effectiveAmount += useCombatStore.getState().triggerChimeraShot(member.id, targetId, ability.id, now);
|
||||
}
|
||||
if (consumesColdBlood) useCombatStore.getState().consumeActorAuraTag(member.id, "next-offensive-critical");
|
||||
if (ability.dbcSpellId === 491171) useCombatStore.getState().consumeActorAuraTag(member.id, "rom-charged");
|
||||
if (ability.dbcSpellId === 491172) useCombatStore.getState().consumeActorAuraTag(member.id, "rom-static-field-charge");
|
||||
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
|
||||
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
|
||||
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
|
||||
@@ -1602,16 +1720,23 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 };
|
||||
continue;
|
||||
}
|
||||
const interval = Math.max(1, cast.originalDurationMs / 5);
|
||||
const totalTicks = castingAbility.name === "Divine Hymn" ? 4 : 5;
|
||||
const interval = Math.max(1, cast.originalDurationMs / totalTicks);
|
||||
let completed = cast.channelTicksCompleted ?? 0;
|
||||
while (completed < 5 && cast.startedAt + (completed + 1) * interval <= now) {
|
||||
const effects: AbilityEffect[] = channelAmountEffects(castingAbility, 5).map(({ effect, multiplier }) => ({
|
||||
kind: effect.kind === "heal" || effect.kind === "hot" ? "heal" : "damage",
|
||||
coefficient: 0, basePoints: Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier),
|
||||
bonusPowerCoefficient: 0, sourceLevel: member.level,
|
||||
...(effect.kind === "heal" && effect.percentMaxHealth !== undefined
|
||||
? { percentMaxHealth: effect.percentMaxHealth * multiplier } : {}),
|
||||
}));
|
||||
while (completed < totalTicks && cast.startedAt + (completed + 1) * interval <= now) {
|
||||
const effects: AbilityEffect[] = channelAmountEffects(castingAbility, totalTicks).map(({ effect, multiplier }) => {
|
||||
const amount = Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier);
|
||||
if (effect.kind === "heal" || effect.kind === "hot") return {
|
||||
kind: "heal", coefficient: 0, basePoints: amount, sourceLevel: member.level,
|
||||
...("scope" in effect ? {
|
||||
scope: effect.scope, center: effect.center, radius: effect.radius,
|
||||
maximumTargets: effect.maximumTargets, recipient: effect.recipient,
|
||||
} : {}),
|
||||
...(effect.kind === "heal" && effect.percentMaxHealth !== undefined
|
||||
? { percentMaxHealth: effect.percentMaxHealth * multiplier } : {}),
|
||||
};
|
||||
return { kind: "damage", coefficient: 0, basePoints: amount, sourceLevel: member.level };
|
||||
});
|
||||
const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId,
|
||||
cast.startedAt + (completed + 1) * interval, nextMembers, 0, true);
|
||||
member = execution.member;
|
||||
|
||||
+30
-9
@@ -39,6 +39,7 @@ import {
|
||||
import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
|
||||
import type { EnemyControlMechanic } from "./enemyAttacks";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import { isValidRomClassPair, ROM_SECONDARY_UNLOCK_LEVEL } from "../app/romDualClass";
|
||||
import type { PartyRole, PartySize } from "./partyRoles";
|
||||
import {
|
||||
abilitiesForCharacter,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
type ResourceType,
|
||||
} from "./abilityCatalog";
|
||||
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
|
||||
import { romFiveIdentity } from "./romFiveIdentities";
|
||||
|
||||
export {
|
||||
classCanFillPartyRole,
|
||||
@@ -102,6 +104,8 @@ export interface PartyMember {
|
||||
interface PartyTemplate {
|
||||
readonly name: string;
|
||||
readonly classId: ClassId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly raceId?: RaceId;
|
||||
readonly specialization: string;
|
||||
readonly talentTreeId: string;
|
||||
readonly talentProfileId?: string | null;
|
||||
@@ -111,6 +115,7 @@ export interface ConfiguredPartyMemberSeed {
|
||||
readonly name: string;
|
||||
readonly classId: ClassId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly raceId?: RaceId;
|
||||
readonly specialization: string;
|
||||
readonly specializationId?: string | null;
|
||||
readonly talentTreeId?: string;
|
||||
@@ -167,19 +172,19 @@ function wowTemplates(
|
||||
|
||||
const ROM_TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
|
||||
{ name: "Aldric", classId: "rom-warrior", specialization: "Primary", talentTreeId: "" },
|
||||
{ ...romFiveIdentity("rom-knight", "tank")!, specialization: "Primary", talentTreeId: "" },
|
||||
{ name: "Maerwen", classId: "rom-warden", specialization: "Primary", talentTreeId: "" },
|
||||
{ name: "Bronn", classId: "rom-champion", specialization: "Primary", talentTreeId: "" },
|
||||
]);
|
||||
const ROM_HEALER_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
|
||||
{ name: "Liora", classId: "rom-priest", specialization: "Primary", talentTreeId: "" },
|
||||
{ ...romFiveIdentity("rom-priest", "healer")!, specialization: "Primary", talentTreeId: "" },
|
||||
{ name: "Sylva", classId: "rom-druid", specialization: "Primary", talentTreeId: "" },
|
||||
]);
|
||||
const ROM_DAMAGE_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
|
||||
{ name: "Cael", classId: "rom-scout", specialization: "Primary", talentTreeId: "" },
|
||||
{ name: "Veya", classId: "rom-rogue", specialization: "Primary", talentTreeId: "" },
|
||||
{ name: "Ilyen", classId: "rom-mage", specialization: "Primary", talentTreeId: "" },
|
||||
{ ...romFiveIdentity("rom-scout", "damage")!, specialization: "Primary", talentTreeId: "" },
|
||||
{ ...romFiveIdentity("rom-rogue", "damage")!, specialization: "Primary", talentTreeId: "" },
|
||||
{ ...romFiveIdentity("rom-mage", "damage")!, specialization: "Primary", talentTreeId: "" },
|
||||
{ name: "Morgha", classId: "rom-warlock", specialization: "Primary", talentTreeId: "" },
|
||||
{ name: "Roran", classId: "rom-knight", specialization: "Primary", talentTreeId: "" },
|
||||
]);
|
||||
|
||||
export const TANK_SPECIALIZATIONS = Object.freeze(new Set(
|
||||
@@ -322,11 +327,19 @@ function createMember(
|
||||
const identityRandom = {
|
||||
value: hashSeed(`${seed}:${role}:${index}:${template.classId}:${template.specialization}:appearance-v1`),
|
||||
};
|
||||
const eligibleRaces = PLAYABLE_RACES.filter((race) => classesForRace(race.id).some(
|
||||
(candidate) => candidate.id === template.classId,
|
||||
const eligibleRaces = PLAYABLE_RACES.filter((race) => (
|
||||
classesForRace(race.id).some((candidate) => candidate.id === template.classId)
|
||||
&& (!template.secondaryClassId || isValidRomClassPair(race.id, template.classId, template.secondaryClassId))
|
||||
));
|
||||
if (template.secondaryClassId && !eligibleRaces.length) {
|
||||
throw new Error(`No playable race supports ${template.classId}/${template.secondaryClassId}.`);
|
||||
}
|
||||
if (template.raceId && !eligibleRaces.some((race) => race.id === template.raceId)) {
|
||||
throw new Error(`Race ${template.raceId} cannot play ${template.classId}/${template.secondaryClassId ?? "none"}.`);
|
||||
}
|
||||
const racePool = eligibleRaces.length ? eligibleRaces : PLAYABLE_RACES;
|
||||
const race = racePool[Math.floor(nextRandom(identityRandom) * racePool.length)] ?? racePool[0];
|
||||
const race = racePool.find((candidate) => candidate.id === template.raceId)
|
||||
?? racePool[Math.floor(nextRandom(identityRandom) * racePool.length)] ?? racePool[0];
|
||||
const genders = supportedGenders(race.id);
|
||||
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
|
||||
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
|
||||
@@ -354,6 +367,7 @@ function createMember(
|
||||
gender,
|
||||
appearance,
|
||||
classId: template.classId,
|
||||
secondaryClassId: level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
|
||||
specialization: template.specialization,
|
||||
specializationId,
|
||||
talentTreeId: template.talentTreeId,
|
||||
@@ -398,13 +412,14 @@ export function createConfiguredPartyMember(
|
||||
const member = createMember({
|
||||
name: configuration.name,
|
||||
classId: configuration.classId,
|
||||
secondaryClassId: configuration.secondaryClassId,
|
||||
raceId: configuration.raceId,
|
||||
specialization: configuration.specialization,
|
||||
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
|
||||
talentProfileId: configuration.talentProfileId ?? null,
|
||||
}, configuration.role, level, index, seed, configuration.talentRanks);
|
||||
return Object.freeze(withMemberAbilityResourcePools({
|
||||
...member,
|
||||
secondaryClassId: configuration.secondaryClassId ?? null,
|
||||
specializationId: configuration.specializationId ?? member.specializationId,
|
||||
talentRanks: configuration.talentRanks ?? member.talentRanks,
|
||||
}));
|
||||
@@ -655,6 +670,12 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
return withMemberAbilityResourcePools({
|
||||
...member,
|
||||
level: safeLevel,
|
||||
secondaryClassId: isRomClassId(member.classId)
|
||||
&& romFiveIdentity(member.classId, member.role)?.name === member.name
|
||||
? (safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL
|
||||
? romFiveIdentity(member.classId, member.role)?.secondaryClassId ?? null
|
||||
: null)
|
||||
: member.secondaryClassId,
|
||||
health,
|
||||
maxHealth,
|
||||
resource,
|
||||
|
||||
@@ -506,18 +506,130 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
.filter((effect) => effect.kind === kind)
|
||||
.sort((left, right) => (strongest ? -1 : 1) * (outputStrength(left) - outputStrength(right)))
|
||||
.slice(0, 1);
|
||||
const holySealDot = (strongest: boolean): readonly AbilityEffect[] => oneOutput("dot", strongest).map((effect) => (
|
||||
effect.kind === "dot" ? { ...effect, tag: "rom-holy-seal" } : effect
|
||||
));
|
||||
// These source rows carry alternate/duplicate payloads, while their player
|
||||
// tooltips specify a single projectile hit. The first attack component is
|
||||
// the primary shot; executing every component also applies unrelated Shot
|
||||
// stat modifiers and doubles Throw damage.
|
||||
if (row.sourceSkillId === 490309 || row.sourceSkillId === 490423) {
|
||||
return translated.filter((effect) => effect.kind === "damage").slice(0, 1);
|
||||
}
|
||||
if (row.sourceSkillId === 490313) return translated.filter((effect) => effect.kind === "damage");
|
||||
// The source rows include auxiliary spell records alongside the tooltip's
|
||||
// single hit or periodic heal. They are alternatives, not extra casts.
|
||||
if (row.sourceSkillId === 491150 || row.sourceSkillId === 490204) {
|
||||
return translated.filter((effect) => effect.kind === "damage").slice(0, 1);
|
||||
}
|
||||
if (row.sourceSkillId === 490252 || row.sourceSkillId === 490244) {
|
||||
const hit = translated.find((effect) => effect.kind === "damage");
|
||||
const hitCount = row.sourceSkillId === 490252 ? 5 : 3;
|
||||
return [
|
||||
...(hit ? Array.from({ length: hitCount }, () => hit) : []),
|
||||
...(row.sourceSkillId === 490244
|
||||
? translated.filter((effect) => effect.kind === "apply-aura")
|
||||
: []),
|
||||
];
|
||||
}
|
||||
if (row.sourceSkillId === 490256) {
|
||||
return translated.filter((effect) => effect.kind === "damage");
|
||||
}
|
||||
if (row.sourceSkillId === 490269) {
|
||||
return translated.filter((effect) => effect.kind === "hot").slice(0, 1);
|
||||
}
|
||||
if (row.sourceSkillId === 490304 || row.sourceSkillId === 490434) {
|
||||
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] },
|
||||
} : effect);
|
||||
}
|
||||
if (row.sourceSkillId === 490506 || row.sourceSkillId === 490218) {
|
||||
return translated.filter((effect) => effect.kind === "apply-aura");
|
||||
}
|
||||
if (row.sourceSkillId === 490420) {
|
||||
return translated.filter((effect) => effect.kind === "damage"
|
||||
|| (effect.kind === "apply-aura" && !effect.control));
|
||||
}
|
||||
if (row.sourceSkillId === 490242) {
|
||||
return translated.filter((effect) => effect.kind === "damage"
|
||||
|| (effect.kind === "apply-aura" && effect.control?.kind === "stun"));
|
||||
}
|
||||
if (row.sourceSkillId === 490344) {
|
||||
return [{ kind: "movement", movement: "teleport", toward: "target" }];
|
||||
}
|
||||
if (row.sourceSkillId === 490311) {
|
||||
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot");
|
||||
}
|
||||
if (row.sourceSkillId === 490192) {
|
||||
return translated.filter((effect) => effect.kind === "apply-aura").map((effect) => (
|
||||
effect.kind === "apply-aura" && effect.aura.procs?.length ? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
durationMs: 30_000,
|
||||
procs: effect.aura.procs.map((proc) => ({
|
||||
...proc,
|
||||
action: { kind: "custom" as const, id: "rom-shield-discipline-reflect" },
|
||||
})),
|
||||
},
|
||||
} : effect
|
||||
));
|
||||
}
|
||||
if (row.sourceSkillId === 490347) {
|
||||
return [{ kind: "status", status: "sleep", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
|
||||
}
|
||||
if (row.sourceSkillId === 490454) {
|
||||
return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
|
||||
}
|
||||
if (row.sourceSkillId === 490438) {
|
||||
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
|
||||
}
|
||||
if (row.sourceSkillId === 491172) {
|
||||
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
|
||||
}
|
||||
if (row.sourceSkillId === 490337) {
|
||||
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot"
|
||||
|| (effect.kind === "apply-aura" && effect.control?.kind === "stun"));
|
||||
}
|
||||
if (row.sourceSkillId === 490507) {
|
||||
const aura = translated.find((effect) => effect.kind === "apply-aura");
|
||||
return aura?.kind === "apply-aura" ? [{
|
||||
...aura,
|
||||
aura: { ...aura.aura, utilityTags: ["next-offensive-critical", "rom-premeditation"] },
|
||||
}] : translated;
|
||||
}
|
||||
if (row.sourceSkillId === 490354) {
|
||||
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] },
|
||||
} : effect);
|
||||
}
|
||||
if (row.sourceSkillId === 490329) {
|
||||
const aura = translated.find((effect) => effect.kind === "apply-aura");
|
||||
return aura?.kind === "apply-aura" ? [{
|
||||
...aura,
|
||||
aura: { ...aura.aura, durationMs: 900_000, utilityTags: ["rom-poisonous"] },
|
||||
}] : translated;
|
||||
}
|
||||
// RuneWaker's Knight uses Holy Seal as a target-scoped build/spend resource.
|
||||
// Model it through the combat engine's combo authority so both players and
|
||||
// companions build seals before Punishment instead of casting the payoff on
|
||||
// an unsealed target. Source components contain one alternative payload per
|
||||
// seal count; executing every branch at once greatly overstates the skills.
|
||||
if (row.sourceSkillId === 490148) return [
|
||||
...oneOutput("damage", true), ...oneOutput("dot", false), { kind: "combo", amount: 1 },
|
||||
...oneOutput("damage", true), ...holySealDot(false), { kind: "combo", amount: 1 },
|
||||
];
|
||||
if (row.sourceSkillId === 490149) return [
|
||||
...oneOutput("damage", true), ...oneOutput("dot", true), { kind: "combo", amount: 4 },
|
||||
...oneOutput("damage", true), ...holySealDot(true), { kind: "combo", amount: 4 },
|
||||
];
|
||||
if (row.sourceSkillId === 490150) return [
|
||||
{ kind: "movement", movement: "charge", toward: "target" },
|
||||
...oneOutput("damage", true),
|
||||
...translated.filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun").slice(0, 1),
|
||||
...holySealDot(false),
|
||||
{ kind: "combo", amount: 2 },
|
||||
];
|
||||
if (row.sourceSkillId === 490150) return [...translated, { kind: "combo", amount: 2 }];
|
||||
if (row.sourceSkillId === 490151) return [
|
||||
...oneOutput("damage", true), ...oneOutput("dot", true), { kind: "combo", amount: -5 },
|
||||
];
|
||||
|
||||
@@ -51,7 +51,19 @@ describe("executable RuneWaker ability mappings", () => {
|
||||
|
||||
it("retains every executable part of multi-effect abilities", () => {
|
||||
const kinds = named("Sneak Attack").effects.map((effect) => effect.kind);
|
||||
expect(kinds).toEqual(expect.arrayContaining(["damage", "apply-aura", "dot", "scripted"]));
|
||||
expect(kinds).toEqual(["damage", "apply-aura", "dot"]);
|
||||
});
|
||||
|
||||
it("executes one projectile hit for Rogue Throw and Scout Shot", () => {
|
||||
for (const name of ["Throw", "Shot"]) {
|
||||
const ability = named(name);
|
||||
expect(ability.effects).toHaveLength(1);
|
||||
expect(ability.effects[0]).toMatchObject({ kind: "damage" });
|
||||
for (const version of ability.ranks ?? []) {
|
||||
expect(version.effects).toHaveLength(1);
|
||||
expect(version.effects[0]).toMatchObject({ kind: "damage" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("implements source-authored immunity and absorbs as named auras", () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityById, type AbilityEffect } from "./abilityCatalog";
|
||||
import { romEffectsForCast, romTargetBleedCount, type RomCastMechanics } from "./romCombatMechanics";
|
||||
|
||||
const empty: RomCastMechanics = {
|
||||
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 2_000,
|
||||
};
|
||||
|
||||
function cast(id: string, overrides: Partial<RomCastMechanics> = {}): readonly AbilityEffect[] {
|
||||
const ability = abilityById(id);
|
||||
expect(ability).toBeDefined();
|
||||
return romEffectsForCast(ability!, { ...empty, ...overrides });
|
||||
}
|
||||
|
||||
function kinds(effects: readonly AbilityEffect[]): readonly string[] {
|
||||
return effects.map((effect) => effect.kind);
|
||||
}
|
||||
|
||||
describe("RoM primary class conditional combat effects", () => {
|
||||
it("counts only active Rogue bleeds applied by the caster to that target", () => {
|
||||
const timed = [
|
||||
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "rogue", targetId: "mob", remainingTicks: 3 },
|
||||
{ kind: "dot", sourceAbilityId: "rom-rogue-general-0", sourceActorId: "rogue", targetId: "mob", remainingTicks: 2 },
|
||||
{ kind: "dot", sourceAbilityId: "rom-rogue-general-0", sourceActorId: "rogue", targetId: "mob", remainingTicks: 1 },
|
||||
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-9", sourceActorId: "other", targetId: "mob", remainingTicks: 2 },
|
||||
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-4", sourceActorId: "rogue", targetId: "other-mob", remainingTicks: 2 },
|
||||
];
|
||||
expect(romTargetBleedCount(timed, "rogue", "mob")).toBe(2);
|
||||
});
|
||||
|
||||
it("gives Wound Attack one hit plus one hit per active bleed, without inventing a DoT", () => {
|
||||
expect(kinds(cast("rom-rogue-primary-2"))).toEqual(["damage"]);
|
||||
expect(kinds(cast("rom-rogue-primary-2", { bleedCount: 1 }))).toEqual(["damage", "damage"]);
|
||||
expect(kinds(cast("rom-rogue-primary-2", { bleedCount: 2 }))).toEqual(["damage", "damage", "damage"]);
|
||||
expect(kinds(cast("rom-rogue-primary-1"))).toEqual(["damage"]);
|
||||
expect(kinds(cast("rom-rogue-primary-1", { bleedCount: 1 }))).toEqual(["damage", "dot"]);
|
||||
});
|
||||
|
||||
it("selects exactly one Electric Explosion hit for normal and Charged casts", () => {
|
||||
const normal = cast("rom-mage-primary-54");
|
||||
const charged = cast("rom-mage-primary-54", { charged: true });
|
||||
expect(kinds(normal)).toEqual(["damage"]);
|
||||
expect(kinds(charged)).toEqual(["damage"]);
|
||||
expect((charged[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints)
|
||||
.toBeGreaterThan((normal[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints ?? 0);
|
||||
});
|
||||
|
||||
it("requires Holy Seals for Punishment and scales its payoff by stack count", () => {
|
||||
expect(cast("rom-knight-general-0")).toEqual([]);
|
||||
const one = cast("rom-knight-general-0", { sealStacks: 1 });
|
||||
const four = cast("rom-knight-general-0", { sealStacks: 4 });
|
||||
expect(kinds(one)).toEqual(["damage", "dot", "combo"]);
|
||||
expect((four[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints)
|
||||
.toBe(4 * ((one[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints ?? 0));
|
||||
});
|
||||
|
||||
it("gates Knight seal riders and grants Shield Bash's absorb to the caster", () => {
|
||||
expect(kinds(cast("rom-knight-primary-7"))).toEqual(["damage", "apply-aura"]);
|
||||
expect(kinds(cast("rom-knight-primary-7", { sealStacks: 4 })).filter((kind) => kind === "damage")).toHaveLength(2);
|
||||
expect(kinds(cast("rom-knight-primary-12"))).toEqual(["damage", "apply-aura"]);
|
||||
expect(kinds(cast("rom-knight-primary-12", { sealStacks: 4 })).filter((kind) => kind === "damage")).toHaveLength(2);
|
||||
const bash = cast("rom-knight-primary-11", { sealStacks: 3 });
|
||||
const shield = bash.find((effect) => effect.kind === "apply-aura");
|
||||
expect(shield?.kind === "apply-aura" && shield.recipient).toBe("caster");
|
||||
expect(shield?.kind === "apply-aura" && shield.aura.absorbs?.[0]?.amount).toBe(300);
|
||||
expect(kinds(bash)).toEqual(["damage", "apply-aura", "combo"]);
|
||||
});
|
||||
|
||||
it("requires a dagger for Shadowstab and Sneak Attack bleeds", () => {
|
||||
expect(kinds(cast("rom-rogue-general-0"))).toEqual(["damage"]);
|
||||
expect(kinds(cast("rom-rogue-general-0", { mainHandDagger: true }))).toEqual(["damage", "dot"]);
|
||||
expect(kinds(cast("rom-rogue-primary-9"))).toEqual(["damage", "apply-aura"]);
|
||||
expect(kinds(cast("rom-rogue-primary-9", { mainHandDagger: true }))).toEqual(["damage", "apply-aura", "dot"]);
|
||||
});
|
||||
|
||||
it("keeps auxiliary source rows from adding extra hits, heals, or false control", () => {
|
||||
expect(kinds(cast("rom-priest-general-2"))).toEqual(["hot"]);
|
||||
expect(kinds(cast("rom-mage-primary-1"))).toEqual(["damage"]);
|
||||
expect(kinds(cast("rom-mage-general-0"))).toEqual(["damage"]);
|
||||
expect(kinds(cast("rom-rogue-primary-0"))).toEqual(["apply-aura", "apply-aura"]);
|
||||
expect(kinds(cast("rom-scout-general-3"))).toEqual(["damage", "apply-aura"]);
|
||||
});
|
||||
|
||||
it("executes the source count for multi-hit Mage storms", () => {
|
||||
expect(kinds(cast("rom-mage-primary-5"))).toEqual(Array(5).fill("damage"));
|
||||
expect(kinds(cast("rom-mage-primary-12"))).toEqual([
|
||||
"damage", "damage", "damage", "apply-aura", "apply-aura",
|
||||
]);
|
||||
});
|
||||
|
||||
it("holds Shield of Discipline for 30 seconds and reflects only on being hit", () => {
|
||||
const effects = cast("rom-knight-general-7");
|
||||
expect(kinds(effects)).toEqual(["apply-aura", "apply-aura", "apply-aura"]);
|
||||
const proc = effects.find((effect) => effect.kind === "apply-aura" && effect.aura.procs?.length);
|
||||
expect(proc?.kind === "apply-aura" && proc.aura.durationMs).toBe(30_000);
|
||||
expect(proc?.kind === "apply-aura" && proc.aura.procs?.[0]?.action).toMatchObject({
|
||||
kind: "custom", id: "rom-shield-discipline-reflect",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses one breakable control effect for each single-target restraint", () => {
|
||||
for (const id of ["rom-scout-primary-4", "rom-rogue-primary-5", "rom-mage-primary-56"]) {
|
||||
const effects = cast(id);
|
||||
expect(effects).toHaveLength(1);
|
||||
expect(effects[0]).toMatchObject({ kind: "status", maxTargets: 1, breakOnDamage: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
const ROGUE_BLEED_ABILITIES = new Set([
|
||||
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
|
||||
"rom-rogue-primary-4", // Blind Spot
|
||||
"rom-rogue-primary-9", // Sneak Attack
|
||||
"rom-rogue-general-0", // Shadowstab
|
||||
]);
|
||||
|
||||
export interface RomCastMechanics {
|
||||
readonly sealStacks: number;
|
||||
readonly bleedCount: number;
|
||||
readonly charged: boolean;
|
||||
readonly mainHandDagger: boolean;
|
||||
readonly casterMaxHealth: number;
|
||||
}
|
||||
|
||||
/** Source periodic values for self-sacrifice toggle skills are percentages of HP. */
|
||||
export function romSelfDrainPercent(spellId: number): number {
|
||||
if (spellId === 490304) return 6; // Priest Holy Candle
|
||||
if (spellId === 490434) return 3; // Scout Blood Arrow
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function romSingleTargetControl(ability: AbilityDefinition | null): boolean {
|
||||
return ability?.source === "runewaker"
|
||||
&& [490347, 490454, 491172].includes(ability.dbcSpellId);
|
||||
}
|
||||
|
||||
export function romAbilityNeedsShield(ability: AbilityDefinition): boolean {
|
||||
return ability.source === "runewaker"
|
||||
&& [490175, 490186, 490192, 490468].includes(ability.dbcSpellId);
|
||||
}
|
||||
|
||||
export function romTargetBleedCount(
|
||||
effects: readonly { readonly kind: string; readonly sourceAbilityId: string; readonly sourceActorId: string; readonly targetId: string | null; readonly remainingTicks: number }[],
|
||||
sourceActorId: string,
|
||||
targetId: string | null,
|
||||
): number {
|
||||
if (!targetId) return 0;
|
||||
return new Set(effects.filter((effect) => effect.kind === "dot"
|
||||
&& effect.remainingTicks > 0
|
||||
&& effect.sourceActorId === sourceActorId
|
||||
&& effect.targetId === targetId
|
||||
&& ROGUE_BLEED_ABILITIES.has(effect.sourceAbilityId))
|
||||
.map((effect) => effect.sourceAbilityId)).size;
|
||||
}
|
||||
|
||||
function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
|
||||
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
|
||||
return {
|
||||
...effect,
|
||||
coefficient: effect.coefficient * factor,
|
||||
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
|
||||
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
|
||||
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
|
||||
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Select source-conditional RoM payloads once for both player and companion casts. */
|
||||
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker") return ability.effects;
|
||||
const effects = ability.effects;
|
||||
const damage = effects.filter((effect) => effect.kind === "damage");
|
||||
const seals = Math.max(0, Math.min(4, context.sealStacks));
|
||||
switch (ability.dbcSpellId) {
|
||||
case 490148: // A new Holy Strike increases the target's Holy Seal bleed stack.
|
||||
case 490150: { // Charge adds two stacks.
|
||||
const gained = ability.dbcSpellId === 490148 ? 1 : 2;
|
||||
const nextStacks = Math.min(4, seals + gained);
|
||||
return effects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect);
|
||||
}
|
||||
case 490313: // Wound Attack: one hit plus one rider per self-applied bleed.
|
||||
return damage.slice(0, 1 + Math.min(2, context.bleedCount));
|
||||
case 490323: // Low Blow only creates Grievous Wound on a bleeding target.
|
||||
return context.bleedCount > 0 ? effects : effects.filter((effect) => effect.kind !== "dot");
|
||||
case 491170: // Electric Explosion has mutually exclusive normal/Charged hits.
|
||||
return context.charged ? damage.slice(0, 1) : damage.slice(-1);
|
||||
case 490151: // Punishment spends Holy Seals and scales its one payoff.
|
||||
return seals > 0 ? effects.map((effect) => scaleOutput(effect, seals / 4)) : [];
|
||||
case 490153: { // Threaten: target debuffs and extra hit require Holy Seals.
|
||||
const base = damage[0];
|
||||
const threat = effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
|
||||
const debuffs = seals > 0 ? effects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : [];
|
||||
const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null;
|
||||
return [...(base ? [base] : []), ...(threat ? [threat] : []), ...debuffs, ...(bonus ? [bonus] : [])];
|
||||
}
|
||||
case 490154: { // Shock: one hit, one stun, plus a seal-dependent rider.
|
||||
const stun = effects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun");
|
||||
const scaledStun = stun && stun.kind === "apply-aura"
|
||||
? { ...stun, aura: { ...stun.aura, durationMs: 1_000 * (1 + seals) } }
|
||||
: null;
|
||||
const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null;
|
||||
return [...(damage[0] ? [damage[0]] : []), ...(scaledStun ? [scaledStun] : []), ...(bonus ? [bonus] : [])];
|
||||
}
|
||||
case 490186: { // Shield Bash shields its caster only when consuming seals.
|
||||
const absorb = effects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length);
|
||||
const shield = seals > 0 && absorb?.kind === "apply-aura" ? {
|
||||
...absorb,
|
||||
recipient: "caster" as const,
|
||||
aura: {
|
||||
...absorb.aura,
|
||||
absorbs: absorb.aura.absorbs?.map((pool) => ({
|
||||
...pool, amount: Math.max(1, Math.round(context.casterMaxHealth * 0.05 * seals)),
|
||||
})),
|
||||
},
|
||||
} : null;
|
||||
return [...(damage[0] ? [damage[0]] : []), ...(shield ? [shield, { kind: "combo" as const, amount: -5 }] : [])];
|
||||
}
|
||||
case 490306: // Shadowstab's bleed needs a main-hand dagger.
|
||||
return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot");
|
||||
case 490337: // Sneak Attack always stuns, but its bleed needs a dagger.
|
||||
return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot");
|
||||
default:
|
||||
return effects;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { defaultActionBarForCharacter, resourceProfileForClass } from "./abilityCatalog";
|
||||
import { abilityById, defaultActionBarForCharacter, resourceProfileForClass } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { companionActorFromMember, companionActorWithMemberRuntime } from "./combatActorAuthority";
|
||||
import { targetingRulesForAbility } from "./abilityRules";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
|
||||
describe("RuneWaker combat runtime", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("creates keyed pools for both classes and changes the active resource on orientation swap", () => {
|
||||
@@ -106,6 +109,96 @@ describe("RuneWaker combat runtime", () => {
|
||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("applies companion Holy Candle HP costs to its actor and party record", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 50 });
|
||||
usePartyStore.getState().initializeParty("rom-self-drain", 50, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
const original = usePartyStore.getState().members[0]!;
|
||||
const member = { ...original, classId: "rom-priest" as const, role: "healer" as const };
|
||||
usePartyStore.setState((state) => ({ members: [member, ...state.members.slice(1)] }));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const ability = abilityById("rom-priest-primary-17")!;
|
||||
const dot = ability.effects.find((effect) => effect.kind === "dot");
|
||||
const aura = ability.effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(dot?.kind).toBe("dot");
|
||||
expect(aura?.kind).toBe("apply-aura");
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(member.id, member.id, ability,
|
||||
dot as Extract<typeof dot, { kind: "dot" }>, 1_000)).toBe(true);
|
||||
expect(useCombatStore.getState().applyActorAura(member.id, member.id, ability,
|
||||
aura as Extract<typeof aura, { kind: "apply-aura" }>, 1_000)).toBe(true);
|
||||
|
||||
useCombatStore.getState().tick(1, 2_100);
|
||||
expect(usePartyStore.getState().members[0]!.health).toBeLessThan(member.health);
|
||||
expect(useCombatStore.getState().actors[member.id]!.health).toBe(usePartyStore.getState().members[0]!.health);
|
||||
const healthAfterTick = usePartyStore.getState().members[0]!.health;
|
||||
useCombatStore.getState().consumeActorAuraTag(member.id, "rom-self-health-drain");
|
||||
useCombatStore.getState().tick(1, 3_100);
|
||||
expect(usePartyStore.getState().members[0]!.health).toBe(healthAfterTick);
|
||||
});
|
||||
|
||||
it("keeps Scout Lasso on one target and breaks it when attacked", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-human", level: 50 });
|
||||
for (const [id, x] of [["lasso-a", 3], ["lasso-b", 4]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("lasso-a");
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-4", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
expect(useCombatStore.getState().mobs["lasso-a"]!.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "root", breakOnDamage: true }),
|
||||
]));
|
||||
|
||||
useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, "rom-scout-primary-4": 0 } }));
|
||||
useCombatStore.getState().selectMob("lasso-b");
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-4", undefined, 3_600)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_700);
|
||||
expect(useCombatStore.getState().mobs["lasso-a"]!.statuses.some((status) => status.kind === "root")).toBe(false);
|
||||
expect(useCombatStore.getState().mobs["lasso-b"]!.statuses.some((status) => status.kind === "root")).toBe(true);
|
||||
useCombatStore.getState().damageMob("lasso-b", 10, 4_800);
|
||||
expect(useCombatStore.getState().mobs["lasso-b"]!.statuses.some((status) => status.kind === "root")).toBe(false);
|
||||
});
|
||||
|
||||
it("moves Rogue Shadow Step behind its target", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 50 });
|
||||
useCombatStore.getState().registerMob("step-dummy", { name: "Step Dummy", maxHealth: 100_000 });
|
||||
setMobPosition("step-dummy", [0, 0, 6]);
|
||||
useCombatStore.getState().selectMob("step-dummy");
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-general-7", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 4.5]);
|
||||
});
|
||||
|
||||
it("lets Vanish create the out-of-combat opening for Premeditation", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 50 });
|
||||
useCombatStore.getState().registerMob("vanish-dummy", { name: "Vanish Dummy", maxHealth: 100_000 });
|
||||
setMobPosition("vanish-dummy", [3, 0, 0]);
|
||||
useCombatStore.getState().damageMob("vanish-dummy", 1, 1_000);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-8", undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-11", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-8", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("reflects a spell hit from Shield of Discipline without damaging on cast", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", raceId: "rom-human", level: 50, role: "damage" });
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-general-7", undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", raceId: "rom-human", level: 50, role: "tank" });
|
||||
useCombatStore.getState().registerMob("reflect-mage", {
|
||||
name: "Reflect Mage", maxHealth: 100_000,
|
||||
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "reflect-fire", school: "fire", delivery: "projectile", range: 20 }],
|
||||
});
|
||||
setMobPosition("reflect-mage", [3, 0, 0]);
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-general-7", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().mobs["reflect-mage"]!.health).toBe(100_000);
|
||||
useCombatStore.getState().damageMob("reflect-mage", 1, 1_000);
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.abilityId === "rom-knight-general-7" && event.targetActorId === "reflect-mage")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses source coefficients for direct damage even without equipment", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 20 });
|
||||
useCombatStore.getState().registerMob("coefficient-dummy", { name: "Coefficient Dummy", maxHealth: 1_000 });
|
||||
@@ -211,6 +304,57 @@ describe("RuneWaker combat runtime", () => {
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(17);
|
||||
});
|
||||
|
||||
it("requires a target's Holy Seals before Punishment and consumes the stacks", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", raceId: "rom-human", level: 50 });
|
||||
useCombatStore.getState().registerMob("seal-dummy", { name: "Seal Dummy", maxHealth: 100_000 });
|
||||
setMobPosition("seal-dummy", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("seal-dummy");
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-general-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().comboPoints).toBe(1);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "rom-holy-seal")).toHaveLength(1);
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().comboPoints).toBe(0);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "rom-holy-seal")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fires one Rogue Wound Attack hit without an existing bleed", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 20 });
|
||||
useCombatStore.getState().registerMob("wound-dummy", { name: "Wound Dummy", maxHealth: 100_000 });
|
||||
setMobPosition("wound-dummy", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("wound-dummy");
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, energy: 100 },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-2", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
||||
&& event.abilityId === "rom-rogue-primary-2")).toHaveLength(1);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "rom-rogue-primary-2"))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["rom-priest", "rom-priest-primary-17"],
|
||||
["rom-scout", "rom-scout-general-4"],
|
||||
] as const)("applies %s self-sacrifice damage on periodic ticks", (classId, abilityId) => {
|
||||
const ability = abilityById(abilityId);
|
||||
expect(ability).toBeDefined();
|
||||
useCombatStore.getState().initializeCharacter({ classId, raceId: "rom-human", level: 50 });
|
||||
const before = useCombatStore.getState().health;
|
||||
|
||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-self-health-drain")).toBe(true);
|
||||
useCombatStore.getState().tick(1, 2_100);
|
||||
expect(useCombatStore.getState().health).toBeLessThan(before);
|
||||
});
|
||||
|
||||
it("keeps native summons as temporary allied combat actors", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", raceId: "rom-elf", level: 20 });
|
||||
const maximumMana = useCombatStore.getState().maxResourcePools.mana!;
|
||||
@@ -241,6 +385,34 @@ describe("RuneWaker combat runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps RoM resurrection and Rogue openers out of combat", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 40 });
|
||||
usePartyStore.getState().initializeParty("rom-combat-resurrection", 40, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
usePartyStore.getState().damageMember(member.id, member.maxHealth * 10);
|
||||
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
useCombatStore.getState().registerMob("rom-combat-target", { name: "Combat target", maxHealth: 1_000 });
|
||||
setMobPosition("rom-combat-target", [3, 0, 0]);
|
||||
useCombatStore.getState().damageMob("rom-combat-target", 1, 1_000);
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-primary-5", undefined, 2_000))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 40 });
|
||||
useCombatStore.getState().registerMob("rogue-opener-target", { name: "Opener target", maxHealth: 1_000 });
|
||||
setMobPosition("rogue-opener-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("rogue-opener-target");
|
||||
useCombatStore.getState().damageMob("rogue-opener-target", 1, 1_000);
|
||||
for (const id of ["rom-rogue-primary-8", "rom-rogue-primary-9"]) {
|
||||
expect(useCombatStore.getState().castAbility(id, undefined, 2_000), id)
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
}
|
||||
const blindSpot = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-rogue-primary-4")!;
|
||||
const sneakAttack = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-rogue-primary-9")!;
|
||||
expect(targetingRulesForAbility(blindSpot).requiresBehindTarget).toBe(true);
|
||||
expect(targetingRulesForAbility(sneakAttack).requiresBehindTarget).toBe(true);
|
||||
});
|
||||
|
||||
it("chains Plasma Arrow, Electric Compression, and Static Field through their source states", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", raceId: "rom-human", level: 20 });
|
||||
useCombatStore.getState().registerMob("static-dummy", { name: "Static Dummy", maxHealth: 10_000 });
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RomClassId, RomRaceId } from "../app/characterCatalog";
|
||||
import type { PartyRole } from "./partyRoles";
|
||||
|
||||
interface RomFiveIdentity {
|
||||
readonly name: string;
|
||||
readonly classId: RomClassId;
|
||||
readonly secondaryClassId: RomClassId;
|
||||
readonly raceId: RomRaceId;
|
||||
readonly role: PartyRole;
|
||||
}
|
||||
|
||||
/** Named, race-legal identities shared by live NPCs and the targeted RoM builds. */
|
||||
export const ROM_FIVE_IDENTITIES: readonly RomFiveIdentity[] = Object.freeze([
|
||||
{ name: "Rorik", classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", role: "tank" },
|
||||
{ name: "Serenna", classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", role: "healer" },
|
||||
{ name: "Veyra", classId: "rom-rogue", secondaryClassId: "rom-warrior", raceId: "rom-human", role: "damage" },
|
||||
{ name: "Caelan", classId: "rom-scout", secondaryClassId: "rom-rogue", raceId: "rom-elf", role: "damage" },
|
||||
{ name: "Durnik", classId: "rom-mage", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", role: "damage" },
|
||||
]);
|
||||
|
||||
export function romFiveIdentity(classId: RomClassId, role: PartyRole): RomFiveIdentity | undefined {
|
||||
return ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId && identity.role === role);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog";
|
||||
import { isValidRomClassPair } from "../app/romDualClass";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { readinessStatusFor } from "./companionSpecReadiness";
|
||||
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
||||
import { partyRolesForClass } from "./partyRoles";
|
||||
import { createConfiguredPartyMember, createDungeonParty, createSpectatorDungeonParty, usePartyStore } from "./partyStore";
|
||||
import { ROM_ORDINARY_ABILITIES_BY_CLASS } from "./romAbilityCatalog";
|
||||
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
||||
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
||||
import { romGearProfile } from "./specializationCatalog";
|
||||
|
||||
const CASES = [
|
||||
{ classId: "rom-priest", role: "healer", layout: "caster" },
|
||||
{ classId: "rom-knight", role: "tank", layout: "weapon-shield" },
|
||||
{ classId: "rom-scout", role: "damage", layout: "ranged" },
|
||||
{ classId: "rom-rogue", role: "damage", layout: "dual-wield" },
|
||||
{ classId: "rom-mage", role: "damage", layout: "caster" },
|
||||
] as const;
|
||||
|
||||
describe("RoM five class validation scope", () => {
|
||||
it.each([10, 30, 60, 80])("keeps each secondary class at the primary level for stat growth at level %i", (level) => {
|
||||
for (const identity of ROM_FIVE_IDENTITIES) {
|
||||
const primary = romNativeClassBaseStats(identity.classId, level);
|
||||
const secondary = romNativeClassBaseStats(identity.secondaryClassId, level);
|
||||
const combined = romDualClassBaseStats(identity.classId, identity.secondaryClassId, level)!;
|
||||
for (const stat of Object.keys(primary) as (keyof typeof primary)[]) {
|
||||
expect(combined[stat]).toBeCloseTo(primary[stat] + secondary[stat] * 0.1, 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("builds the requested named class pairs and races without talents", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds(60, CASES.map((entry) => entry.classId));
|
||||
const selected = builds.filter((build) => CASES.some((entry) => (
|
||||
entry.classId === build.classId && entry.role === build.role
|
||||
)));
|
||||
expect(selected).toHaveLength(5);
|
||||
for (const build of selected) {
|
||||
expect(build.orientation).toBe("Primary");
|
||||
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === build.classId && entry.role === build.role)!;
|
||||
expect(build).toMatchObject({
|
||||
characterName: identity.name,
|
||||
raceId: identity.raceId,
|
||||
secondaryClassId: identity.secondaryClassId,
|
||||
});
|
||||
expect(isValidRomClassPair(identity.raceId, identity.classId, identity.secondaryClassId)).toBe(true);
|
||||
expect(build.talentRanks).toEqual({});
|
||||
expect(readinessStatusFor(romGearProfile(build.classId, build.role).id)).toBe("needs-testing");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the same identities for live NPCs and unlocks their secondary class at level 10", () => {
|
||||
const found = new Map<string, (typeof ROM_FIVE_IDENTITIES)[number]>();
|
||||
for (let seed = 0; seed < 40; seed += 1) {
|
||||
for (const member of createSpectatorDungeonParty(`rom-pairs-${seed}`, 60, "rom")) {
|
||||
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === member.classId && entry.role === member.role);
|
||||
if (!identity) continue;
|
||||
expect(member).toMatchObject({
|
||||
name: identity.name,
|
||||
raceId: identity.raceId,
|
||||
secondaryClassId: identity.secondaryClassId,
|
||||
talentRanks: {},
|
||||
});
|
||||
found.set(identity.classId, identity);
|
||||
}
|
||||
}
|
||||
expect(found.size).toBe(5);
|
||||
expect(new Set(ROM_FIVE_IDENTITIES.map((entry) => entry.name)).size).toBe(5);
|
||||
usePartyStore.getState().initializeSpectatorParty("rom-unlock", 1, "rom");
|
||||
expect(usePartyStore.getState().members.every((member) => member.secondaryClassId === null)).toBe(true);
|
||||
usePartyStore.getState().syncLevel(10);
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === member.classId && entry.role === member.role);
|
||||
if (identity) expect(member.secondaryClassId).toBe(identity.secondaryClassId);
|
||||
}
|
||||
const mage = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-mage")!;
|
||||
expect(createConfiguredPartyMember({
|
||||
name: mage.name,
|
||||
classId: mage.classId,
|
||||
secondaryClassId: mage.secondaryClassId,
|
||||
raceId: mage.raceId,
|
||||
specialization: "Primary",
|
||||
role: mage.role,
|
||||
}, 60, 4, "rom-manual")).toMatchObject({
|
||||
name: "Durnik",
|
||||
raceId: "rom-dwarf",
|
||||
secondaryClassId: "rom-warlock",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds general secondary skills to each pair's learned kit", () => {
|
||||
for (const identity of ROM_FIVE_IDENTITIES) {
|
||||
const abilities = abilitiesForCharacter(identity.classId, identity.secondaryClassId);
|
||||
expect(abilities.some((ability) => ability.classId === identity.secondaryClassId
|
||||
&& ability.romSkillGroup === "general")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses ordinary learned abilities with no elite or talent-point gate", () => {
|
||||
for (const { classId, role } of CASES) {
|
||||
expect(partyRolesForClass(classId)).toContain(role);
|
||||
const abilities = abilitiesForCharacter(classId, null);
|
||||
expect(abilities.map((ability) => ability.id))
|
||||
.toEqual(ROM_ORDINARY_ABILITIES_BY_CLASS[classId].map((ability) => ability.id));
|
||||
expect(abilities.every((ability) => !ability.secondaryClassId
|
||||
&& ability.romSkillGroup !== "elite"
|
||||
&& !ability.talentNodeId)).toBe(true);
|
||||
for (const ability of abilities) {
|
||||
const learned = learnedAbilityForBuild(ability, 80, {});
|
||||
expect(learned?.id, ability.id).toBe(ability.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("equips the role defining weapons in the manual cohort", () => {
|
||||
for (const { classId, role, layout } of CASES) {
|
||||
expect(romGearProfile(classId, role).weaponLayout).toBe(layout);
|
||||
const loadout = resolvedEquipmentLoadout({}, [], {
|
||||
ownerId: `rom-five:${classId}`,
|
||||
ownerKind: "party",
|
||||
classId,
|
||||
specializationId: null,
|
||||
role,
|
||||
level: 60,
|
||||
});
|
||||
if (classId === "rom-knight") expect(loadout["off-hand"]?.armorType).toBe("shield");
|
||||
if (classId === "rom-scout") expect(loadout.ranged?.weaponType).toBe("bow");
|
||||
if (classId === "rom-rogue") {
|
||||
expect(loadout["main-hand"]?.weaponType).toBe("dagger");
|
||||
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("seats Knight only as a tank in RoM generated parties", () => {
|
||||
const parties = Array.from({ length: 40 }, (_, index) => createDungeonParty(`rom-knight-${index}`, 60, "healer", 5, "rom"));
|
||||
expect(parties.some((party) => party.some((member) => member.classId === "rom-knight"))).toBe(true);
|
||||
for (const party of parties) for (const member of party) {
|
||||
if (member.classId === "rom-knight") expect(member.role).toBe("tank");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ describe("RuneWaker source loot catalog", () => {
|
||||
|
||||
it("executes NPC slot and treasure probabilities deterministically", () => {
|
||||
const sequence = [0, 0, ...Array.from({ length: 28 }, () => 0.999999)];
|
||||
const drops = rollRunewakerBossLoot("rw-barren-caves-101462", 37, {
|
||||
const drops = rollRunewakerBossLoot("rw-barren-caves-101462", 80, {
|
||||
random: () => sequence.shift() ?? 0.999999,
|
||||
now: 12_345,
|
||||
});
|
||||
@@ -23,11 +23,14 @@ describe("RuneWaker source loot catalog", () => {
|
||||
itemId: 200004,
|
||||
name: "Healing Sigil",
|
||||
quantity: 1,
|
||||
itemLevel: 37,
|
||||
itemLevel: 80,
|
||||
sourceBossName: "Anglo",
|
||||
inventorySlot: "misc",
|
||||
});
|
||||
expect(drops[0].stats).toMatchObject({ stamina: 0, armor: 0, meleeWeapon: null });
|
||||
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 37, {
|
||||
random: () => 0,
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps no-source tables and unknown bosses fail closed", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
specializationById,
|
||||
specializationIconUrl,
|
||||
specializationsForClass,
|
||||
romGearProfile,
|
||||
} from "./specializationCatalog";
|
||||
|
||||
describe("specialization registry", () => {
|
||||
@@ -47,4 +48,12 @@ describe("specialization registry", () => {
|
||||
expect(specializationIconUrl("priest", "mage:frost"))
|
||||
.toBe("/assets/ui/spells/classicon128_priest.png");
|
||||
});
|
||||
|
||||
it("gives the five RoM primary roles their appropriate weapon layouts", () => {
|
||||
expect(romGearProfile("rom-priest", "healer").weaponLayout).toBe("caster");
|
||||
expect(romGearProfile("rom-knight", "tank").weaponLayout).toBe("weapon-shield");
|
||||
expect(romGearProfile("rom-scout", "damage").weaponLayout).toBe("ranged");
|
||||
expect(romGearProfile("rom-rogue", "damage").weaponLayout).toBe("dual-wield");
|
||||
expect(romGearProfile("rom-mage", "damage").weaponLayout).toBe("caster");
|
||||
});
|
||||
});
|
||||
@@ -221,7 +221,7 @@ export function romGearProfile(classId: ClassId, role: PartyRole): Specializatio
|
||||
: caster ? "spell-dps" : base === "hunter" ? "ranged-dps" : definition.primaryStat === "strength" ? "strength-dps" : "agility-dps";
|
||||
const weaponLayout: WeaponLayout = role === "tank" && gearArchetype === "block-tank"
|
||||
? "weapon-shield"
|
||||
: gearArchetype === "ranged-dps" ? "ranged" : caster ? "caster" : "two-hand";
|
||||
: gearArchetype === "ranged-dps" ? "ranged" : caster ? "caster" : base === "rogue" ? "dual-wield" : "two-hand";
|
||||
return Object.freeze({
|
||||
id: `${classId}:role-${role}`,
|
||||
classId,
|
||||
|
||||
@@ -21,6 +21,22 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
|
||||
}
|
||||
|
||||
describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
it("combines each Holy Nova damage rank with its matching healing payload", () => {
|
||||
const nova = byId("wow335-priest-holy-nova");
|
||||
expect(nova.ranks).toHaveLength(9);
|
||||
for (const rank of nova.ranks ?? []) {
|
||||
expect(effectsOfKind(rank, "damage")).toHaveLength(1);
|
||||
expect(effectsOfKind(rank, "heal")).toHaveLength(1);
|
||||
expect(rank.cost.amount).toBeGreaterThan(0);
|
||||
}
|
||||
expect(abilityAtLevel(nova, 80)).toMatchObject({
|
||||
dbcSpellId: 48078,
|
||||
target: "self",
|
||||
radius: 10,
|
||||
castMode: "instant",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Envenom to Deadly Poison dose damage and its poison-frequency buff", () => {
|
||||
const envenom = abilityAtLevel(byId("wow335-rogue-envenom"), 80);
|
||||
expect(effectsOfKind(envenom, "finisher-damage")).toEqual([
|
||||
@@ -188,13 +204,85 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("models Penance as the Discipline rotation's friendly heal", () => {
|
||||
it("models Penance as three healing or damaging pulses", () => {
|
||||
const penance = byId("wow335-priest-penance");
|
||||
expect(penance.target).toBe("friendly");
|
||||
expect(penance.target).toBe("any");
|
||||
expect(effectsOfKind(penance, "heal")).toEqual([
|
||||
expect.objectContaining({ coefficient: 1.8 }),
|
||||
expect.objectContaining({ coefficient: 0.6 }),
|
||||
]);
|
||||
expect(effectsOfKind(penance, "damage")).toEqual([]);
|
||||
expect(effectsOfKind(penance, "hot")).toEqual([
|
||||
expect.objectContaining({ coefficient: 0.6, ticks: 2, intervalMs: 1_000 }),
|
||||
]);
|
||||
expect(effectsOfKind(penance, "damage")).toEqual([
|
||||
expect.objectContaining({ coefficient: 0.6 }),
|
||||
]);
|
||||
expect(effectsOfKind(penance, "dot")).toEqual([
|
||||
expect.objectContaining({ coefficient: 0.6, ticks: 2, intervalMs: 1_000 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps the Ready-five missing spell payloads without cast-time damage on self buffs", () => {
|
||||
const devastate = abilityAtLevel(byId("wow335-warrior-devastate"), 80);
|
||||
expect(effectsOfKind(devastate, "damage")).toEqual([
|
||||
expect.objectContaining({ coefficient: 1, bonusPerSunderStack: 202 }),
|
||||
]);
|
||||
expect(effectsOfKind(devastate, "apply-aura")).toEqual([
|
||||
expect.objectContaining({ aura: expect.objectContaining({ id: "wow335:warrior:sunder-armor", maxStacks: 5 }) }),
|
||||
]);
|
||||
const lastStand = byId("wow335-warrior-last-stand");
|
||||
expect(lastStand.target).toBe("self");
|
||||
expect(effectsOfKind(lastStand, "heal")).toEqual([]);
|
||||
expect(effectsOfKind(lastStand, "apply-aura")).toEqual([
|
||||
expect.objectContaining({ aura: expect.objectContaining({
|
||||
modifiers: [{ kind: "stat", stat: "maximum-health", operation: "percent", value: 0.3 }],
|
||||
}) }),
|
||||
]);
|
||||
expect(effectsOfKind(byId("wow335-priest-divine-hymn"), "heal")).toEqual([
|
||||
expect.objectContaining({ scope: "party", maximumTargets: 3, radius: 40 }),
|
||||
]);
|
||||
expect(effectsOfKind(byId("wow335-hunter-chimera-shot"), "damage")).toEqual([
|
||||
expect.objectContaining({ coefficient: 1.25 }),
|
||||
]);
|
||||
expect(effectsOfKind(abilityAtLevel(byId("wow335-mage-living-bomb"), 80), "dot")).toHaveLength(1);
|
||||
const moltenArmor = abilityAtLevel(byId("wow335-mage-molten-armor"), 80);
|
||||
expect(moltenArmor.target).toBe("self");
|
||||
expect(effectsOfKind(moltenArmor, "damage")).toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-rogue-shiv"), "combo")).toEqual([{ kind: "combo", amount: 1 }]);
|
||||
expect(effectsOfKind(byId("wow335-rogue-cold-blood"), "apply-aura")).toEqual([
|
||||
expect.objectContaining({ aura: expect.objectContaining({
|
||||
utilityTags: ["next-offensive-critical"],
|
||||
modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 100 }],
|
||||
}) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps the remaining Ready-five cooldowns and defensive modifiers", () => {
|
||||
expect(effectsOfKind(byId("wow335-warrior-last-stand"), "apply-aura")[0]?.aura.durationMs).toBe(20_000);
|
||||
expect(byId("wow335-warrior-enraged-regeneration").target).toBe("self");
|
||||
const innerFire = abilityAtLevel(byId("wow335-priest-inner-fire"), 80);
|
||||
expect(effectsOfKind(innerFire, "apply-aura")[0]?.aura).toMatchObject({
|
||||
maxStacks: 20, initialStacks: 20,
|
||||
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 2440, perStack: false }],
|
||||
});
|
||||
const shadowfiend = byId("wow335-priest-shadowfiend");
|
||||
expect(effectsOfKind(shadowfiend, "summon")).toHaveLength(1);
|
||||
expect(effectsOfKind(shadowfiend, "resource")).toHaveLength(0);
|
||||
expect(effectsOfKind(abilityAtLevel(byId("wow335-hunter-kill-shot"), 80), "damage"))
|
||||
.toEqual([{ kind: "damage", coefficient: 2, bonusFlat: 325 }]);
|
||||
const combustion = byId("wow335-mage-combustion");
|
||||
expect(combustion.target).toBe("self");
|
||||
expect(effectsOfKind(combustion, "damage")).toHaveLength(0);
|
||||
expect(effectsOfKind(combustion, "apply-aura")[0]?.aura.utilityTags).toContain("fire-crit-stacking");
|
||||
expect(effectsOfKind(byId("wow335-mage-mirror-image"), "summon")).toHaveLength(3);
|
||||
const molten = abilityAtLevel(byId("wow335-mage-molten-armor"), 80);
|
||||
expect(effectsOfKind(molten, "apply-aura").flatMap((effect) => effect.aura.modifiers ?? []))
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ stat: "incoming-critical-strike", value: -5 }),
|
||||
expect.objectContaining({ stat: "spirit-to-spell-crit-rating", value: 0.35 }),
|
||||
]));
|
||||
expect(effectsOfKind(abilityAtLevel(byId("wow335-rogue-evasion"), 80), "apply-aura")
|
||||
.flatMap((effect) => effect.aura.modifiers ?? []))
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ stat: "incoming-ranged-hit", value: -25 })]));
|
||||
});
|
||||
|
||||
it("keeps triggered shield damage children out of learned Shaman ranks", () => {
|
||||
|
||||
@@ -171,7 +171,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
|
||||
case 13: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
|
||||
case 14: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
|
||||
case 20: return flatStat("health-regeneration");
|
||||
case 22:
|
||||
case 22: return effect.miscValue === 1 ? flatStat("armor") : flatStat(resistanceStat);
|
||||
case 83:
|
||||
case 123:
|
||||
case 143: return flatStat(resistanceStat);
|
||||
@@ -210,13 +210,13 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
|
||||
case 49: return percentStat("dodge");
|
||||
case 50: return percentStat("critical-healing");
|
||||
case 51: return percentStat("block");
|
||||
case 52:
|
||||
case 187:
|
||||
case 188:
|
||||
case 197: return percentStat("critical-strike");
|
||||
case 54:
|
||||
case 184:
|
||||
case 185: return percentStat("hit");
|
||||
case 52: return percentStat("critical-strike");
|
||||
case 187: return [{ kind: "stat", stat: "incoming-melee-critical-strike", operation: "flat", value: effect.basePoints }];
|
||||
case 188: return [{ kind: "stat", stat: "incoming-ranged-critical-strike", operation: "flat", value: effect.basePoints }];
|
||||
case 197: return [{ kind: "stat", stat: "incoming-critical-strike", operation: "flat", value: effect.basePoints }];
|
||||
case 54: return percentStat("hit");
|
||||
case 184: return [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: effect.basePoints }];
|
||||
case 185: return [{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: effect.basePoints }];
|
||||
case 55:
|
||||
case 186:
|
||||
case 199: return percentStat("spell-hit");
|
||||
@@ -447,6 +447,14 @@ function namedAuraForEffect(
|
||||
const school = schoolFromMask(effect.miscValue);
|
||||
const auraId = `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`;
|
||||
const modifiers = [...auraModifiers(effect)];
|
||||
if (abilityName === "Inner Fire") {
|
||||
for (const [index, modifier] of modifiers.entries()) modifiers[index] = { ...modifier, perStack: false };
|
||||
}
|
||||
if (abilityName === "Molten Armor" && effect.auraType === 220) {
|
||||
modifiers.splice(0, modifiers.length, {
|
||||
kind: "stat", stat: "spirit-to-spell-crit-rating", operation: "flat", value: effect.basePoints / 100,
|
||||
});
|
||||
}
|
||||
if (abilityName === "Greater Blessing of Sanctuary" && effect.auraType === 4) {
|
||||
modifiers.push(
|
||||
{ kind: "damage", direction: "taken", operation: "percent", value: -0.03 },
|
||||
@@ -623,8 +631,9 @@ function namedAuraForEffect(
|
||||
? { hideFromAuraStrip: true }
|
||||
: {}),
|
||||
durationMs,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
maxStacks: abilityName === "Inner Fire" ? 20 : 1,
|
||||
...(abilityName === "Inner Fire" ? { initialStacks: 20 } : {}),
|
||||
stackBehavior: abilityName === "Inner Fire" ? "replace" : "refresh",
|
||||
...(exclusiveGroupForAbility(classId, abilityName) || abilityName === "Light's Beacon"
|
||||
? { exclusiveGroup: abilityName === "Light's Beacon" ? "paladin:beacon" : exclusiveGroupForAbility(classId, abilityName) }
|
||||
: {}),
|
||||
@@ -758,10 +767,36 @@ function classScriptEffects(
|
||||
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
|
||||
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
||||
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
|
||||
case "warrior:Devastate": return [
|
||||
{
|
||||
kind: "damage",
|
||||
coefficient: 1,
|
||||
bonusPerSunderStack: rank.effects.find((effect) => effect.effectType === 121)?.basePoints ?? 0,
|
||||
},
|
||||
{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: "wow335:warrior:sunder-armor", name: "Sunder Armor", disposition: "debuff", durationMs: 30_000,
|
||||
maxStacks: 5, stackBehavior: "add",
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.04, perStack: true }],
|
||||
},
|
||||
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
|
||||
},
|
||||
];
|
||||
case "warrior:Last Stand": return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "wow335:warrior:last-stand", name: abilityName, disposition: "buff",
|
||||
durationMs: 20_000, maxStacks: 1, stackBehavior: "refresh",
|
||||
modifiers: [{ kind: "stat", stat: "maximum-health", operation: "percent", value: 0.3 }],
|
||||
},
|
||||
sourceEffectType: 6, sourceAuraType: 133, miscValue: 0, triggerSpellId: 0,
|
||||
}];
|
||||
case "warrior:Sunder Armor": return [{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: `wow335:${rank.spellId}:sunder-armor`, name: abilityName, disposition: "debuff", durationMs: 30_000,
|
||||
id: "wow335:warrior:sunder-armor", name: abilityName, disposition: "debuff", durationMs: 30_000,
|
||||
maxStacks: 5, stackBehavior: "add",
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.04, perStack: true }],
|
||||
},
|
||||
@@ -793,6 +828,11 @@ function classScriptEffects(
|
||||
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
|
||||
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
|
||||
case "hunter:Readiness": return [{ kind: "cooldown-reset", mode: "reset" }];
|
||||
case "hunter:Chimera Shot": return [{ kind: "damage", coefficient: 1.25 }];
|
||||
case "hunter:Kill Shot": return [{
|
||||
kind: "damage", coefficient: 2,
|
||||
bonusFlat: rank.effects.find((effect) => effect.effectType === 17)?.basePoints ?? 0,
|
||||
}];
|
||||
// Volley's player-facing spell is a six-second channel aura whose Arcane
|
||||
// pulses live in same-rank triggered child rows. Keep the channel/cost but
|
||||
// make the six ground-area pulses executable by players and companions.
|
||||
@@ -807,7 +847,7 @@ function classScriptEffects(
|
||||
tag: "volley",
|
||||
}] : [];
|
||||
}
|
||||
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
|
||||
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }, { kind: "combo", amount: 1 }];
|
||||
case "rogue:Killing Spree": return [{
|
||||
kind: "dot", coefficient: 0.5, ticks: 5, intervalMs: 500, tag: "killing-spree",
|
||||
}];
|
||||
@@ -843,7 +883,8 @@ function classScriptEffects(
|
||||
durationMs: rank.durationMs > 0 ? rank.durationMs : 20_000,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 1 }],
|
||||
modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 100 }],
|
||||
utilityTags: ["next-offensive-critical"],
|
||||
},
|
||||
recipient: "caster",
|
||||
sourceEffectType: 6,
|
||||
@@ -887,10 +928,17 @@ function classScriptEffects(
|
||||
// trigger spells. Keep the imported cast/cost/rank data and supply only the
|
||||
// missing executable payload.
|
||||
case "priest:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
|
||||
case "priest:Penance": return [{ kind: "heal", coefficient: 1.8 }];
|
||||
case "priest:Penance": return [
|
||||
{ kind: "heal", coefficient: 0.6 },
|
||||
{ kind: "hot", coefficient: 0.6, ticks: 2, intervalMs: 1_000 },
|
||||
{ kind: "damage", coefficient: 0.6 },
|
||||
{ kind: "dot", coefficient: 0.6, ticks: 2, intervalMs: 1_000, tag: "penance" },
|
||||
];
|
||||
case "priest:Divine Hymn": return [{
|
||||
kind: "heal", coefficient: 1, scope: "party", center: "caster", radius: 40, maximumTargets: 3,
|
||||
}];
|
||||
case "priest:Shadowfiend": return [
|
||||
{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 },
|
||||
{ kind: "resource", resource: "mana", amount: 30, percentage: true },
|
||||
];
|
||||
case "priest:Inner Focus": return [{
|
||||
kind: "apply-aura",
|
||||
@@ -971,6 +1019,18 @@ function classScriptEffects(
|
||||
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||
case "rogue:Tricks of the Trade": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||
case "mage:Blink": return [{ kind: "movement", movement: "leap", toward: "away" }];
|
||||
case "mage:Combustion": return [{
|
||||
kind: "apply-aura", recipient: "caster",
|
||||
aura: {
|
||||
id: "wow335:mage:combustion", name: abilityName, disposition: "buff",
|
||||
durationMs: null, maxStacks: 100, stackBehavior: "replace",
|
||||
utilityTags: ["fire-crit-stacking"],
|
||||
},
|
||||
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
|
||||
}];
|
||||
case "mage:Mirror Image": return Array.from({ length: 3 }, () => ({
|
||||
kind: "summon" as const, creatureId: 58836, coefficient: 0.25, durationMs: 30_000,
|
||||
}));
|
||||
// Arcane Missiles stores its per-tick payload in a server-triggered child spell
|
||||
// that is not a player-facing SkillLine entry. Model the channel payload here so
|
||||
// both players and shared-rotation consumers execute the imported spell instead
|
||||
@@ -1275,7 +1335,9 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
}
|
||||
const auraOnlyStance = classId === "death-knight"
|
||||
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
|
||||
if (!auraOnlyStance && !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
||||
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
|
||||
&& !(classId === "mage" && abilityName === "Molten Armor")
|
||||
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
||||
if (classId === "death-knight" && abilityName === "Death Coil") {
|
||||
effects.unshift({ kind: "damage", coefficient: 1 });
|
||||
} else if (/\b(?:heals?|restore|mend|rejuvenat)\w*\b/i.test(rank.description)) {
|
||||
@@ -1293,6 +1355,18 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (classId === "priest" && abilityName === "Penance"
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (classId === "priest" && abilityName === "Divine Hymn" && effect.kind === "heal")
|
||||
|| (classId === "warrior" && ["Devastate", "Last Stand"].includes(abilityName)
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (classId === "hunter" && abilityName === "Chimera Shot" && effect.kind === "damage")
|
||||
|| (classId === "hunter" && abilityName === "Kill Shot" && effect.kind === "damage")
|
||||
|| (classId === "priest" && abilityName === "Shadowfiend"
|
||||
&& (effect.kind === "summon" || effect.kind === "trigger-spell"))
|
||||
|| (classId === "mage" && abilityName === "Combustion"
|
||||
&& (effect.kind === "damage" || effect.kind === "apply-aura" || effect.kind === "trigger-spell"))
|
||||
|| (classId === "mage" && abilityName === "Mirror Image"
|
||||
&& (effect.kind === "summon" || effect.kind === "trigger-spell"))
|
||||
|| (classId === "rogue" && abilityName === "Cold Blood" && effect.kind === "apply-aura")
|
||||
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|
||||
|| (classId === "warrior" && abilityName === "Bladestorm" && effect.kind === "dot")
|
||||
|| (classId === "paladin" && abilityName === "Hammer of the Righteous" && effect.kind === "damage")
|
||||
@@ -1443,9 +1517,20 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
const sourceRanks = chain.ranks.some((rank) => rank.rank > 0)
|
||||
? chain.ranks.filter((rank) => rank.rank > 0)
|
||||
: chain.ranks;
|
||||
const ranks = sourceRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
|
||||
// Holy Nova's DBC chain stores damage and healing as separate same-rank
|
||||
// spells. The damage spell is the cast (and pays mana); its healing partner
|
||||
// is a second payload of that same cast, not another selectable rank.
|
||||
const castRanks = classId === "priest" && chain.name === "Holy Nova"
|
||||
? sourceRanks.filter((rank) => rank.effects.some((effect) => effect.effectType === 2))
|
||||
.map((rank) => {
|
||||
const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank
|
||||
&& candidate.effects.some((effect) => effect.effectType === 10));
|
||||
return healing ? { ...rank, effects: [...rank.effects, ...healing.effects] } : rank;
|
||||
})
|
||||
: sourceRanks;
|
||||
const ranks = castRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
|
||||
const first = ranks[0];
|
||||
const firstRaw = sourceRanks[0];
|
||||
const firstRaw = castRanks[0];
|
||||
if (!first || !firstRaw) return null;
|
||||
const target = abilityNameTarget(chain.name) ?? targetForRank(firstRaw, first.effects);
|
||||
const radius = Math.max(
|
||||
@@ -1506,7 +1591,8 @@ function abilityNameRadius(abilityName: string): number {
|
||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead", "Fan of Knives"].includes(abilityName)) return "self";
|
||||
if (abilityName === "Volley") return "hostile";
|
||||
if (abilityName === "Penance") return "friendly";
|
||||
if (abilityName === "Penance") return "any";
|
||||
if (["Last Stand", "Enraged Regeneration", "Molten Armor", "Combustion"].includes(abilityName)) return "self";
|
||||
if (abilityName === "Death Coil") return "hostile";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,11 @@ describe("combat-effect runtime budget", () => {
|
||||
], "high");
|
||||
expect(timed.map(({ event: active }) => active.phase)).toEqual(["release"]);
|
||||
expect(originalInstantCastEvent(timed[0].event)).toBeNull();
|
||||
expect(originalInstantCastEvent(event(5, {
|
||||
phase: "release",
|
||||
instantCast: true,
|
||||
abilityId: "wow335-priest-holy-nova",
|
||||
}))).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves source terminal semantics for instant impact and aura fallbacks", () => {
|
||||
|
||||
@@ -152,7 +152,8 @@ export function addOriginalInstantCastEffect(
|
||||
export function originalInstantCastEvent(
|
||||
event: CombatPresentationEvent,
|
||||
): CombatPresentationEvent | null {
|
||||
if (event.phase !== "release" || !event.instantCast) return null;
|
||||
if (event.phase !== "release" || !event.instantCast
|
||||
|| event.abilityId === "wow335-priest-holy-nova") return null;
|
||||
return {
|
||||
...event,
|
||||
id: -event.id,
|
||||
|
||||
@@ -183,7 +183,7 @@ export function CharacterCreateScreen() {
|
||||
<div className="class-summary">
|
||||
<strong style={{ color: characterClass.color }}>{characterClass.name}</strong>
|
||||
<span>{characterClass.description}</span>
|
||||
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}</small>
|
||||
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Skills unlock by level"}</small>
|
||||
</div>
|
||||
{specializationOptions.length > 0 && (
|
||||
<div className="creator-specializations" aria-label="Specialization">
|
||||
|
||||
@@ -68,12 +68,15 @@ export function DungeonSelectScreen() {
|
||||
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const dungeonSelectionId = useShellStore((state) => state.dungeonSelectionId);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||
const character = activeCharacter ?? rosterCharacter;
|
||||
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
|
||||
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.id === dungeonSelectionId)
|
||||
?? DUNGEON_CATALOG.find((dungeon) => dungeon.available)
|
||||
?? DUNGEON_CATALOG[0];
|
||||
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
|
||||
const [selectedDungeonId, setSelectedDungeonId] = useState<DungeonId | "">(firstAvailable?.id ?? "");
|
||||
const [rolePickerOpen, setRolePickerOpen] = useState(false);
|
||||
|
||||
@@ -83,9 +83,9 @@ export function GameplayPanels() {
|
||||
known: !abilityTalentNodeId(catalogAbility) || Boolean(learnedTalentRank),
|
||||
talentLocked: Boolean(abilityTalentNodeId(catalogAbility)),
|
||||
passive: ability.passive,
|
||||
rank: activeRank?.rank,
|
||||
rankCount: catalogAbility.ranks?.length,
|
||||
ranks: catalogAbility.ranks?.map((rank) => ({
|
||||
rank: ability.source === "runewaker" ? undefined : activeRank?.rank,
|
||||
rankCount: ability.source === "runewaker" ? undefined : catalogAbility.ranks?.length,
|
||||
ranks: ability.source === "runewaker" ? undefined : catalogAbility.ranks?.map((rank) => ({
|
||||
rank: rank.rank,
|
||||
level: rank.level,
|
||||
spellId: rank.spellId,
|
||||
|
||||
+43
-16
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useMemo, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
@@ -28,8 +28,22 @@ const GAME_MODES = [
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "randomized-bite-sized",
|
||||
id: "random-dungeon",
|
||||
number: "02",
|
||||
name: "Random Dungeon",
|
||||
description: "Let fate choose an available full dungeon from WoW or RoM.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "random-bite-sized",
|
||||
number: "03",
|
||||
name: "Random Regular Bite-Sized Dungeon",
|
||||
description: "Choose a short authored dungeon with its regular enemies and boss rules.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "randomized-bite-sized",
|
||||
number: "04",
|
||||
name: "Randomized Bite-Sized",
|
||||
description: "Run a short dungeon with every mob and the final boss rerolled.",
|
||||
available: true,
|
||||
@@ -114,6 +128,8 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
const openDungeons = useShellStore((state) => state.openDungeons);
|
||||
const openRandomDungeon = useCallback(() => openDungeons("random"), [openDungeons]);
|
||||
const openRandomBiteSizedDungeon = useCallback(() => openDungeons("random-bite-sized"), [openDungeons]);
|
||||
const openManastorms = useShellStore((state) => state.openManastorms);
|
||||
const openArenas = useShellStore((state) => state.openArenas);
|
||||
const openClassHall = useShellStore((state) => state.openClassHall);
|
||||
@@ -137,12 +153,22 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
{
|
||||
id: "mode-dungeons",
|
||||
run: openDungeons,
|
||||
neighbors: { up: "back-to-roster", right: "mode-randomized-bite-sized" },
|
||||
neighbors: { up: "back-to-roster", right: "mode-random-dungeon", down: "mode-random-bite-sized" },
|
||||
},
|
||||
{
|
||||
id: "mode-random-dungeon",
|
||||
run: openRandomDungeon,
|
||||
neighbors: { up: "back-to-roster", left: "mode-dungeons", down: "mode-randomized-bite-sized" },
|
||||
},
|
||||
{
|
||||
id: "mode-random-bite-sized",
|
||||
run: openRandomBiteSizedDungeon,
|
||||
neighbors: { up: "mode-dungeons", right: "mode-randomized-bite-sized" },
|
||||
},
|
||||
{
|
||||
id: "mode-randomized-bite-sized",
|
||||
run: onOpenRandomizedBiteSized,
|
||||
neighbors: { up: "back-to-roster", left: "mode-dungeons" },
|
||||
neighbors: { up: "mode-random-dungeon", left: "mode-random-bite-sized" },
|
||||
},
|
||||
...(character ? [
|
||||
{ id: "mode-class-hall", run: openClassHall },
|
||||
@@ -158,7 +184,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
] : []),
|
||||
{ id: "mode-downloads", run: onOpenDownloads },
|
||||
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
|
||||
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
columns: 2,
|
||||
initialId: gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
|
||||
@@ -168,6 +194,15 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
const specialization = specializationById(character?.specializationId);
|
||||
const secondaryClass = character?.secondaryClassId ? classById(character.secondaryClassId) : null;
|
||||
const gameModeAction = (id: string) => {
|
||||
if (id === "manastorms") return openManastorms;
|
||||
if (id === "arenas") return openArenas;
|
||||
if (id === "downloads") return onOpenDownloads;
|
||||
if (id === "random-dungeon") return openRandomDungeon;
|
||||
if (id === "random-bite-sized") return openRandomBiteSizedDungeon;
|
||||
if (id === "randomized-bite-sized") return onOpenRandomizedBiteSized;
|
||||
return () => openDungeons();
|
||||
};
|
||||
const renderGameMode = (mode: (typeof GAME_MODES)[number]) => mode.available ? (
|
||||
<ControllerButton
|
||||
key={mode.id}
|
||||
@@ -176,15 +211,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available"
|
||||
type="button"
|
||||
onClick={mode.id === "manastorms"
|
||||
? openManastorms
|
||||
: mode.id === "arenas"
|
||||
? openArenas
|
||||
: mode.id === "downloads"
|
||||
? onOpenDownloads
|
||||
: mode.id === "randomized-bite-sized"
|
||||
? onOpenRandomizedBiteSized
|
||||
: openDungeons}
|
||||
onClick={gameModeAction(mode.id)}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
<span><strong>{mode.name}</strong><small>{mode.description}</small></span>
|
||||
@@ -247,7 +274,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
<section className="main-menu-card main-menu-card--adventures" aria-labelledby="main-menu-adventures-title">
|
||||
<header><span>01</span><strong id="main-menu-adventures-title">Adventures</strong></header>
|
||||
<div className="main-menu-card__grid">
|
||||
{GAME_MODES.filter((mode) => mode.id === "dungeons" || mode.id === "randomized-bite-sized").map(renderGameMode)}
|
||||
{GAME_MODES.filter((mode) => ["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"].includes(mode.id)).map(renderGameMode)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -280,7 +307,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm-menu" type="button" onClick={() => setGmMenuOpen(true)}>
|
||||
<b aria-hidden="true">GM</b><span><strong>GM Menu</strong><small>Testing, readiness, reports, and balance tools.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
{GAME_MODES.filter((mode) => mode.id !== "dungeons" && mode.id !== "randomized-bite-sized").map(renderGameMode)}
|
||||
{GAME_MODES.filter((mode) => !["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"].includes(mode.id)).map(renderGameMode)}
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { abilityAtLevel, abilityRankForLevel, type AbilityDefinition } from "../game/abilityCatalog";
|
||||
import { abilityAtLevel, type AbilityDefinition } from "../game/abilityCatalog";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
@@ -41,11 +41,11 @@ export function RomSkillsPanel({ open, onClose }: { open?: boolean; onClose?: ()
|
||||
<div className="modal-backdrop game-panel-backdrop" role="presentation">
|
||||
<section className="game-panel rom-skills-panel" role="dialog" aria-modal="true" aria-labelledby="rom-skills-title">
|
||||
<header className="game-panel__header"><div><p>Runes of Magic / Automatic progression</p><h2 id="rom-skills-title">Skills</h2></div><ControllerButton controlId="rom-skills-close" selectedId={controller.selectedId} select={controller.select} type="button" onClick={close}>Close</ControllerButton></header>
|
||||
<p className="rom-skills-panel__intro"><strong>{primary.name}{secondary ? ` / ${secondary.name}` : ""}</strong><span>Ranks automatically use the highest source rank available at level {level}. No training-point allocation is required.</span></p>
|
||||
<p className="rom-skills-panel__intro"><strong>{primary.name}{secondary ? ` / ${secondary.name}` : ""}</strong><span>Skills unlock automatically as you level. There are no talent points or spell ranks to assign.</span></p>
|
||||
<div className="rom-skills-groups">
|
||||
{GROUPS.map((group) => {
|
||||
{GROUPS.filter((group) => abilities.some((ability) => visibleGroup(ability, classId) === group.id)).map((group) => {
|
||||
const entries = abilities.filter((ability) => visibleGroup(ability, classId) === group.id);
|
||||
return <section key={group.id}><header><h3>{group.label}</h3><span>{entries.filter((ability) => level >= ability.unlockLevel).length} / {entries.length}</span></header><div>{entries.map((catalogAbility) => { const ability = abilityAtLevel(catalogAbility, level); const rank = abilityRankForLevel(catalogAbility, level); const locked = level < catalogAbility.unlockLevel; return <ControllerButton key={ability.id} controlId={`rom-skill-${ability.id}`} selectedId={controller.selectedId} select={controller.select} className={`rom-skill-card ${locked ? "is-locked" : ""}`} type="button"><img src={ability.icon} alt=""/><span><strong>{ability.name}</strong><small>{locked ? `Unlocks at level ${ability.unlockLevel}` : `Rank ${rank?.rank ?? 1}${ability.secondaryClassId ? ` / ${secondary?.name ?? "Elite"}` : ""}`}</small></span><em>{ability.passive ? "Passive" : ability.cost.amount ? `${ability.cost.amount} ${ability.cost.resource}` : "No cost"}</em></ControllerButton>; })}</div></section>;
|
||||
return <section key={group.id}><header><h3>{group.label}</h3><span>{entries.filter((ability) => level >= ability.unlockLevel).length} / {entries.length}</span></header><div>{entries.map((catalogAbility) => { const ability = abilityAtLevel(catalogAbility, level); const locked = level < catalogAbility.unlockLevel; return <ControllerButton key={ability.id} controlId={`rom-skill-${ability.id}`} selectedId={controller.selectedId} select={controller.select} className={`rom-skill-card ${locked ? "is-locked" : ""}`} type="button"><img src={ability.icon} alt=""/><span><strong>{ability.name}</strong><small>{locked ? `Unlocks at level ${ability.unlockLevel}` : ability.secondaryClassId ? `Elite / ${secondary?.name ?? "Secondary class"}` : "Learned"}</small></span><em>{ability.passive ? "Passive" : ability.cost.amount ? `${ability.cost.amount} ${ability.cost.resource}` : "No cost"}</em></ControllerButton>; })}</div></section>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("gameplay panel loading integration", () => {
|
||||
|
||||
it("groups primary adventures separately and keeps testing utilities in the GM menu", () => {
|
||||
expect(mainMenuSource).toContain('className="main-menu-card main-menu-card--adventures"');
|
||||
expect(mainMenuSource).toContain('mode.id === "dungeons" || mode.id === "randomized-bite-sized"');
|
||||
expect(mainMenuSource).toContain('["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"]');
|
||||
expect(mainMenuSource).toContain('controlId="mode-gm-menu"');
|
||||
for (const controlId of [
|
||||
"gm-menu-gm-tools",
|
||||
|
||||
Reference in new issue
Block a user