Compare commits

...
7 Commits
54 changed files with 152315 additions and 186 deletions

No files matched your search

+56
View File
@@ -3764,3 +3764,59 @@ Vite's existing large-chunk advisory remains informational.
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.
## RoM leveling loot and package catalog (2026-09-23)
- Rebuilt the client and reran `node scripts/playtest-rom-five.cjs` after the
leveling boss-loot rule and source package catalog changes. The live Rogue
roster preview showed the complete body and starter blade silhouette;
the in-dungeon scene showed the player and companion bodies with weapon,
shield, and staff silhouettes. No browser page errors occurred. Evidence:
`artifacts/playtest/rom-five/roster-preview.png` and
`artifacts/playtest/rom-five/dungeon-rogue.png`.
- This level-1 scene checks rendering. Focused boss-card, SQL loot, RuneWaker
loot, and RoM class tests check that leveling boss item drops are empty,
cards remain granted, and secondary stat growth uses the primary level.
Source package contents are cataloged as unverified and were not granted.
## RoM heirloom reference atlas (2026-09-23)
- Rebuilt the client and reran `node scripts/playtest-rom-five.cjs` after adding
the source-backed 1–79 native-stat references. The live Rogue roster showed
its body and starter blade; the Dwarf Mage roster showed its body and staff.
Wailing Caverns showed the Rogue player and companion bodies with equipped
blade, shield, and staff silhouettes. No browser page errors occurred.
Evidence: `artifacts/playtest/rom-five/roster-preview.png`,
`dwarf-mage-roster.png`, and `dungeon-rogue.png` in the same directory.
- These references are not equipped by the runtime, so this level-1 visual check
confirms the existing character path still renders. The reference suite
verifies the five class curves and source IDs at every level 1–79.
## Five-class RoM hybrid equipment (2026-09-23)
- Rebuilt and reran `node scripts/playtest-rom-five.cjs` after changing the five
active heirloom layouts to legal RoM weapon combinations with WoW combat
stats. The Rogue and Dwarf Mage roster previews showed complete bodies and
starter blade/staff silhouettes. Wailing Caverns showed the player and
companion bodies with visible blade, mace, shield, and staff silhouettes;
there were no browser page errors. Evidence: `artifacts/playtest/rom-five/`
`roster-preview.png`, `dwarf-mage-roster.png`, and `dungeon-rogue.png`.
- At level 1, the screenshot does not confirm each member's full inventory or
the level-10 secondary unlock. `src/game/romFiveValidation.test.ts` checks
every hybrid weapon slot for legality and stat growth at levels 1, 20, 40,
60, and 79. Targeted Shadoj reports at levels 20, 60, and 79 are under
`artifacts/balance-lab/rom-hybrid-l*-shadoj/`; they exposed low Mage damage
and a level-79 Priest death in each seed, so balance remains Needs Testing.
## RoM independent weapon slots (2026-09-23)
- Rebuilt and ran `node scripts/playtest-rom-five.cjs` after adding the trained
off-hand and ranged heirlooms. The Rogue roster preview and Wailing Caverns
scene showed a complete character body and starter blade; companion bodies,
shields, and weapons remained visible. The Dwarf Mage roster still showed its
body and two-hand staff. The playtest reported no browser page errors.
Evidence: `artifacts/playtest/rom-five/roster-preview.png`,
`dungeon-rogue.png`, and `dwarf-mage-roster.png` in the same directory.
- These level-1 screenshots do not show the later training unlocks. Focused
equipment tests verify the Knight/Scout bow at level 10, Rogue projectile at
level 12, and Scout/Rogue and Rogue off-hand weapons at level 16.
+24 -8
View File
@@ -1,10 +1,10 @@
# 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.
Status: **Needs Testing**. This is a targeted diagnostic for Priest healer, Knight tank, Scout DPS, Rogue DPS, and Mage DPS. The accepted pair elite skills have focused source and runtime checks, with specific unresolved mechanics listed below.
## 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.
The five named builds and live NPCs use these race-legal pairings. Their secondary classes activate at level 10 and always share their primary class's level. 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 |
| --- | --- | --- | --- |
@@ -14,9 +14,9 @@ The five named builds and live NPCs use these race-legal pairings. Their seconda
| 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.
`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; focused checks now cover their translated effects, selected live casts, and source-conditional procs.
`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.
`npm run test:rom-five` checks the named pairings, legal races, level-10 unlock, role-specific hybrid heirloom layouts and level growth, ordinary and pair-elite ability coverage, and focused runtime mechanics. Knight is a tank seat in RoM generated parties. The equipment model has separate main-hand, off-hand, and ranged positions. Knight/Scout uses mace, shield, and bow from level 10; Priest/Knight uses mace and shield; Scout/Rogue uses dagger and bow, gaining an off-hand dagger at level 16; Rogue/Warrior gains a projectile at level 12 and an off-hand dagger at level 16; Mage/Warlock uses a two-handed staff. Priest and Mage retain empty ranged positions because their accepted pairings do not grant a ranged weapon. Their equipment uses the same level budget and WoW-shaped fields as the WoW heirlooms, allocated by RoM role. `src/game/wotlkStats.ts` gives these five WoW-equivalent base attribute, HP, and Mana curves with a 10% same-level secondary contribution from level 10. Other RoM classes retain native growth. Mage/Warlock is a RoM dwarf with the Human Mage Wrath curve as its base reference because Dwarf Mage has no Wrath 3.3.5 row.
## Findings and fixes in this pass
@@ -29,6 +29,20 @@ The five named builds and live NPCs use these race-legal pairings. Their seconda
- 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.
## Accepted pair skill checks
`src/game/romPriestKnightOverrides.ts`, `src/game/romScoutRogueOverrides.ts`, and `src/game/romMageWarlockOverrides.ts` specialize the five accepted pairs from source components. `src/game/romCombatMechanics.ts` selects their conditional cast effects; `src/game/combatStore.ts` handles proc actions, damage schools, conditional cooldowns, line and chain targeting, and timed effects. `src/game/partyRuntime.ts` installs learned NPC passive auras on their first cast and passes passive and target tags into the shared selector. The three pair validation files are part of `npm run test:rom-five`; the focused run passed 228 tests across eleven files on 2026-09-23. These include direct healing, area healing and damage, taunts, resource changes, offensive and defensive auras, crowd control, source-conditional skill checks, gear-slot unlocks, and selected live player/companion casts. Pure effect tests are evidence for translation and selection, not proof that every encounter timing is correct.
The hidden source references are now executable through their parent skills where RuneWaker treats them as replacements: Priest/Knight Wave Armor `499967`, Knight/Scout Holy Chains `499925` and Blood Arrow `499924`, Scout/Rogue Reflected Shot `499535` and Wrist Attack `499537`, Rogue/Warrior Poison Spray `499477` and Whirlwind `499481`, and Mage/Warlock Soul Pain `498746`, Plasma Arrow `498749`, Elements of Pride `498755`/`498756`, and Elemental Extraction `499812`/`499851`. Priest/Knight Devotion Halo `499966` is a separate level-70 button: it gives the whole party Wisdom and reduces other members' actual Mana spending. Poisonous Spit supplies its own Poison Powder and Poison Spray consumes the selected target's Infection poison before spreading its upgraded poison. Toxic Splash can grant Rage on Infection. The source-only Holy Chains trigger `499910` has no measurable player-facing payload in the extracted data, so no extra effect is invented.
The RuneWaker NPC object database now supplies Undead and Demon family labels through `src/game/romCreatureFamilies.ts`. Base Holy Chains targets those families; Evil Shackles opens living targets with a shorter root. Numbing Dagger rejects known Undead targets. Unknown or unregistered NPC families remain permissive. Ammo, Poison Powder, Rogue Throw projectiles, and other required bag reagents are treated as an unlimited, level-appropriate supply; casts do not consume an inventory stack.
Remaining source and runtime gaps:
1. Some source proc chances and magnitudes still use explicit hybrid estimates where the extracted source does not provide a direct WoW-stat conversion. NPC proc execution and resource side effects need more end-to-end coverage across encounters.
2. Rogue/Warrior One-Hand Axe Mastery does not benefit the current dual-dagger loadout. The newly equipped bow, projectile, and off-hand slots change the level-12-and-up gear budget, so previous balance diagnostics need repeating before promotion.
3. Mage/Warlock damage remains low relative to Scout and Rogue. Check its AI rotation, cast cadence, and WoW-equivalent scaling before adjusting encounter tuning or promoting RoM.
## 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-*`.
@@ -48,11 +62,13 @@ After the mechanics corrections, the targeted Shadoj run at level 60 with the sa
## 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.
2. Expand source-row and live NPC-cast audits beyond these corrected skills. Unknown NPC family IDs are permissive until their source records are classified; 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.
5. Verify the level-16 Rogue off-hand dagger visually from an unobstructed angle. The level-1 roster and dungeon screenshots in `artifacts/playtest/rom-five/` confirm rendered bodies and starter weapon silhouettes; they cannot show the later off-hand unlock. 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.
The RoM source catalog and generated data remain authoritative for ability descriptions and components. The older `rom-five-primary-*` results predate paired builds. The later `rom-hybrid-*` and `rom-hybrid-wowbase-*` manual reports contain the named secondary classes even though the report's specialization column reads "Primary" for the primary orientation.
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.
The chosen gameplay model uses a WoW-stat hybrid instead of requiring native RoM equipment conversion for Ready promotion. `docs/ROM_STATS_AND_GEARING.md` records the source coefficients, active hybrid rules, remaining balance risks, and the players-only RoM online group boundary. Initial three-seed Shadoj screens at levels 20, 60, and 79 defeated the boss without a wipe, but Mage damage was far below the other two DPS builds and the Priest died once in each level-79 run. Those one-encounter results do not establish balance. Leveling bosses award cards but no items. `docs/ROM_LEVELING_GEAR_PACKAGES.md` records the source-confirmed package families; exact historical contents remain unverified and would need a WoW-stat translation before grant.
After the WoW-equivalent base change, one-seed manual Shadoj screens at levels 20, 60, and 79 defeated the boss with zero deaths. At level 60, Mage dealt about 185 DPS versus Scout 971 and Rogue 1,026; at level 79 the corresponding values were about 242, 1,418, and 1,538. Reports are under `artifacts/balance-lab/rom-hybrid-wowbase-l20-shadoj`, `rom-hybrid-wowbase-l60-shadoj`, and `rom-hybrid-wowbase-l79-shadoj`. These narrow screens do not certify class or dungeon balance.
+41
View File
@@ -0,0 +1,41 @@
# RoM level 1–79 heirloom reference
Status: **source-backed design data, separate from equipped hybrid heirlooms**. `scripts/runewaker-pipeline/export-rom-heirloom-reference.mjs` generates `src/game/generated/romHeirloomReference.json` from the sibling RuneWaker client snapshot, or from `RUNEWAKER_ROOT`. Run `node scripts/runewaker-pipeline/export-rom-heirloom-reference.mjs` after refreshing `src/game/generated/romLevelingGearPackages.json`. `src/game/romHeirloomReference.ts` provides typed level and item lookups. The 5 × 79 curves cover the five selected primary classes; their secondary class always shares the primary level.
## Evidence and scope
The [RoM Item Sets index](https://runesofmagic.fandom.com/wiki/Category:Item_Sets) and [Leveling Packages index](https://runesofmagic.fandom.com/wiki/Category:Leveling_Packages) identify leveling families. The [Issac](https://runesofmagic.fandom.com/wiki/Issac%27s_%28Item_Set%29), [Savage Lands](https://runesofmagic.fandom.com/wiki/Savage_Lands_%28Item_Set%29), [Lance's Support](https://runesofmagic.fandom.com/wiki/Lance%27s_Support), and [Cloth Armor Package (Multi)](https://runesofmagic.fandom.com/wiki/Cloth_Armor_Package_%28Multi%29) pages connect sets with the level-50, 55, 60, and 65 rewards. `docs/ROM_LEVELING_GEAR_PACKAGES.md` records the quest/package IDs and distinguishes wiki claims from the client package links that remain undecoded.
The local client provides **3,026 Good-quality (blue) armor and weapon records** with required level 1–79. Their IDs, names, levels, armor/weapon types, positions, suit IDs, base effects, deterministic fixed attributes, and decoded native stats are retained as source references. Of those, 727 have attribute IDs absent from this snapshot's `addpowerobject.db`, and 98 have effects outside the mapped core stat model; they remain visible but are excluded from curve candidates. Source file SHA-256 values are embedded for reproducibility. The generated output is design evidence, not a claim that all 3,026 items are normally obtainable at their required level.
The parser uses the local `ObjectStruct.h` `GameObjectWeaponENUM`, `GameObjectArmorENUM`, and `WearEqTypeENUM`, and fixed record layouts from `armorobject.db`, `weaponobject.db`, and `addpowerobject.db`. Numeric quality 2 is the client's Good/blue tier, consistent with the [wiki quality legend](https://runesofmagic.fandom.com/wiki/Category:Items). An effect with type 7 adds its value to all five primary attributes. Only 100% fixed item attributes are summed; random attribute rolls are listed by ID without being assumed.
## Design rules
| Primary class | Armor reference | Main weapon reference | Other weapon reference |
| --- | --- | --- | --- |
| Knight/Scout | Plate | One-hand mace | Shield |
| Priest/Knight | Cloth | Two-hand staff | None in this curve |
| Scout/Rogue | Leather | Dagger | Bow |
| Rogue/Warrior | Leather | Dagger | Off-hand dagger |
| Mage/Warlock | Cloth | Two-hand staff | None |
These source-reference choices follow the [official class equipment guide](https://archive.runesofmagic.gameforge.com/en/classes.html). Scout is **leather**, not chain. Priest can also legally use a wand or mace with a shield, but this client has only one complete blue wand record at level 57. The native Priest reference curve uses the well-populated staff series; the active WoW-stat hybrid equips a mace and shield to support the Priest/Knight pairing. A shield must not be combined with a two-hand staff. The Mage/Warlock remains dwarf; race has no effect on the equipment reference table itself.
The generator chooses a median item at each observed required level using role-oriented native-stat scores, then substitutes matching 50/55/60/65 package-suit armor pieces at those milestone levels. For levels between source points it interpolates each stat; below the first point or above the last it scales from the nearest source point. It rounds positive values to at least one and retains the prior level's value when the next candidate would lower a stat. `referenceItemIds` are the points behind that level, while `packageId` identifies a matched package-suit milestone. A curve row can therefore combine retained prior stats with current source values and **is not an exact copy of its listed item**. These scoring coefficients are selection heuristics, not certified combat stat weights.
All five profiles cover head, hands, feet, chest, legs, back, waist, shoulder, neck, finger, trinket, and main hand. Off hand exists for Knight and Rogue; Scout has ranged. Source earrings are represented by the game's `trinket` reference slot. The source catalog has no matching wrist slot for this export. The game must resolve its final slot design before equipping the sets.
## Numeric cross-checks
| Source item | Required level | Confirmed decoded effects |
| --- | ---: | --- |
| Magic Robe of the Lake, ID 229164 | 65 | 1,029 DEF, 1,655 MDEF, 273 HP, 290 MATK, 100 Stamina, 67 Intelligence |
| Gale Staff, ID 213416 | 60 | 903 weapon damage, 1,094 magical weapon damage, 172 MATK, 20 Stamina, 56 Intelligence, 56 Wisdom |
| Rescuer's Blessing, ID 212999 | 50 | 440 Heal Point, 660 MATK, 1,717 magical weapon damage, 50 Wisdom; a useful healing reference but an unusually strong source item, so it is not a median staff anchor |
The [Gale Staff wiki entry](https://runesofmagic.fandom.com/wiki/Gale_Staff) provides an independent level-60 cross-check. More source IDs, including each 65 package chest, are queryable through `romSourceReferenceItem` and each curve's `referenceItemIds`.
## Use in the hybrid runtime
The active RoM runtime uses WoW-shaped heirloom stats and WoW combat formulas by design; see `docs/ROM_STATS_AND_GEARING.md`. These native source curves remain references for level progression and class identity. Feeding native values directly into the WoW path would produce meaningless balance. The five active hybrid loadouts use legal weapon layouts, a WoW-stat level budget, and no comparison against WoW boss drops. Tune Knight survival, Priest healing/sustain, and Scout/Rogue/Mage damage across levels 1–79. Keep dungeon bosses on the existing cards-only leveling policy; heirlooms and package gear should come from progression, not dungeon equipment drops. Elite skills remain out of scope.
+44
View File
@@ -0,0 +1,44 @@
# RoM leveling gear package catalog
Status: **quest choices and several armor-set families verified; exact client package expansion unverified**. This is source evidence for a RoM leveling route, not a runtime gear grant. The generated inventory is `src/game/generated/romLevelingGearPackages.json`, owned by `scripts/runewaker-pipeline/export-leveling-gear-packages.mjs`. Refresh it with `node scripts/runewaker-pipeline/export-leveling-gear-packages.mjs` against the sibling RuneWaker client snapshot, or set `RUNEWAKER_ROOT` to another snapshot. Native item-stat references and 1–79 heirloom curves are in `docs/ROM_HEIRLOOM_REFERENCE.md`.
## Confirmed quest milestones
The client `Resource/data/questdetailobject.db` links each quest to the armor-category reward packages in the order below. `itemobject.db` confirms that the later parent packages and their similarly named weapons and armor subpackages exist. The client strings describe each later parent choice as giving one weapons package and one armor package; their exact item links have not been decoded. The generated catalog records matching seven-piece source suits from `suitobject.db` and `armorobject.db`; wiki pages independently identify several as package contents, but do not establish every weapon, accessory, quantity, or version in this client snapshot.
| Quest level and name | Quest ID | Plate package | Chain package | Leather package | Cloth package |
| --- | ---: | ---: | ---: | ---: | ---: |
| 5 Equip Yourself | 423783 | — | 209025 | 209024 | 209015 |
| 30 Key to Victory | 423771 | 208802* | 208803 | 208804 | 208805 |
| 50 Appropriate Support | 424596 | 240384 | 240387 | 240390 | 240393 |
| 55 Rewards for Students | 424597 | 240353 | 240356 | 240359 | 240362 |
| 60 Lance's Support | 424598 | 240341 | 240344 | 240347 | 240350 |
| 65 Return to Dalanis | 424213 | 240304 | 240307 | 240310 | 240313 |
*The level-30 source name is **World Battlefield Bonus Armor Package**, without the word “Plate.” Its plate classification is only a likely reading of its position beside chain, leather, and cloth; the generated catalog uses `armor` until verified. This is a battlefield reward, so it is an optional source candidate rather than a guaranteed leveling grant. The level-5 quest has only three choices, and its chain choice is named **Chainmail Armor Box**. Neither early tier has confirmed individual package contents. The level-65 rewards are the Redhill Mountains Pioneer packages. The source-linked subpackage IDs, source names, matching suit IDs, seven armor member IDs, and their actual required levels for levels 50–65 are in the JSON catalog. Some level-50 suit members have required levels below 50; the quest reward level is the milestone, not an assertion that every item requires that level.
## Wiki cross-checks
The [Item Sets index](https://runesofmagic.fandom.com/wiki/Category:Item_Sets) and [Leveling Packages index](https://runesofmagic.fandom.com/wiki/Category:Leveling_Packages) give additional reference families beyond the client quests. Their set pages support these specific connections:
| Milestone | Package or set evidence | Confidence for armor pieces |
| --- | --- | --- |
| 50 | [Issac's sets](https://runesofmagic.fandom.com/wiki/Issac%27s_%28Item_Set%29) identify four seven-piece sets in the level-50 armor packages. | Wiki-confirmed family; source suit member IDs in catalog. |
| 55 | [Savage Lands sets](https://runesofmagic.fandom.com/wiki/Savage_Lands_%28Item_Set%29) identify Haz, Eduth, Luzan, and Rufa with Bodo Treasure rewards. | Wiki-confirmed family; source suit member IDs in catalog. |
| 60 | [Lance's Support](https://runesofmagic.fandom.com/wiki/Lance%27s_Support) identifies Rh'anka, Sley, Tempest, and Croso package choices; the [Tempest set](https://runesofmagic.fandom.com/wiki/Tempest_Set_%28Item_Set%29) supplies a piece-level cross-check. | Wiki-confirmed family; source suit member IDs in catalog. |
| 65 | [Cloth Armor Package (Multi)](https://runesofmagic.fandom.com/wiki/Cloth_Armor_Package_%28Multi%29) names the Lake Energy cloth set at 65; the other armor categories currently have matching source suits but need their package pages checked. | Cloth wiki-confirmed; other categories matched by source name/type/level. |
The wiki is a reference for the RoM item families, while the local RuneWaker records are the authority for item IDs and numeric stats in this snapshot. Page descriptions alone do not prove that this snapshot's package-opening script grants the same items.
## Applying these to the five classes
The current game marks Knight as plate, Priest and Mage as cloth, and Rogue and Scout as leather in `src/app/characterCatalog.ts`. The [official class guide](https://archive.runesofmagic.gameforge.com/en/classes.html) confirms that Scout wears cloth or leather, while Knight can wear plate and Priest/Mage cloth. This suggests one plate, two leather, and two cloth choices for the five-class cohort; chain remains in the source catalog but is not a default choice for these primary classes. A secondary class shares the primary's level and does not require a second package progression.
During leveling, `src/game/lootPolicy.ts` suppresses all boss item drops. Boss cards are awarded separately. Package gear should come from a level or quest milestone, never from a dungeon boss loot table. A fallback starter kit can cover levels for which no verified source package exists.
## Gaps before playable package gear
1. Locate and verify package expansion records or scripts for every parent and subpackage, including weapon selections, accessories, exact armor items, and quantities. Matching a suit by name, category, and required level is not enough to grant its pieces as historical package contents.
2. The source-backed 1–79 reference now decodes native equipment slots, weapon/armor type, base stats, and deterministic fixed attributes. The active RoM path uses WoW-stat hybrid heirlooms; historical package pieces would need a defined translation into that stat budget and legal slot/weapon checks before they become equippable rewards. A full native item-instance and combat adapter is optional future fidelity work.
3. Find a complete, generally available source-backed route from 1–49 and above level 65. Level 5 has only cloth, leather, and chain choices; level 30 has four battlefield choices. Logar/Hope package names exist in the item table, but their acquisition route and contents are not confirmed. Avoid inventing missing plate or weapon contents.
4. Make the milestone grant idempotent and persistent, then verify class eligibility, package selection, leveling through each milestone, and native-stat combat outcomes for all five builds.
+15 -9
View File
@@ -1,12 +1,12 @@
# 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.
Status: **WoW-stat hybrid chosen for gameplay; native source model retained as reference**. This audit covers the Priest/Knight healer, Knight/Scout tank, Scout/Rogue DPS, Rogue/Warrior DPS, and Mage/Warlock DPS. The game keeps RoM abilities and class pairings. The five accepted builds now use WoW-equivalent base curves, hybrid heirlooms, and WoW-derived combat stats. See `docs/ROM_HEIRLOOM_REFERENCE.md` for the client-derived blue-item atlas and native-stat curves; those numbers are reference points and are not applied directly to these five builds.
## 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/wotlkStats.ts` gives the five accepted primaries their corresponding Wrath base attribute, HP, and Mana curves. At level 10 the secondary contributes 10% of its WoW-equivalent curve **at the primary's level**. Human curves cover Priest, Knight, Rogue, and Mage; Scout uses Night Elf. Durnik remains a RoM dwarf, but Human Mage is the base-curve fallback because Wrath 3.3.5 has no Dwarf Mage row. Other RoM classes still use `src/game/romStats.ts` native growth. The shared secondary level is the game's progression rule, even though the historical source allowed separate class levels.
- `src/game/wotlkStats.ts` maps native Wisdom into WoW Spirit only on the remaining native-growth path, then derives HP, Mana, attack power, spell power, avoidance, and ratings with WoW rules. This is the selected compatibility adapter, not a historical RoM conversion. In particular, the global Mana multiplier and Stamina-to-HP rule are not source RoM formulas.
- `src/game/specializationCatalog.ts` defines distinct hybrid gear profiles for the five builds. `src/game/heirloomEquipment.ts` gives these five WoW-stat heirlooms at levels 1–79 from a level budget without matching WoW boss drops. The equipment model has independent main-hand, off-hand, and ranged slots. Knight/Scout gains a bow at level 10 alongside mace/shield; Scout/Rogue gains an off-hand dagger at 16 alongside dagger/bow; Rogue/Warrior gains a projectile at 12 and an off-hand dagger at 16. Priest/Knight uses mace/shield and Mage/Warlock uses a staff; their ranged slots remain empty. The five hybrid loadouts omit the WoW wrist slot; `src/game/equipment.ts` permits Priest shields. These are active gameplay gear, while the native source curves remain references. Other RoM classes retain their existing fallback gear path until separately validated.
- `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
@@ -38,18 +38,24 @@ The table below shows **vocation terms only** from the player records of `voctab
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
## Hybrid gameplay boundary
The five-class equipment path deliberately translates RoM roles into WoW combat inputs: Strength and Agility feed physical attack, Intelligence and Wisdom (as Spirit) feed spell and resource stats, defense/avoidance use armor and WoW ratings, and healing uses spell power and Mana sustain. The gear profile weights and heirloom budgets are design choices, not a claim that RoM native stats have the same numerical scale as WoW stats. The source item atlas can guide level progression and class identity without copying DEF, MDEF, MATK, Heal Point, or weapon damage into WoW fields one-for-one.
This reduces equipment implementation work but still requires targeted balance validation. The earlier three-seed Shadoj diagnostic with the paired cohort and active Priest defeated the boss at levels 20, 60, and 79; Mage damage remained well below Scout/Rogue, and Priest died in each level-79 run. After switching the base curves, a one-seed Shadoj screen at each of levels 20, 60, and 79 defeated the boss with zero deaths. At level 60, Mage dealt about 185 DPS versus Scout 971 and Rogue 1,026; Mage remains a large outlier. The current reports are under `artifacts/balance-lab/rom-hybrid-wowbase-l{20,60,79}-shadoj`. These are screening runs against one encounter, not balance certification. RoM remains Needs Testing; `docs/ROM_FIVE_VALIDATION.md` records the elite-skill checks and unresolved source gaps.
## Optional native-fidelity path
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.
1. Use `scripts/runewaker-pipeline/export-rom-heirloom-reference.mjs` and `src/game/generated/romHeirloomReference.json` as decoded references for native slot, armor/weapon type, base effects, deterministic fixed attributes, and required level. Preserve item IDs and investigate the 727 records with unresolved attribute IDs and any special effects before importing historical source items. The `src/game/romHeirloomReference.ts` lookup is design data and is not read by combat equipment.
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.
4. If granting historical quest or item-shop packages later, resolve exact package expansion and translate their properties into the chosen WoW-stat budget or add a separate native item path. The client snapshot confirms armor-choice quest families at levels 5, 30, 50, 55, 60, and 65; see `docs/ROM_LEVELING_GEAR_PACKAGES.md`. Dungeon bosses award cards during leveling, not equipment or miscellaneous items. Do not copy native item numbers directly into WoW ratings.
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.
`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** while live NPC-cast coverage, equipment compatibility, and the Mage damage outlier are investigated. The identified level-60/70 source replacement skills now execute through their parent abilities, and Priest/Knight Devotion Halo is a separate playable skill.
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.
Validate the hybrid with legal slot/equip tests for all five builds, stat growth across 1–79, `npm run test:rom-five`, and targeted level/encounter diagnostics. Check Knight threat and survival, Priest effective healing and sustain, and the three DPS roles separately before promotion. If package rewards are added, compare their translated upgrades and combat outcomes. A common encounter definition can use source-specific tuning coefficients; no duplicate WoW/RoM dungeon catalogs are required by this audit.
+1 -1
View File
@@ -125,7 +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",
"test:rom-five": "vitest run src/game/romFiveValidation.test.ts src/game/romPriestKnightPairValidation.test.ts src/game/romScoutRoguePairValidation.test.ts src/game/romMageWarlockPairValidation.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 src/game/romHeirloomReference.test.ts src/game/romCreatureFamilies.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",
@@ -70,25 +70,36 @@ function sourceName(id) {
// 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: [
{ level: 5, questId: 423783, packageStructure: "single-package-contents-unverified", choices: [
["cloth", 209015],
["leather", 209024],
["chain", 209025],
] },
{ level: 30, questId: 423771, packageStructure: "battlefield-package-contents-unverified", choices: [
["armor", 208802],
["chain", 208803],
["leather", 208804],
["cloth", 208805],
] },
{ level: 50, questId: 424596, packageStructure: "named-subpackages-links-unverified", 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: [
{ level: 55, questId: 424597, packageStructure: "named-subpackages-links-unverified", 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: [
{ level: 60, questId: 424598, packageStructure: "named-subpackages-links-unverified", 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: [
{ level: 65, questId: 424213, packageStructure: "named-subpackages-links-unverified", choices: [
["plate", 240304, 240302, 240303, 610387],
["chain", 240307, 240305, 240306, 610388],
["leather", 240310, 240308, 240309, 610389],
@@ -96,15 +107,18 @@ const milestones = [
] },
];
const extracted = milestones.map(({ level, questId, choices }) => {
const extracted = milestones.map(({ level, questId, packageStructure, 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));
if (rewardIds.slice(choices.length).some((id) => id !== 0)) {
throw new Error(`Quest ${questId} has an unrecorded reward choice.`);
}
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))
for (const id of [packageId, weaponPackageId, armorPackageId].filter(Boolean)) record("itemobject.db", id);
const suit = matchingSuitId ? record("suitobject.db", matchingSuitId) : null;
const matchingArmorItems = suit ? Array.from({ length: 10 }, (_, member) => suit.readInt32LE(1224 + member * 4))
.filter((id) => id > 0)
.map((itemId) => {
const item = record("armorobject.db", itemId);
@@ -116,26 +130,22 @@ const extracted = milestones.map(({ level, questId, choices }) => {
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.`);
}) : [];
if (suit && 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,
...(weaponPackageId ? { weaponPackageId, weaponPackageName: sourceName(weaponPackageId) } : {}),
...(armorPackageId ? { armorPackageId, armorPackageName: sourceName(armorPackageId) } : {}),
...(suit ? { matchingSuitId, matchingSuitName: sourceName(matchingSuitId), matchingArmorItems } : {}),
};
});
return { level, questId, questName: sourceName(questId), packages };
return { level, questId, questName: sourceName(questId), packageStructure, packages };
});
const output = {
schemaVersion: 1,
schemaVersion: 2,
contentStatus: "quest-rewards-confirmed-package-expansion-unverified",
sourceFiles: Object.fromEntries(files.map((name) => [name, createHash("sha256").update(buffers[name]).digest("hex")])),
milestones: extracted,
@@ -0,0 +1,43 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
const recipe = await readJson(path.join(projectRoot, "scripts/runewaker-pipeline/recipes/raksha-temple-population.json"));
const source = path.join(sourceRootFor(recipe), recipe.source.resourceRoot ?? "Resource", "data/npcobject.db");
const bytes = await readFile(source);
const dataStart = 140;
const count = bytes.readInt32LE(132);
const recordSize = 2_224;
const raceOffset = 176;
if (count <= 0 || dataStart + count * recordSize > bytes.length) throw new Error("Unexpected NPCObjectDB layout");
const knownIds = [];
const undeadIds = [];
const demonIds = [];
for (let index = 0; index < count; index += 1) {
const row = dataStart + index * recordSize;
if (bytes.readInt32LE(row + 4) !== 1) throw new Error(`Unexpected NPC object type at row ${index}`);
const id = bytes.readInt32LE(row);
if (id <= 0) continue;
knownIds.push(id);
const race = bytes.readInt32LE(row + raceOffset);
// ObjectStruct.h Race_ENUM names value 5 Undead. The source Demon Soldier
// and Demon Bodo NPC records use value 12.
if (race === 5) undeadIds.push(id);
if (race === 12) demonIds.push(id);
}
const sorted = (ids) => [...new Set(ids)].sort((left, right) => left - right);
const output = {
schemaVersion: 1,
sourceSha256: createHash("sha256").update(bytes).digest("hex"),
raceOffset,
knownIds: sorted(knownIds),
undeadIds: sorted(undeadIds),
demonIds: sorted(demonIds),
};
await writeFile(
path.join(projectRoot, "src/game/generated/romCreatureFamilies.json"),
`${JSON.stringify(output)}\n`,
);
console.log(`Exported ${output.knownIds.length} NPC families (${output.undeadIds.length} undead, ${output.demonIds.length} demon).`);
@@ -0,0 +1,268 @@
#!/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", "romHeirloomReference.json");
const packagePath = path.join(projectRoot, "src", "game", "generated", "romLevelingGearPackages.json");
const files = ["armorobject.db", "weaponobject.db", "addpowerobject.db", "string_enus.db"];
const buffers = Object.fromEntries(await Promise.all(files.map(async (name) => (
[name, await readFile(path.join(dataRoot, name))]
))));
const packageCatalog = JSON.parse(await readFile(packagePath, "utf8"));
const RECORD_START = 140;
const RECORD_SIZE = 1776;
const ATTRIBUTE_RECORD_SIZE = 372;
const QUALITY_GOOD_BLUE = 2;
const STAT_TYPES = Object.freeze({
2: "strength", 3: "stamina", 4: "intellect", 5: "wisdom", 6: "agility",
8: "health", 9: "mana", 10: "healthRecovery", 11: "manaRecovery",
12: "physicalAttack", 13: "physicalDefense", 14: "magicalDefense", 15: "magicalAttack",
16: "physicalHit", 17: "dodge", 18: "physicalCritical", 20: "magicalCritical",
22: "parry", 25: "weaponDamage", 148: "magicalDamage", 150: "healingPower",
191: "magicalWeaponDamage", 195: "magicalHit",
});
const STAT_KEYS = [...new Set(Object.values(STAT_TYPES))];
const ARMOR_SLOTS = { head: 0, hands: 1, feet: 2, chest: 3, legs: 4, back: 5, waist: 6, shoulder: 7 };
const COMMON_SLOTS = Object.keys(ARMOR_SLOTS);
const PROFILES = {
"rom-knight": { armorType: 0, weaponType: 5, weaponPos: 2, offHand: "shield", weights: { stamina: 5, health: 0.2, physicalDefense: 0.12, magicalDefense: 0.06, parry: 2, physicalAttack: 0.15 } },
"rom-priest": { armorType: 3, weaponType: 7, weaponPos: 3, weights: { wisdom: 5, intellect: 2, healingPower: 1.5, mana: 0.1, magicalAttack: 0.3, magicalDefense: 0.02 } },
"rom-scout": { armorType: 2, weaponType: 2, weaponPos: 2, rangedType: 11, weights: { strength: 3, agility: 2, physicalAttack: 1, weaponDamage: 1, physicalHit: 1, physicalCritical: 1 } },
"rom-rogue": { armorType: 2, weaponType: 2, weaponPos: 2, offHand: "dagger", weights: { agility: 3, strength: 2, physicalAttack: 1, weaponDamage: 1, physicalHit: 1, physicalCritical: 1 } },
"rom-mage": { armorType: 3, weaponType: 7, weaponPos: 3, weights: { intellect: 5, wisdom: 1, magicalAttack: 1, magicalDamage: 1, magicalWeaponDamage: 1, magicalCritical: 1 } },
};
function indexNames(buffer) {
const text = buffer.toString("latin1");
const positions = new Map();
for (const match of text.matchAll(/Sys(\d+)_name\0/g)) positions.set(Number(match[1]), match.index + match[0].length);
return positions;
}
const namePositions = indexNames(buffers["string_enus.db"]);
function sourceName(id) {
const buffer = buffers["string_enus.db"];
const position = namePositions.get(id);
if (position === undefined) throw new Error(`Missing English item name for ${id}.`);
for (let offset = position; offset < Math.min(buffer.length - 4, position + 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 { return new TextDecoder("utf-8", { fatal: true }).decode(value); } catch { /* metadata before the text */ }
}
throw new Error(`Could not decode English item name for ${id}.`);
}
function effectStats(buffer, offset, unmappedEffects = []) {
const stats = {};
for (let effect = 0; effect < 10; effect += 1) {
const type = buffer.readInt32LE(offset + 0xd8 + effect * 4);
const value = buffer.readInt32LE(offset + 0x100 + effect * 4);
if (type === 7 && value > 0) {
for (const key of ["strength", "stamina", "intellect", "wisdom", "agility"]) {
stats[key] = (stats[key] ?? 0) + value;
}
continue;
}
const key = STAT_TYPES[type];
if (key && value > 0) stats[key] = (stats[key] ?? 0) + value;
else if (type && value > 0) unmappedEffects.push({ type, value });
}
return stats;
}
const attributeBuffer = buffers["addpowerobject.db"];
const attributeCount = attributeBuffer.readInt32LE(132);
if (attributeCount < 1 || RECORD_START + attributeCount * ATTRIBUTE_RECORD_SIZE > attributeBuffer.length) {
throw new Error("addpowerobject.db fixed-record layout changed.");
}
const attributes = new Map();
for (let index = 0; index < attributeCount; index += 1) {
const offset = RECORD_START + index * ATTRIBUTE_RECORD_SIZE;
if (attributeBuffer.readInt32LE(offset + 4) !== 5) throw new Error(`Attribute object type changed at row ${index}.`);
const unmappedEffects = [];
const stats = effectStats(attributeBuffer, offset, unmappedEffects);
attributes.set(attributeBuffer.readInt32LE(offset), { stats, unmappedEffects });
}
function sourceItems(name) {
const buffer = buffers[name];
const count = buffer.readInt32LE(132);
if (count < 1 || RECORD_START + count * RECORD_SIZE > buffer.length) {
throw new Error(`${name} fixed-record layout changed.`);
}
const items = [];
for (let index = 0; index < count; index += 1) {
const offset = RECORD_START + index * RECORD_SIZE;
if (buffer.readInt32LE(offset + 4) !== 2) throw new Error(`${name} object type changed at row ${index}.`);
const level = buffer.readInt32LE(offset + 0x78);
const quality = buffer.readInt32LE(offset + 0x60);
if (level < 1 || level > 79 || quality !== QUALITY_GOOD_BLUE) continue;
const itemId = buffer.readInt32LE(offset);
const unmappedEffects = [];
const baseStats = effectStats(buffer, offset, unmappedEffects);
const fixedStats = {};
const fixedAttributeIds = [];
const randomAttributeIds = [];
const unresolvedAttributeIds = [];
for (let effect = 0; effect < 6; effect += 1) {
const attributeId = buffer.readInt32LE(offset + 0x134 + effect * 8);
const rate = buffer.readInt32LE(offset + 0x138 + effect * 8);
if (!attributeId) continue;
if (!attributes.has(attributeId)) {
unresolvedAttributeIds.push(attributeId);
continue;
}
if (rate !== 100_000) {
randomAttributeIds.push(attributeId);
continue;
}
fixedAttributeIds.push(attributeId);
const attribute = attributes.get(attributeId);
for (const [key, value] of Object.entries(attribute.stats)) {
fixedStats[key] = (fixedStats[key] ?? 0) + value;
}
unmappedEffects.push(...attribute.unmappedEffects.map((entry) => ({ ...entry, attributeId })));
}
const stats = { ...baseStats };
for (const [key, value] of Object.entries(fixedStats)) stats[key] = (stats[key] ?? 0) + value;
items.push({
itemId,
name: sourceName(itemId),
level,
quality,
sourceCatalog: name,
type: buffer.readInt32LE(offset + 0xd0),
position: buffer.readInt32LE(offset + 0xd4),
suitId: buffer.readInt32LE(offset + 0x1a8),
...(name === "weaponobject.db" ? { speedTenths: buffer.readInt32LE(offset + 0x1b0) } : {}),
baseStats,
fixedStats,
fixedAttributeIds,
...(unmappedEffects.length ? { unmappedEffects } : {}),
...(randomAttributeIds.length ? { randomAttributeIds } : {}),
...(unresolvedAttributeIds.length ? { unresolvedAttributeIds } : {}),
stats,
});
}
return items;
}
const items = [...sourceItems("armorobject.db"), ...sourceItems("weaponobject.db")]
.sort((left, right) => left.level - right.level || left.itemId - right.itemId);
const byId = new Map(items.map((item) => [item.itemId, item]));
const packageAnchors = new Map();
for (const milestone of packageCatalog.milestones) {
if (milestone.level < 50 || milestone.level > 65) continue;
for (const choice of milestone.packages) {
for (const member of choice.matchingArmorItems ?? []) {
const item = byId.get(member.itemId);
if (!item || item.quality !== 2) throw new Error(`Package suit member ${member.itemId} is not Good quality source gear.`);
if (item.unresolvedAttributeIds?.length || item.unmappedEffects?.length) continue;
packageAnchors.set(`${milestone.level}:${item.type}:${item.position}`, {
...item,
packageId: choice.packageId,
packageLevel: milestone.level,
});
}
}
}
function medianItem(candidates, weights) {
const scored = candidates.map((item) => ({
item,
score: Object.entries(weights).reduce((sum, [key, weight]) => sum + (item.stats[key] ?? 0) * weight, 0),
})).sort((left, right) => left.score - right.score || left.item.itemId - right.item.itemId);
return scored[Math.floor((scored.length - 1) / 2)].item;
}
function referenceCurve(candidates, weights, packageType = null, packagePosition = null) {
if (!candidates.length) throw new Error("A RoM heirloom slot has no Good quality source references.");
const byLevel = new Map();
for (const item of candidates) {
const group = byLevel.get(item.level) ?? [];
group.push(item);
byLevel.set(item.level, group);
}
const anchors = new Map([...byLevel].map(([level, group]) => [level, medianItem(group, weights)]));
if (packageType !== null) for (const level of [50, 55, 60, 65]) {
const item = packageAnchors.get(`${level}:${packageType}:${packagePosition}`);
if (item) anchors.set(level, item);
}
const anchorLevels = [...anchors.keys()].sort((left, right) => left - right);
const result = [];
const relevant = new Set([
...Object.keys(weights), "stamina", "health", "physicalDefense", "magicalDefense",
"weaponDamage", "magicalWeaponDamage",
]);
const relevantKeys = STAT_KEYS.filter((key) => relevant.has(key));
const previous = Object.fromEntries(relevantKeys.map((key) => [key, 0]));
for (let level = 1; level <= 79; level += 1) {
const lowerLevel = anchorLevels.filter((entry) => entry <= level).at(-1);
const upperLevel = anchorLevels.find((entry) => entry >= level);
const lower = anchors.get(lowerLevel ?? upperLevel);
const upper = anchors.get(upperLevel ?? lowerLevel);
const blend = lowerLevel === undefined ? level / upperLevel
: upperLevel === undefined ? level / lowerLevel
: lowerLevel === upperLevel ? 0 : (level - lowerLevel) / (upperLevel - lowerLevel);
const stats = {};
for (const key of relevantKeys) {
const value = lowerLevel === undefined ? (upper.stats[key] ?? 0) * blend
: upperLevel === undefined ? (lower.stats[key] ?? 0) * blend
: (lower.stats[key] ?? 0) * (1 - blend) + (upper.stats[key] ?? 0) * blend;
previous[key] = Math.max(previous[key], value > 0 ? Math.max(1, Math.round(value)) : 0);
if (previous[key] > 0) stats[key] = previous[key];
}
result.push({
level,
stats,
referenceItemIds: [...new Set([lower.itemId, upper.itemId])],
...(anchors.get(level)?.packageId ? { packageId: anchors.get(level).packageId } : {}),
...(lower.speedTenths || upper.speedTenths ? { speedTenths: lower.speedTenths || upper.speedTenths } : {}),
});
}
return result;
}
function armorCandidates(type, position) {
return items.filter((item) => item.sourceCatalog === "armorobject.db" && item.type === type && item.position === position && !item.unresolvedAttributeIds?.length && !item.unmappedEffects?.length);
}
function weaponCandidates(type, position) {
return items.filter((item) => item.sourceCatalog === "weaponobject.db" && item.type === type && item.position === position && !item.unresolvedAttributeIds?.length && !item.unmappedEffects?.length);
}
const profiles = Object.fromEntries(Object.entries(PROFILES).map(([classId, profile]) => {
const curves = {};
for (const slot of COMMON_SLOTS) {
const armorType = slot === "back" ? 3 : profile.armorType;
curves[slot] = referenceCurve(armorCandidates(armorType, ARMOR_SLOTS[slot]), profile.weights, armorType, ARMOR_SLOTS[slot]);
}
for (const [slot, position] of [["neck", 8], ["finger", 9], ["trinket", 10]]) {
curves[slot] = referenceCurve(armorCandidates(7, position), profile.weights);
}
curves["main-hand"] = referenceCurve(weaponCandidates(profile.weaponType, profile.weaponPos), profile.weights);
if (profile.offHand === "shield") curves["off-hand"] = referenceCurve(armorCandidates(5, 11), profile.weights);
if (profile.offHand === "dagger") curves["off-hand"] = referenceCurve(weaponCandidates(2, 2), profile.weights);
if (profile.rangedType) curves.ranged = referenceCurve(weaponCandidates(profile.rangedType, 5), profile.weights);
return [classId, curves];
}));
const output = {
schemaVersion: 1,
status: "source-base-and-fixed-effects-with-synthetic-interpolation",
sourceFiles: Object.fromEntries(files.map((name) => [name, createHash("sha256").update(buffers[name]).digest("hex")])),
quality: { sourceValue: QUALITY_GOOD_BLUE, name: "Good", color: "blue" },
statTypeMap: STAT_TYPES,
sourceItems: items,
profiles,
};
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(`Exported ${items.length} Good source items (${items.filter((item) => item.unresolvedAttributeIds?.length).length} with unresolved attributes) and ${Object.keys(profiles).length} RoM heirloom curves at levels 1–79.`);
+15
View File
@@ -26,6 +26,7 @@ const MANASTORM_ACTIVITY: OnlineGroupActivity = {
function entryDependencies(enterDungeon: () => boolean) {
return {
prepareDungeonContent: vi.fn(async () => undefined),
prepareRandomizedDungeonContent: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined),
enterDungeon: vi.fn(enterDungeon),
@@ -45,6 +46,9 @@ describe("online group activity entry", () => {
const dependencies = entryDependencies(() => true);
await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 },
"damage", vi.fn(), dependencies);
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith("deadmines");
expect(dependencies.prepareDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "damage", {
difficultyId: "heroic", bossDifficulty: 9, freshRun: true, runId: DUNGEON_ACTIVITY.id,
});
@@ -59,10 +63,21 @@ describe("online group activity entry", () => {
await enterOnlineGroupActivity(activity, "healer", vi.fn(), dependencies);
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith(activity.contentId);
expect(dependencies.prepareRandomizedDungeonContent).toHaveBeenCalledWith(activity.id);
expect(dependencies.prepareRandomizedDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
});
it("does not enter a shared dungeon when its content download fails", async () => {
const dependencies = entryDependencies(() => true);
dependencies.prepareDungeonContent.mockRejectedValueOnce(new Error("Download failed"));
const consumeActivity = vi.fn();
await expect(enterOnlineGroupActivity(DUNGEON_ACTIVITY, "healer", consumeActivity, dependencies))
.rejects.toThrow("Download failed");
expect(dependencies.enterDungeon).not.toHaveBeenCalled();
expect(consumeActivity).not.toHaveBeenCalled();
});
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => false);
+9 -1
View File
@@ -12,12 +12,14 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import { dungeonContentPackId, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies {
readonly prepareDungeonContent: (dungeonId: string) => Promise<void>;
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
@@ -26,6 +28,11 @@ interface OnlineGroupActivityEntryDependencies {
}
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
prepareDungeonContent: async (dungeonId) => {
if (dungeonContentIsUsable(dungeonId)) return;
await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The group dungeon download could not be verified.");
},
prepareRandomizedDungeonContent: async (seed) => {
const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent");
await ensureRandomizedBiteSizedRunContent(seed);
@@ -53,6 +60,7 @@ export async function enterOnlineGroupActivity(
): Promise<boolean> {
let entered: boolean;
if (activity.type === "dungeon") {
await dependencies.prepareDungeonContent(activity.contentId);
if (activity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
await dependencies.prepareRandomizedDungeonContent(activity.id);
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroup } from "./onlineGroupTypes";
import { dungeonById } from "../game/dungeonCatalog";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { queueRandomDungeon, type RandomDungeonQueueOptions } from "./randomDungeonQueue";
const character: CharacterProfile = {
id: "character-1", ownerId: "player-1", name: "Remedy", raceId: "human", classId: "priest",
specializationId: "priest:holy", categoryId: "wow", gender: "female",
appearance: createDefaultAppearance(), level: 1, experience: 0, talentRanks: {},
actionBindings: {}, inventory: [], equipment: {}, location: "", createdAt: 1, lastPlayedAt: 1,
};
const offlineSession: PlayerSession = { kind: "offline", ownerId: "player-1", displayName: "Offline" };
const accountSession: PlayerSession = { kind: "account", ownerId: "player-1", displayName: "Remedy", accessToken: "test-token" };
const group: OnlineGroup = {
id: "group-1", leaderAccountId: "player-1", members: [], activity: null,
activityRevision: 0, createdAt: 1, updatedAt: 1,
};
function queueOptions(overrides: Partial<RandomDungeonQueueOptions> = {}): RandomDungeonQueueOptions {
return {
mode: "regular", role: "healer", character, session: offlineSession, group: null,
includeBetaSpecs: false, fillWithAi: true, onProgress: vi.fn(),
enterDungeon: vi.fn(() => true), updateOnlineMember: vi.fn(async () => true),
startOnlineActivity: vi.fn(async () => true), failureNotice: () => "",
...overrides,
};
}
describe("random dungeon queue", () => {
it("downloads a rolled full dungeon before entering a fresh local run", async () => {
let installed = false;
const installPack = vi.fn(async () => { installed = true; });
const installDungeonRuntime = vi.fn(async () => undefined);
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => installed,
installPack,
installDungeonRuntime,
});
expect(result.status).toBe("entered");
const dungeonId = vi.mocked(options.enterDungeon).mock.calls[0][0];
expect(["wow", "rom"]).toContain(dungeonById(dungeonId)?.category);
expect(installPack).toHaveBeenCalledWith(dungeonContentPackId(dungeonId));
expect(installPack.mock.invocationCallOrder[0]).toBeLessThan(installDungeonRuntime.mock.invocationCallOrder[0]);
expect(installDungeonRuntime.mock.invocationCallOrder[0])
.toBeLessThan(vi.mocked(options.enterDungeon).mock.invocationCallOrder[0]);
expect(options.enterDungeon).toHaveBeenCalledWith(dungeonId, "healer", expect.objectContaining({ freshRun: true }));
expect(options.onProgress).toHaveBeenCalledWith("downloading", expect.objectContaining({ id: dungeonId }));
});
it("keeps regular Bite-Sized separate from the creature-randomized mode", async () => {
const installPack = vi.fn(async () => undefined);
const options = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => true,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
})).resolves.toMatchObject({ status: "entered" });
expect(options.enterDungeon).toHaveBeenCalledWith("bite-sized-stormwind-stockade", "healer", expect.any(Object));
expect(installPack).not.toHaveBeenCalled();
});
it("does not enter when the downloaded pack cannot be verified", async () => {
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => false,
installPack: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined),
});
expect(result).toMatchObject({ status: "error", message: expect.stringMatching(/verified/i) });
expect(options.enterDungeon).not.toHaveBeenCalled();
});
it("publishes the rolled dungeon for the online leader after installation", async () => {
let installed = false;
const options = queueOptions({ session: accountSession, group });
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => installed,
installPack: vi.fn(async () => { installed = true; }),
installDungeonRuntime: vi.fn(async () => undefined),
});
expect(result.status).toBe("started");
expect(options.updateOnlineMember).toHaveBeenCalledWith(accountSession, character, "healer");
expect(options.startOnlineActivity).toHaveBeenCalledWith(accountSession, expect.objectContaining({
type: "dungeon", partySize: 5, fillWithAi: true,
}));
expect(options.enterDungeon).not.toHaveBeenCalled();
});
it("marks a group member ready and waits for the leader's roll", async () => {
const options = queueOptions({ session: accountSession, group: { ...group, leaderAccountId: "player-2" } });
const installPack = vi.fn(async () => undefined);
await expect(queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => false,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
})).resolves.toMatchObject({ status: "waiting" });
expect(installPack).not.toHaveBeenCalled();
expect(options.startOnlineActivity).not.toHaveBeenCalled();
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroup, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { randomDungeonByCategory, randomRegularDungeon, type DungeonCatalogEntry } from "../game/dungeonCatalog";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
export type RandomDungeonQueueMode = "regular" | "bite-sized";
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
export interface RandomDungeonQueueOptions {
readonly mode: RandomDungeonQueueMode;
readonly role: PartyRole;
readonly character: CharacterProfile | null;
readonly session: PlayerSession | null;
readonly group: OnlineGroup | null;
readonly includeBetaSpecs: boolean;
readonly fillWithAi: boolean;
readonly onProgress: (stage: RandomDungeonQueueStage, dungeon: DungeonCatalogEntry | null) => void;
readonly enterDungeon: (dungeonId: string, role: PartyRole, settings: DungeonEntrySettings) => boolean;
readonly updateOnlineMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
readonly startOnlineActivity: (session: PlayerSession, input: StartOnlineGroupActivityInput) => Promise<boolean>;
readonly failureNotice: () => string;
}
interface RandomDungeonQueueDependencies {
readonly random: () => number;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly installPack: (packId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
}
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
random: Math.random,
contentIsUsable: dungeonContentIsUsable,
installPack: installContentPack,
installDungeonRuntime: async () => {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
},
};
export type RandomDungeonQueueResult =
| { readonly status: "entered" | "waiting" | "started"; readonly dungeonTitle?: string }
| { readonly status: "error"; readonly message: string };
/** Rolls only after Queue is pressed; downloads the selected map before local or shared entry. */
export async function queueRandomDungeon(
options: RandomDungeonQueueOptions,
dependencies: RandomDungeonQueueDependencies = DEFAULT_DEPENDENCIES,
): Promise<RandomDungeonQueueResult> {
const { character, session, group, role } = options;
if (!character || !session) return { status: "error", message: "Select a character before queuing." };
const specialization = specializationById(character.specializationId);
if ((character.categoryId !== "rom" && specialization?.role !== role)
|| !classCanFillPartyRole(character.classId, role)) {
return { status: "error", message: "That role is unavailable for this character." };
}
if (group && (session.kind !== "account" || !session.accessToken)) {
return { status: "error", message: "Reconnect before queuing with your group." };
}
try {
if (group) {
if (!await options.updateOnlineMember(session, character, role)) {
return { status: "error", message: options.failureNotice() || "The group role could not be updated." };
}
if (group.leaderAccountId !== session.ownerId) return { status: "waiting" };
}
options.onProgress("rolling", null);
const dungeon = options.mode === "regular"
? randomRegularDungeon(undefined, dependencies.random)
: randomDungeonByCategory("bite-sized", undefined, dependencies.random);
if (!dungeon) return { status: "error", message: "No dungeon is currently ready for this queue." };
if (!dependencies.contentIsUsable(dungeon.id)) {
options.onProgress("downloading", dungeon);
await dependencies.installPack(dungeonContentPackId(dungeon.id));
if (!dependencies.contentIsUsable(dungeon.id)) {
return { status: "error", message: `${dungeon.title} could not be verified after downloading.` };
}
}
const difficultyId = dungeonDefinitionById(dungeon.id)?.defaultDifficultyId;
options.onProgress("entering", dungeon);
if (group) {
const started = await options.startOnlineActivity(session, {
type: "dungeon",
contentId: dungeon.id,
difficultyId,
bossDifficulty: 1,
partySize: 5,
fillWithAi: options.fillWithAi,
includeBetaSpecs: options.includeBetaSpecs,
});
return started
? { status: "started", dungeonTitle: dungeon.title }
: { status: "error", message: options.failureNotice() || "The group dungeon could not be started." };
}
await dependencies.installDungeonRuntime();
const entered = options.enterDungeon(dungeon.id, role, {
difficultyId,
bossDifficulty: 1,
freshRun: true,
});
return entered
? { status: "entered", dungeonTitle: dungeon.title }
: { status: "error", message: options.failureNotice() || `Could not enter ${dungeon.title}.` };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "The random dungeon queue failed." };
}
}
-20
View File
@@ -5,7 +5,6 @@ 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";
@@ -95,7 +94,6 @@ describe("shell store", () => {
selectedCharacterId: null,
activeCharacter: null,
activeDungeonRole: "healer",
dungeonSelectionId: null,
notice: "",
});
useGameStore.getState().resetAtEntrance();
@@ -137,24 +135,6 @@ 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();
+3 -16
View File
@@ -38,7 +38,6 @@ 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";
@@ -78,7 +77,6 @@ export interface ShellState {
draft: CharacterDraft;
notice: string;
activeDungeonId: DungeonId;
dungeonSelectionId: DungeonId | null;
activeDungeonRole: PartyRole;
combatPresentationSettings: CombatPresentationSettings;
beginSession: (session: PlayerSession) => void;
@@ -92,7 +90,7 @@ export interface ShellState {
createDraftCharacter: () => boolean;
deleteSelectedCharacter: () => void;
openMainMenu: () => boolean;
openDungeons: (selection?: "random" | "random-bite-sized") => boolean;
openDungeons: () => boolean;
openManastorms: () => boolean;
openArenas: () => boolean;
openGmAdmin: () => boolean;
@@ -168,7 +166,6 @@ const restored = restoredState();
export const useShellStore = create<ShellState>((set, get) => ({
...restored,
activeDungeonId: DEFAULT_DUNGEON_ID,
dungeonSelectionId: null,
activeDungeonRole: "healer",
activeCharacter: null,
draft: defaultDraft(),
@@ -322,7 +319,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "main-menu", activeCharacter: null, notice: "" });
return true;
},
openDungeons: (selection) => {
openDungeons: () => {
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." });
@@ -333,17 +330,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
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}.` : "" });
set({ phase: "dungeons", activeCharacter: null, notice: "" });
return true;
},
openManastorms: () => {
+1
View File
@@ -316,6 +316,7 @@ export function OnlineSessionBridge() {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: combat.classId,
secondaryClassId: combat.secondaryClassId,
specializationId: combat.specializationId,
role: combat.gearRole,
level: combat.level,
+5 -3
View File
@@ -29,7 +29,7 @@ export type ResourceType =
| `custom:${string}`;
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
export type AbilityCastMode = "instant" | "cast" | "channel";
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "ground";
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "line" | "ground";
export interface AbilityTargetingRules {
readonly shape: AbilityTargetShape;
@@ -157,7 +157,7 @@ export interface AbilityDamageResource {
}
export type AbilityEffect =
| ({ readonly kind: "damage"; readonly coefficient: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
| {
readonly kind: "finisher-damage";
readonly baseCoefficient: number;
@@ -167,7 +167,7 @@ export type AbilityEffect =
}
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "dot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
@@ -182,6 +182,8 @@ export type AbilityEffect =
readonly recipient?: "ability-target" | "caster";
/** Party/raid area auras are applied to the player and every active party member. */
readonly scope?: "target" | "party";
/** Some party effects benefit allies but exclude the caster. */
readonly excludeCaster?: boolean;
readonly control?: AbilityAuraControl;
readonly sourceEffectType: number;
readonly sourceAuraType: number;
+1 -1
View File
@@ -950,7 +950,7 @@ describe("combat store", () => {
expect(useCombatStore.getState().lootMob("lootable", 30_000)).toBe(false);
});
it("suppresses leveling equipment while granting boss cards automatically exactly once", () => {
it("suppresses leveling boss items while granting boss cards automatically exactly once", () => {
useCombatStore.getState().initializeCharacter({
classId: "priest",
level: 1,
+245 -29
View File
@@ -156,6 +156,9 @@ import {
type HeirloomContext,
} from "./heirloomEquipment";
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics";
import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset, romMageWarlockCriticalEffects } from "./romMageWarlockOverrides";
import { romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
import { romCreatureFamilyForServerEntry, romCreatureFamilyTargetTags, type CombatCreatureFamily } from "./romCreatureFamilies";
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
import { specializationById } from "./specializationCatalog";
import {
@@ -391,6 +394,7 @@ export interface MobCombatState {
readonly id: string;
readonly name: string;
readonly serverEntry: number | null;
readonly creatureFamily: CombatCreatureFamily;
readonly summonedBy?: string;
readonly spawnGroupId?: string;
readonly boss: boolean;
@@ -590,6 +594,7 @@ export interface CharacterCombatSeed {
export interface MobRegistration {
readonly name: string;
readonly serverEntry?: number;
readonly creatureFamily?: CombatCreatureFamily;
readonly boss?: boolean;
readonly level?: number;
readonly resistances?: Partial<MobMagicResistances>;
@@ -1218,7 +1223,9 @@ function addHealingThreatInWork(
const engagedIds = Object.values(work.mobs)
.filter((mob) => mob.engaged && !mob.dead)
.map((mob) => mob.id);
const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1);
const perMob = threatFromHealing(effectiveHealing, engagedIds.length)
* (source.threatScale ?? 1)
* resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat");
if (perMob <= 0) return;
for (const id of engagedIds) {
const mob = work.mobs[id];
@@ -1269,6 +1276,12 @@ function talentAdjustedMaxHealth(
}
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
if (ability.source === "runewaker") {
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") {
return "spell";
}
}
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
const healing = ability.effects.some((effect) => (
effect.kind === "heal"
@@ -1285,6 +1298,10 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
}
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
if (ability.source === "runewaker") {
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school) return sourceHit.school;
}
if (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature";
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
@@ -1306,7 +1323,8 @@ export function equipmentAdjustedCombatAmount(
classId: ClassId,
ability: AbilityDefinition,
): number {
const kind = abilityAttackKind(classId, ability);
const kind = typeof scaling !== "number" && "school" in scaling && scaling.school && scaling.school !== "physical"
? "spell" : abilityAttackKind(classId, ability);
const attackPower = equipmentAttackPower(classId, gearStats, kind);
const weapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null;
const weaponSpeed = weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8;
@@ -1377,21 +1395,24 @@ function playerCharacterStats(
});
}
function npcCombatDefender(mob: MobCombatState, attackerLevel: number): CombatDefenderStats {
function npcCombatDefender(mob: MobCombatState, attackerLevel: number, auras: readonly ActiveAura[]): CombatDefenderStats {
const levelAdvantage = Math.max(0, mob.level - attackerLevel);
const resistance = (value: number, school: DamageSchool): number => Math.max(0,
resolveAuraStatValue(resolveAuraStatValue(value, auras, `resistance:${school}`), auras, "magical-defense"),
);
return {
level: mob.level,
armor: mob.armor,
armor: Math.max(0, resolveAuraStatValue(mob.armor, auras, "armor")),
dodgePercent: 5 + levelAdvantage * 0.5,
parryPercent: 5 + levelAdvantage * 0.5,
blockPercent: 0,
blockValue: 0,
resistances: {
arcane: mob.resistances.arcane,
fire: mob.resistances.fire,
frost: mob.resistances.frost,
nature: mob.resistances.nature,
shadow: mob.resistances.shadow,
arcane: resistance(mob.resistances.arcane, "arcane"),
fire: resistance(mob.resistances.fire, "fire"),
frost: resistance(mob.resistances.frost, "frost"),
nature: resistance(mob.resistances.nature, "nature"),
shadow: resistance(mob.resistances.shadow, "shadow"),
},
};
}
@@ -1458,7 +1479,7 @@ function resolvePlayerDamageResult(
attackKind,
damageSchool: school,
attacker,
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level),
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level, aurasForEntity(work.auras, mob.id)),
rules: immune
? {
...rules,
@@ -2057,6 +2078,7 @@ function summonedMobCombatState(
id,
name: template?.name ?? `Summoned Creature ${creatureEntry}`,
serverEntry: creatureEntry,
creatureFamily: romCreatureFamilyForServerEntry(creatureEntry),
summonedBy: summoner.id,
...(spawnGroupId ? { spawnGroupId } : {}),
boss: false,
@@ -2202,7 +2224,7 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
dodgePercent: stat(base.dodgePercent, "dodge"),
parryPercent: stat(base.parryPercent, "parry"),
blockPercent: stat(base.blockPercent, "block"),
blockPercent: stat(base.blockPercent, "block", "block-chance"),
expertise: stat(base.expertise, "expertise"),
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
fireResistance: resistance(base.fireResistance, "fire"),
@@ -2309,6 +2331,52 @@ function executeTriggeredAuraProcsInWork(
): void {
for (const triggered of triggeredProcs) {
const action = triggered.action;
if (action.kind === "custom" && action.id.startsWith("rom-")) {
const triggeringAbility = triggered.event.abilityId
? work.abilities.find((ability) => ability.id === triggered.event.abilityId)
?? abilityById(triggered.event.abilityId)
: null;
const poisonous = work.auras.some((aura) => aura.targetId === triggered.sourceId
&& aura.definition.utilityTags?.includes("rom-poisonous"));
const spec = romScoutRogueProcActionSpec(action.id, triggeringAbility?.dbcSpellId ?? 0, poisonous);
if (spec) {
const recipient = spec.recipient === "caster"
? triggered.sourceId : triggered.event.otherId ?? triggered.targetId;
if (spec.kind === "aura" && recipient) {
work.auras = [...applyCombatAura(work.auras, {
definition: spec.aura, sourceId: triggered.sourceId, targetId: recipient, now,
}).auras];
} else if (spec.kind === "resource" && recipient === PLAYER_AURA_ENTITY_ID) {
const maximum = work.maxResourcePools[spec.resource] ?? defaultResourceMaximum(spec.resource);
work.resourcePools[spec.resource] = clamp(
(work.resourcePools[spec.resource] ?? 0) + spec.amount, 0, maximum,
);
} else if (spec.kind === "resource" && recipient && work.actors[recipient]) {
const actor = work.actors[recipient];
const pool = actor.resources[spec.resource];
if (pool) {
const next = { ...actor, resources: {
...actor.resources,
[spec.resource]: { ...pool, current: clamp(pool.current + spec.amount, 0, pool.maximum) },
} };
work.actors[recipient] = next;
mirrorCompanionActor(next);
}
} else if (spec.kind === "dot" && recipient && work.mobs[recipient]) {
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
&& effect.sourceActorId === triggered.sourceId
&& effect.targetId === recipient && effect.tag === spec.effect.tag));
work.effects.push(timedEffect(
"dot", triggeringAbility?.id ?? triggered.procId,
triggered.sourceId, recipient, spec.effect,
spec.effect.ticks, spec.effect.intervalMs, now,
spec.effect.tag, spec.effect.school ?? "nature",
work.gearStats.spellPenetration,
));
}
continue;
}
}
const actionTarget = action.kind === "custom"
? null
: action.target === "aura-source"
@@ -2783,7 +2851,8 @@ function damageMobInWork(
: addActorThreat(
engagedMob.threatByActor,
source.actorId,
threatFromDamage(applied, source.role, source.threatScale),
threatFromDamage(applied, source.role, source.threatScale)
* resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat"),
);
work.mobs[id] = {
...engagedMob,
@@ -2897,6 +2966,19 @@ function resolveAndDamageMobInWork(
work.gearStats.spellPenetration,
combatResult,
);
if (ability?.source === "runewaker" && combatResult.critical && result.amount > 0) {
const passiveTags = new Set(aurasForEntity(work.auras, source.actorId)
.flatMap((aura) => aura.definition.utilityTags ?? []));
for (const rider of romMageWarlockCriticalEffects(ability, passiveTags)) {
if (rider.kind !== "apply-aura") continue;
work.auras = [...applyCombatAura(work.auras, {
definition: auraDefinitionForAbility(rider.aura, ability),
sourceId: source.actorId,
targetId: id,
now,
}).auras];
}
}
if (combustion && combatResult.landed && result.amount > 0) {
const criticalHits = (combustion.combustionCriticalHits ?? 0) + Number(combatResult.critical);
work.auras = work.auras.flatMap((aura) => aura.instanceId !== combustion.instanceId
@@ -3063,6 +3145,7 @@ function executeTriggeredSpellInWork(
ability,
);
for (const mobId of mobTargets) {
const effectSchool = effect.school ?? school;
const result = resolveAndDamageMobInWork(
work.classId,
work,
@@ -3070,8 +3153,8 @@ function executeTriggeredSpellInWork(
amount,
now,
source,
attackKind,
school,
effectSchool === "physical" ? attackKind : "spell",
effectSchool,
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
ability,
);
@@ -3962,6 +4045,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId,
secondaryClassId,
specializationId,
role: gearRole,
level: progression.level,
@@ -4027,6 +4111,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -4066,6 +4151,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const existing = state.mobs[id];
const boss = registration.boss ?? existing?.boss ?? false;
const serverEntry = registration.serverEntry ?? existing?.serverEntry ?? null;
const creatureFamily = registration.creatureFamily
?? (registration.serverEntry === undefined && existing?.creatureFamily
? existing.creatureFamily : romCreatureFamilyForServerEntry(serverEntry));
const lootSourceId = registration.lootSourceId ?? existing?.lootSourceId ?? null;
const level = Math.max(1, Math.trunc(registration.level ?? existing?.level ?? state.level));
const resistances: MobMagicResistances = {
@@ -4135,6 +4223,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
...existing,
name: registration.name || existing.name,
serverEntry,
creatureFamily,
boss,
lootSourceId,
level,
@@ -4165,6 +4254,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
id,
name: registration.name || "Unknown Creature",
serverEntry,
creatureFamily,
boss,
lootSourceId,
level,
@@ -4294,9 +4384,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
equipItem: (instanceId, ownerId = PLAYER_EQUIPMENT_OWNER_ID) => {
const state = get();
const ownerMember = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? null
: usePartyStore.getState().members.find((member) => member.id === ownerId);
const ownerClassId = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? state.classId
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.classId;
: ownerMember?.classId;
if (!ownerClassId) {
set({ feedback: nextFeedback("error", "That party member is not available.") });
return false;
@@ -4307,6 +4400,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId,
ownerClassId,
instanceId,
{
secondaryClassId: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.secondaryClassId : ownerMember?.secondaryClassId,
level: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.level : ownerMember?.level,
},
);
if (!change.ok) {
set({ feedback: nextFeedback("error", change.reason ?? "That item cannot be equipped.") });
@@ -4315,11 +4412,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const item = state.inventory.find((candidate) => candidate.instanceId === instanceId);
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? "You"
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
: ownerMember?.name ?? "Companion";
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -4370,6 +4468,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -4773,10 +4872,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const catalogAbility = abilityById(abilityId);
const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
const ability = catalogAbility
let ability = catalogAbility
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
: null;
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
if (ability?.dbcSpellId === 490133 && activePlayerAuras.some((aura) =>
aura.definition.utilityTags?.includes("rom-whirlwind-mastery"))) {
const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage"
? { ...cost, amount: Math.max(0, cost.amount - 20) } : cost);
ability = {
...ability,
cooldownMs: Math.max(0, ability.cooldownMs - 20_000),
cost: adjustedCosts[0] ?? ability.cost,
costs: adjustedCosts,
};
}
const innerFocusAura = activePlayerAuras.find((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
));
@@ -4815,7 +4925,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
const targeting = ability ? targetingRulesForAbility(ability) : null;
const baseTargeting = ability ? targetingRulesForAbility(ability) : null;
const targeting = ability?.dbcSpellId === 490457 && baseTargeting && hasRuneWakerState("rom-master-reflected-shot")
? { ...baseTargeting, maximumTargets: 5 }
: baseTargeting;
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
const runeCost = ability && baseClassFor(state.classId) === "death-knight"
? runeCostForAbility(ability)
@@ -4843,6 +4956,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met";
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
else if (ability.dbcSpellId === 493017 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
else if (abilityRequiresOutOfCombat(ability)
&& !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish"))
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
@@ -4922,6 +5036,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
if (!failure && ability?.name === "Power Word: Shield"
&& hasWeakenedSoul(state.auras, targetId ?? PLAYER_AURA_ENTITY_ID, now)) failure = "condition-not-met";
if (!failure && ability?.source === "runewaker") {
const family = targetId ? state.mobs[targetId]?.creatureFamily : undefined;
if (ability.dbcSpellId === 491613 && family === "living"
&& !hasRuneWakerState("rom-evil-shackles")) failure = "condition-not-met";
if (ability.dbcSpellId === 490347 && family === "undead") failure = "condition-not-met";
if (ability.dbcSpellId === 492920 && !state.effects.some((effect) =>
effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) failure = "condition-not-met";
if (ability.dbcSpellId === 490151
&& (!targetId || state.comboTargetId !== targetId || state.comboPoints <= 0)) failure = "condition-not-met";
if (ability.dbcSpellId === 490347 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
@@ -4930,6 +5051,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -5005,6 +5127,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
affectedMobIds.push(id);
previous = position;
}
} else if (targeting?.shape === "line" && effectiveTarget === "hostile" && targetId && targetPosition) {
const directionX = targetPosition[0] - playerOrigin[0];
const directionZ = targetPosition[2] - playerOrigin[2];
const targetDistance = Math.max(0.001, Math.hypot(directionX, directionZ));
const reach = targetDistance + 4;
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, reach).filter((id) => {
const mob = state.mobs[id];
const position = getMobPosition(id);
if (!mob || mob.dead || !position || !combatHasLineOfSight(playerOrigin, position)) return false;
const offsetX = position[0] - playerOrigin[0];
const offsetZ = position[2] - playerOrigin[2];
const forward = (offsetX * directionX + offsetZ * directionZ) / targetDistance;
const sideways = Math.abs(offsetX * directionZ - offsetZ * directionX) / targetDistance;
return forward >= 0 && forward <= reach && sideways <= 1.25;
});
} else if (targeting?.shape === "cone" && effectiveTarget === "hostile") {
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => {
@@ -5024,14 +5161,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} else if (effectiveTarget === "hostile" && targetId) {
affectedMobIds = [targetId];
}
if (targeting?.maximumTargets && affectedMobIds.length > targeting.maximumTargets) {
affectedMobIds = affectedMobIds.slice(0, targeting.maximumTargets);
}
const castTimeMs = talentAdjustedCastTime(
const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648
&& ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer");
const ordinaryCastTimeMs = talentAdjustedCastTime(
ability,
modifiers,
state.gearStats,
state.level,
state.classId,
);
const castTimeMs = lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs;
const gcdMs = talentAdjustedGcd(
ability,
modifiers,
@@ -5070,7 +5213,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
originalDurationMs: castTimeMs,
pushbackHits: 0,
school: spellSchool,
canCastWhileMoving: canAbilityCastWhileMoving(ability),
canCastWhileMoving: canAbilityCastWhileMoving(ability)
|| (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")),
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
groundPosition,
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0,
@@ -5083,6 +5227,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
set({
activeCast,
...(lastPrayerReady ? { auras: state.auras.filter((aura) => !(
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("rom-last-prayer")
)) } : {}),
...autoAttackUpdate,
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
@@ -5220,6 +5368,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -5231,10 +5380,30 @@ export const useCombatStore = create<CombatState>((set, get) => ({
charged: hasRuneWakerState("rom-charged"),
mainHandDagger: romMainHand?.weaponType === "dagger",
casterMaxHealth: state.maxHealth,
passiveTags: work.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID)
.flatMap((aura) => aura.definition.utilityTags ?? []),
targetTags: work.auras.filter((aura) => aura.targetId === targetId)
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
romCreatureFamilyTargetTags(targetId && work.mobs[targetId]
? work.mobs[targetId].creatureFamily : "unknown"),
work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
.flatMap((effect) => [
...(effect.tag ? [effect.tag] : []),
...(effect.tag?.includes("501690") ? ["rom-501690"] : []),
...(effect.tag?.includes("rom-deadly-poison") ? ["rom-deadly-poison"] : []),
]),
),
});
if (ability.dbcSpellId === 492920 && targetId
&& castEffects.some((effect) => effect.kind === "dot" && effect.tag === "rom-622992")) {
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
&& effect.targetId === targetId
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35"));
}
const effectsToResolve = completingCast?.mode === "channel"
? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
: castEffects;
let clearedSingleTargetControls = false;
for (const effect of effectsToResolve) {
if (ability.name === "Penance" && effectiveTarget === "friendly"
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
@@ -5265,7 +5434,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
"damage-percent",
ability,
);
for (const id of affectedMobIds) {
for (const [targetIndex, id] of affectedMobIds.entries()) {
const effectSchool = effect.school ?? damageSchool;
const sunderStacks = effect.bonusPerSunderStack
? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
: 0;
@@ -5273,17 +5443,36 @@ export const useCombatStore = create<CombatState>((set, get) => ({
state.classId,
work,
id,
amount + sunderStacks * (effect.bonusPerSunderStack ?? 0),
Math.round((amount + sunderStacks * (effect.bonusPerSunderStack ?? 0))
* Math.pow(effect.falloffPerJump ?? 1, targetIndex)),
now,
castThreatSource,
attackKind,
damageSchool,
effectSchool === "physical" ? attackKind : "spell",
effectSchool,
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
ability,
false,
ability.name === "Shiv" ? { canCrit: false } : {},
);
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
if (ability.dbcSpellId === 490133) {
const bonus = romScoutRogueWhirlwindBonusEffect(
ability,
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).flatMap((aura) => aura.definition.utilityTags ?? []),
work.effects.filter((active) => active.targetId === id && active.remainingTicks > 0)
.flatMap((active) => active.tag ? [active.tag] : []),
);
if (bonus) {
const bonusAmount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(bonus, work.progression.level, work.gearStats, state.classId, ability),
modifiers, "damage-percent", ability,
);
resolveAndDamageMobInWork(
state.classId, work, id, bonusAmount, now, castThreatSource,
"melee", "physical", `${castThreatSource.actorId}:${ability.id}:${id}:${now}:poison-bonus`, ability,
);
}
}
}
} else if (effect.kind === "finisher-damage") {
const comboPoints = work.comboPoints;
@@ -5754,7 +5943,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
effect.intervalMs,
now,
effect.tag,
damageSchool,
effect.school ?? damageSchool,
work.gearStats.spellPenetration,
));
}
@@ -5808,12 +5997,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
} else if (effect.kind === "status") {
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
if (romSingleTargetControl(ability) && candidates.length) {
if (romSingleTargetControl(ability) && candidates.length && !clearedSingleTargetControls) {
for (const [mobId, mob] of Object.entries(work.mobs)) {
work.mobs[mobId] = { ...mob, statuses: mob.statuses.filter((status) => !(
status.sourceAbilityId === ability.id && status.sourceActorId === PLAYER_AGGRO_ID
)) };
}
clearedSingleTargetControls = true;
}
for (const id of candidates) {
const mob = work.mobs[id];
@@ -5848,7 +6038,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: effectiveTarget === "hostile"
? affectedMobIds
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients) {
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) {
const previousAuras = work.auras;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
const auraResult = applyCombatAura(previousAuras, {
@@ -6298,6 +6488,27 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
}
}
const burstReset = romFireLightningBurstReset(
ability,
Boolean(targetId && work.mobs[targetId] && !work.mobs[targetId].dead && work.mobs[targetId].health > 0),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).some((aura) =>
aura.definition.utilityTags?.includes(ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG)),
);
if (burstReset.reset) {
cooldowns[ability.id] = 0;
work.auras = [...applyCombatAura(work.auras, {
definition: {
id: "rom-fire-lightning-burst-reset-guard",
name: "Fire Lightning Burst reset guard",
disposition: "buff",
durationMs: burstReset.guardDurationMs,
utilityTags: [ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now,
}).auras];
}
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
const levelMessage = work.progression.levelsGained > 0
@@ -6739,7 +6950,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const engagedIds = Object.values(state.mobs)
.filter((mob) => mob.engaged && !mob.dead)
.map((mob) => mob.id);
const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1);
const perMob = threatFromHealing(effectiveHealing, engagedIds.length)
* (source.threatScale ?? 1)
* resolveAuraStatValue(1, aurasForEntity(state.auras, source.actorId), "threat");
if (perMob <= 0) return state;
const mobs = { ...state.mobs };
for (const id of engagedIds) {
@@ -8716,6 +8929,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID
? Math.round(namedAbsorb.remainingDamage * redirectFraction)
: 0;
const aurasAfterRedirect = redirected > 0 && redirectAura?.definition.utilityTags?.includes("rom-disregard-danger-one-hit")
? namedAbsorb.auras.filter((aura) => aura.instanceId !== redirectAura.instanceId)
: namedAbsorb.auras;
const damaged = Math.min(
authoritativeActor.health,
Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)),
@@ -8761,7 +8977,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
set({
actors: { ...get().actors, [nextActor.id]: nextActor },
auras: consumeInnerFireCharges(
namedAbsorb.auras, member.id, combatResult.landed, enemyAttackKind(hit.attack),
aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack),
),
combatEvents: recordCombatEvent(get().combatEvents, {
occurredAt: now,
+27 -4
View File
@@ -44,6 +44,11 @@ export interface EquipmentChange {
readonly reason?: string;
}
export interface EquipmentClassContext {
readonly secondaryClassId?: ClassId | null;
readonly level?: number;
}
const equipmentSlotSet = new Set<string>(EQUIPMENT_SLOTS);
const ARMOR_RANK: Readonly<Record<string, number>> = Object.freeze({
@@ -57,11 +62,12 @@ const ARMOR_RANK: Readonly<Record<string, number>> = Object.freeze({
const SHIELD_CLASSES = new Set<ClassId>([
"warrior", "paladin", "shaman",
"knight-of-xoroth", "guardian", "cultist", "starcaller", "sun-cleric",
"rom-warrior", "rom-knight", "rom-warden", "rom-champion",
"rom-warrior", "rom-knight", "rom-priest", "rom-warden", "rom-champion",
]);
const WEAPON_CLASSES: Readonly<Record<string, ReadonlySet<BaseClassId>>> = Object.freeze({
bow: new Set<BaseClassId>(["warrior", "hunter", "rogue"]),
crossbow: new Set<BaseClassId>(["warrior", "hunter", "rogue"]),
dagger: new Set<BaseClassId>(["warrior", "hunter", "rogue", "priest", "shaman", "mage", "warlock", "druid"]),
mace: new Set<BaseClassId>(["warrior", "paladin", "priest", "death-knight", "shaman", "druid"]),
staff: new Set<BaseClassId>(["warrior", "hunter", "priest", "shaman", "mage", "warlock", "druid"]),
@@ -75,7 +81,23 @@ export function equipmentSlotForItem(item: InventoryItem): EquipmentSlot | null
return equipmentSlotSet.has(slot) ? slot as EquipmentSlot : null;
}
export function classCanEquipItem(classId: ClassId, item: InventoryItem): boolean {
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
const slot = equipmentSlotForItem(item);
if (!slot) return false;
const level = context.level ?? Number.POSITIVE_INFINITY;
if (classId.startsWith("rom-")) {
if (slot === "off-hand" && item.weaponType
&& level < 16) return false;
if (slot === "off-hand" && item.weaponType
&& classId !== "rom-rogue" && context.secondaryClassId !== "rom-rogue") return false;
if (slot === "ranged") {
if (item.weaponType === "bow" || item.weaponType === "crossbow") return classId === "rom-scout"
|| (context.secondaryClassId === "rom-scout" && level >= 10);
if (item.weaponType === "projectile" || item.weaponType === "throwing") return (classId === "rom-rogue"
|| context.secondaryClassId === "rom-rogue") && level >= 12;
return false;
}
}
if (item.armorType === "shield" && !SHIELD_CLASSES.has(classId)) return false;
if (item.armorType && item.armorType !== "shield") {
const requiredRank = ARMOR_RANK[item.armorType] ?? Number.POSITIVE_INFINITY;
@@ -85,7 +107,7 @@ export function classCanEquipItem(classId: ClassId, item: InventoryItem): boolea
const allowedClasses = WEAPON_CLASSES[item.weaponType];
if (!allowedClasses?.has(baseClassFor(classId))) return false;
}
return equipmentSlotForItem(item) !== null;
return true;
}
function mutableAssignments(assignments: EquipmentAssignments): Record<string, EquipmentLoadout> {
@@ -114,12 +136,13 @@ export function equipInventoryItem(
ownerId: string,
ownerClassId: ClassId,
instanceId: string,
classContext: EquipmentClassContext = {},
): EquipmentChange {
const item = inventory.find((candidate) => candidate.instanceId === instanceId);
if (!item) return { ok: false, assignments, reason: "That item is no longer in your inventory." };
const slot = equipmentSlotForItem(item);
if (!slot) return { ok: false, assignments, reason: `${item.name} does not use an equippable slot.` };
if (!classCanEquipItem(ownerClassId, item)) {
if (!classCanEquipItem(ownerClassId, item, classContext)) {
return { ok: false, assignments, reason: `That class cannot equip ${item.name}.` };
}
@@ -0,0 +1 @@
{"schemaVersion":1,"sourceSha256":"b627b6eb269c30576a6558000a31abe2db472689ef9a60cc1c1e20207abd69f1","raceOffset":176,"knownIds":[100001,100002,100003,100004,100005,100006,100007,100008,100009,100010,100011,100012,100013,100014,100015,100016,100017,100018,100019,100020,100021,100022,100023,100024,100025,100026,100027,100028,100029,100030,100031,100032,100033,100034,100035,100036,100037,100038,100039,100040,100041,100042,100043,100044,100045,100046,100047,100048,100049,100050,100051,100052,100053,100054,100055,100056,100057,100058,100059,100060,100061,100062,100063,100064,100065,100066,100067,100068,100069,100070,100071,100072,100073,100074,100075,100076,100077,100078,100079,100080,100081,100082,100083,100084,100085,100086,100087,100088,100089,100090,100091,100092,100093,100094,100095,100096,100097,100098,100099,100100,100101,100102,100103,100104,100105,100106,100107,100108,100109,100110,100111,100112,100113,100114,100115,100116,100117,100118,100119,100120,100121,100122,100123,100124,100125,100126,100127,100128,100129,100130,100131,100132,100133,100134,100135,100136,100137,100138,100139,100140,100141,100142,100143,100144,100145,100146,100147,100148,100149,100150,100151,100152,100153,100154,100155,100156,100157,100158,100159,100160,100161,100162,100163,100164,100165,100166,100167,100168,100169,100170,100171,100172,100173,100174,100175,100176,100177,100178,100179,100180,100181,100182,100183,100184,100185,100186,100187,100188,100189,100190,100191,100192,100193,100194,100195,100196,100197,100198,100199,100200,100201,100202,100203,100204,100205,100206,100207,100208,100209,100210,100211,100212,100213,100214,100215,100216,100217,100218,100219,100220,100221,100222,100223,100224,100225,100226,100227,100228,100229,100230,100231,100232,100233,100234,100235,100236,100237,100238,100239,100240,100241,100242,100243,100244,100245,100246,100247,100248,100249,100250,100251,100252,100253,100254,100255,100256,100257,100258,100259,100260,100261,100262,100263,100264,100265,100266,100267,100268,100269,100270,100271,100272,100273,100274,100275,100276,100277,100278,100279,100280,100281,100282,100283,100284,100285,100286,100287,100288,100289,100290,100291,100292,100293,100294,100295,100296,100297,100298,100299,100300,100301,100302,100303,100304,100305,100306,100307,100308,100309,100310,100311,100312,100313,100314,100315,100316,100317,100318,100319,100320,100321,100322,100323,100324,100325,100326,100327,100328,100329,100330,100331,100332,100333,100334,100335,100336,100337,100338,100339,100340,100341,100342,100343,100344,100345,100346,100347,100348,100349,100350,100351,100352,100353,100354,100355,100356,100357,100358,100359,100360,100361,100362,100363,100364,100365,100366,100367,100368,100369,100370,100371,100372,100373,100374,100375,100376,100377,100378,100379,100380,100381,100382,100383,100384,100385,100386,100387,100388,100389,100390,100391,100392,100393,100394,100395,100396,100397,100398,100399,100400,100401,100402,100403,100404,100405,100406,100407,100408,100409,100410,100411,100412,100413,100414,100415,100416,100417,100418,100419,100420,100421,100422,100423,100424,100425,100426,100427,100428,100429,100430,100431,100432,100433,100434,100435,100436,100437,100438,100439,100440,100441,100442,100443,100444,100445,100446,100447,100448,100449,100450,100451,100452,100453,100454,100455,100456,100457,100458,100459,100460,100461,100462,100463,100464,100465,100466,100467,100468,100469,100470,100471,100472,100473,100474,100475,100476,100477,100478,100479,100480,100481,100482,100483,100484,100485,100486,100487,100488,100489,100490,100491,100492,100493,100494,100495,100496,100497,100498,100499,100500,100501,100502,100503,100504,100505,100506,100507,100508,100509,100510,100511,100512,100513,100514,100515,100516,100517,100518,100519,100520,100521,100522,100523,100524,100525,100526,100527,100528,100529,100530,100531,100532,100533,100534,100535,100536,100537,100538,100539,100540,100541,100542,100543,100544,100545,100546,100547,100548,100549,100550,100551,100552,100553,100554,100555,100556,100557,100558,100559,100560,100561,100562,100563,100564,100565,100566,100567,100568,100569,100570,100571,100572,100573,100574,100575,100576,100577,100578,100579,100580,100581,100582,100583,100584,100585,100586,100587,100588,100589,100590,100591,100592,100593,100594,100595,100596,100597,100598,100599,100600,100601,100602,100603,100604,100605,100606,100607,100608,100609,100610,100611,100612,100613,100614,100615,100616,100617,100618,100619,100620,100621,100622,100623,100624,100625,100626,100627,100628,100629,100630,100631,100632,100633,100634,100635,100636,100637,100638,100639,100640,100641,100642,100643,100644,100645,100646,100647,100648,100649,100650,100651,100652,100653,100654,100655,100656,100657,100658,100659,100660,100661,100662,100663,100664,100665,100666,100667,100668,100669,100670,100671,100672,100673,100674,100675,100676,100677,100678,100679,100680,100681,100682,100683,100684,100685,100686,100687,100688,100689,100690,100691,100692,100693,100694,100695,1006Line truncated
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"contentStatus": "quest-rewards-confirmed-package-expansion-unverified",
"sourceFiles": {
"questdetailobject.db": "52950d1df2e0094144064298ab36554b2db424acfb23d142cce7d3087e4cfa7f",
@@ -9,10 +9,62 @@
"string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
},
"milestones": [
{
"level": 5,
"questId": 423783,
"questName": "Equip Yourself",
"packageStructure": "single-package-contents-unverified",
"packages": [
{
"armorCategory": "cloth",
"packageId": 209015,
"packageName": "Cloth Armor Package"
},
{
"armorCategory": "leather",
"packageId": 209024,
"packageName": "Leather Armor Package"
},
{
"armorCategory": "chain",
"packageId": 209025,
"packageName": "Chainmail Armor Box"
}
]
},
{
"level": 30,
"questId": 423771,
"questName": "Key to Victory",
"packageStructure": "battlefield-package-contents-unverified",
"packages": [
{
"armorCategory": "armor",
"packageId": 208802,
"packageName": "World Battlefield Bonus Armor Package"
},
{
"armorCategory": "chain",
"packageId": 208803,
"packageName": "World Battlefield Bonus Chain Package"
},
{
"armorCategory": "leather",
"packageId": 208804,
"packageName": "World Battlefield Bonus Leather Package"
},
{
"armorCategory": "cloth",
"packageId": 208805,
"packageName": "World Battlefield Bonus Cloth Package"
}
]
},
{
"level": 50,
"questId": 424596,
"questName": "Appropriate Support",
"packageStructure": "named-subpackages-links-unverified",
"packages": [
{
"armorCategory": "plate",
@@ -212,6 +264,7 @@
"level": 55,
"questId": 424597,
"questName": "Rewards for Students",
"packageStructure": "named-subpackages-links-unverified",
"packages": [
{
"armorCategory": "plate",
@@ -411,6 +464,7 @@
"level": 60,
"questId": 424598,
"questName": "Lance's Support",
"packageStructure": "named-subpackages-links-unverified",
"packages": [
{
"armorCategory": "plate",
@@ -610,6 +664,7 @@
"level": 65,
"questId": 424213,
"questName": "Return to Dalanis",
"packageStructure": "named-subpackages-links-unverified",
"packages": [
{
"armorCategory": "plate",
+39 -16
View File
@@ -24,6 +24,7 @@ import type { InventoryItem } from "./lootTypes";
import type { PartyRole } from "./partyRoles";
import {
gearProfileFor,
isRomHybridGearClass,
specializationScore,
type GearArchetype,
type SpecializationDefinition,
@@ -40,6 +41,7 @@ export interface HeirloomContext {
readonly ownerId: string;
readonly ownerKind: HeirloomOwnerKind;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
@@ -95,18 +97,30 @@ function effectiveLevel(context: HeirloomContext): number {
return Math.max(1, Math.min(cap, Math.trunc(Number.isFinite(context.level) ? context.level : 1)));
}
function weaponSlots(layout: WeaponLayout): readonly EquipmentSlot[] {
function weaponSlots(context: HeirloomContext, layout: WeaponLayout): readonly EquipmentSlot[] {
let slots: readonly EquipmentSlot[];
switch (layout) {
case "dual-wield":
case "weapon-shield":
return ["main-hand", "off-hand"];
slots = ["main-hand", "off-hand"];
break;
case "ranged":
return ["main-hand", "ranged"];
slots = ["main-hand", "ranged"];
break;
case "caster":
return ["main-hand", "off-hand", "ranged"];
slots = ["main-hand", "off-hand", "ranged"];
break;
default:
return ["main-hand"];
slots = ["main-hand"];
}
if (!isRomHybridGearClass(context.classId)) return slots;
const level = effectiveLevel(context);
const resolved = new Set(slots);
if (context.classId === "rom-rogue" && level < 16) resolved.delete("off-hand");
if (context.classId === "rom-rogue" && level >= 12) resolved.add("ranged");
if (context.classId === "rom-knight" && context.secondaryClassId === "rom-scout" && level >= 10) resolved.add("ranged");
if (context.classId === "rom-scout" && context.secondaryClassId === "rom-rogue" && level >= 16) resolved.add("off-hand");
return [...resolved];
}
function armorTypeForSlot(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string | null {
@@ -116,9 +130,12 @@ function armorTypeForSlot(classId: ClassId, slot: EquipmentSlot, layout: WeaponL
}
function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: SpecializationDefinition): string | null {
if (slot === "off-hand" && profile.weaponLayout === "dual-wield") return "dagger";
if (slot === "ranged") return profile.gearArchetype === "spell-dps" || profile.gearArchetype === "healer" ? "wand" : "bow";
if (slot === "off-hand" && (profile.weaponLayout === "dual-wield" || classId === "rom-scout")) return "dagger";
if (slot === "ranged") return classId === "rom-rogue" ? "projectile"
: profile.gearArchetype === "spell-dps" || profile.gearArchetype === "healer" ? "wand" : "bow";
if (slot !== "main-hand") return null;
if (classId === "rom-scout" && profile.weaponLayout === "ranged") return "dagger";
if (classId === "rom-mage") return profile.weaponLayout === "two-hand" ? "staff" : "dagger";
if (profile.weaponLayout === "caster") {
return ["priest", "shaman", "druid", "witch-doctor", "chronomancer", "primalist", "venomancer"].includes(classId)
? "mace"
@@ -129,8 +146,8 @@ function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: Specializ
return "mace";
}
function inventorySlotFor(slot: EquipmentSlot, layout: WeaponLayout): string {
if (slot === "main-hand") return layout === "two-hand" || layout === "ranged" ? "two-hand" : "one-hand";
function inventorySlotFor(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string {
if (slot === "main-hand") return layout === "two-hand" || (layout === "ranged" && classId !== "rom-scout") ? "two-hand" : "one-hand";
return slot;
}
@@ -212,13 +229,16 @@ function heirloomItem(
if (cached) return cached;
const armorType = armorTypeForSlot(context.classId, slot, layout);
const weaponType = weaponTypeFor(context.classId, slot, { ...profile, weaponLayout: layout });
const inventorySlot = inventorySlotFor(slot, layout);
const inventorySlot = inventorySlotFor(context.classId, slot, layout);
const weight = SLOT_WEIGHT[slot];
const budget = Math.max(2, level * weight * 1.18);
const armor = armorType && armorType !== "shield"
? Math.max(1, Math.round(level * weight * ({ cloth: 1, leather: 1.35, mail: 1.7, chain: 1.7, plate: 2.1 }[armorType] ?? 1)))
: armorType === "shield" ? Math.max(2, Math.round(level * weight * 3.2)) : 0;
const sourceStats = statDistribution(profile.gearArchetype, budget, armor);
const distributedStats = statDistribution(profile.gearArchetype, budget, armor);
const sourceStats = armorType === "shield" && isRomHybridGearClass(context.classId)
? { ...distributedStats, blockValue: Math.max(1, Math.round(level * weight * 0.45)) }
: distributedStats;
const sourceWeapon = weaponStats(slot, inventorySlot, weaponType, budget);
let stats = itemStatsFor({
itemId: 9_000_000,
@@ -232,11 +252,11 @@ function heirloomItem(
sourceWeapon,
});
const currentScore = specializationScore(stats, profile);
const catalogScore = highestCatalogScore(context.classId, slot, level, profile)
+ (slot === "main-hand" && ["two-hand", "ranged"].includes(layout)
const catalogScore = isRomHybridGearClass(context.classId) ? 0 : highestCatalogScore(context.classId, slot, level, profile)
+ (slot === "main-hand" && inventorySlot === "two-hand"
? highestCatalogScore(context.classId, "off-hand", level, profile)
: 0);
const conflictSafety = slot === "main-hand" && ["two-hand", "ranged"].includes(layout) ? 1.05 : 1.02;
const conflictSafety = slot === "main-hand" && inventorySlot === "two-hand" ? 1.05 : 1.02;
const targetScore = catalogScore > 0 ? catalogScore * conflictSafety : currentScore;
if (currentScore > 0 && Math.abs(targetScore - currentScore) > 0.01) stats = scaleStats(stats, targetScore / currentScore);
const normalizedScore = specializationScore(stats, profile);
@@ -277,12 +297,15 @@ export function resolvedEquipmentLoadout(
): Readonly<Partial<Record<EquipmentSlot, InventoryItem>>> {
const profile = gearProfileFor(context.classId, context.specializationId, context.role);
const result: Partial<Record<EquipmentSlot, InventoryItem>> = {};
for (const slot of [...BASE_SLOTS, ...weaponSlots(profile.weaponLayout)]) result[slot] = heirloomItem(context, profile, slot);
for (const slot of [...BASE_SLOTS, ...weaponSlots(context, profile.weaponLayout)]) {
if (isRomHybridGearClass(context.classId) && slot === "wrist") continue;
result[slot] = heirloomItem(context, profile, slot);
}
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
const earned = assignments[context.ownerId] ?? {};
for (const slot of EQUIPMENT_SLOTS) {
const item = earned[slot] ? itemById.get(earned[slot]!) : null;
if (!item || !classCanEquipItem(context.classId, item)) continue;
if (!item || !classCanEquipItem(context.classId, item, context)) continue;
if (slot === "main-hand" && item.inventorySlot === "two-hand") delete result["off-hand"];
if (slot === "off-hand" && result["main-hand"]?.inventorySlot === "two-hand") {
result["main-hand"] = heirloomItem(context, profile, "main-hand", "one-hand");
+15 -6
View File
@@ -13,7 +13,8 @@ import {
type RotationFriendlyTarget,
} from "./combatRotation";
import { spendDeathKnightRunes } from "./combatResources";
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
import { resolveResourceValue } from "./combatAuras";
import { equipmentAdjustedCombatAmount, useCombatStore, type MobCombatState } from "./combatStore";
import type { PartyMember } from "./partyStore";
import { baseResourcesFor } from "./wotlkStats";
import { companionResourceMaximum } from "./combatActorAuthority";
@@ -51,12 +52,20 @@ function currentResource(member: PartyMember, type: ResourceType): number {
}
function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ({
resource: cost.resource,
amount: cost.percentage ? cost.amount / 100 * (cost.percentageBasis === "base-mana"
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id);
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => {
const maximum = cost.percentageBasis === "base-mana"
? baseResourcesFor(member.classId, member.level).mana
: cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource)) : cost.amount,
}));
: cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource);
const baseAmount = cost.percentage ? cost.amount / 100 * maximum : cost.amount;
const adjusted = resolveResourceValue(
resolveResourceValue(baseAmount, auras, cost.resource, "cost"),
auras,
"power",
"cost",
);
return { resource: cost.resource, amount: Math.max(0, Math.round(adjusted)) };
});
}
export function partyAbilityIsReady(
+94 -2
View File
@@ -82,6 +82,9 @@ import {
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics";
import { romCreatureFamilyTargetTags } from "./romCreatureFamilies";
import { romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset } from "./romMageWarlockOverrides";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
@@ -768,9 +771,17 @@ function partyAbilityTargetIsValid(
&& aura.definition.disposition === "buff" && aura.definition.dispelCategory === "enrage"
&& !aura.definition.name.startsWith("Enraged Regeneration"))) return false;
if (ability.source === "runewaker") {
if (ability.dbcSpellId === 491613 && mob?.creatureFamily === "living"
&& !(member.classId === "rom-knight" && member.secondaryClassId === "rom-scout" && member.level >= 60)) return false;
if (ability.dbcSpellId === 490347 && mob?.creatureFamily === "undead") return false;
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 === 493017 && !combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("rom-poisonous"))) return false;
if (ability.dbcSpellId === 492920 && !combat.effects.some((effect) => effect.kind === "dot"
&& effect.targetId === targetId && effect.remainingTicks > 0
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) 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
@@ -778,6 +789,7 @@ function partyAbilityTargetIsValid(
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) {
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId, role: member.role, level: member.level,
});
if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") return false;
@@ -1130,7 +1142,21 @@ function executePartyAbility(
depth = 0,
castStartAlreadyEmitted = false,
): PartyAbilityExecutionResult {
const combat = useCombatStore.getState();
let combat = useCombatStore.getState();
if (ability.source === "runewaker") {
for (const passive of abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.passive)
.map((candidate) => learnedAbilityForBuild(candidate, member.level, member.talentRanks))
.filter((candidate): candidate is AbilityDefinition => Boolean(candidate))) {
for (const effect of passive.effects) {
if (effect.kind !== "apply-aura" || effect.recipient !== "caster") continue;
if (combat.auras.some((aura) => aura.sourceId === member.id
&& aura.targetId === member.id && aura.definition.id === effect.aura.id)) continue;
useCombatStore.getState().applyActorAura(member.id, member.id, passive, effect, now);
combat = useCombatStore.getState();
}
}
}
if (ability.name === "Enraged Regeneration" && !combat.consumeActorEnrage(member.id, now)) {
return { member, effectiveAmount: 0 };
}
@@ -1197,10 +1223,34 @@ function executePartyAbility(
&& aura.definition.utilityTags?.includes("rom-charged")),
mainHandDagger: member.classId === "rom-rogue" && resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId, role: member.role, level: member.level,
})["main-hand"]?.weaponType === "dagger",
casterMaxHealth: member.maxHealth,
passiveTags: combat.auras.filter((aura) => aura.targetId === member.id)
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.passive)
.map((candidate) => learnedAbilityForBuild(candidate, member.level, member.talentRanks))
.filter((candidate): candidate is AbilityDefinition => Boolean(candidate))
.flatMap((candidate) => candidate.effects.flatMap((effect) => effect.kind === "apply-aura"
? effect.aura.utilityTags ?? [] : [])),
),
targetTags: combat.auras.filter((aura) => aura.targetId === targetId)
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
romCreatureFamilyTargetTags(combat.mobs[targetId]?.creatureFamily ?? "unknown"),
combat.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
.flatMap((effect) => effect.tag ? [effect.tag] : []),
),
});
if (ability.dbcSpellId === 492920 && castEffects.some((effect) => effect.kind === "dot"
&& effect.tag === "rom-622992")) {
useCombatStore.setState((state) => ({
effects: state.effects.filter((effect) => !(effect.kind === "dot"
&& effect.targetId === targetId
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")),
}));
}
for (const effect of castEffects) {
if (ability.name === "Penance" && combat.mobs[targetId]
&& (effect.kind === "heal" || effect.kind === "hot")) continue;
@@ -1244,6 +1294,20 @@ function executePartyAbility(
} : undefined,
);
effectiveAmount += result.amount;
if (effect.kind === "damage" && ability.dbcSpellId === 490133) {
const currentCombat = useCombatStore.getState();
const bonus = romScoutRogueWhirlwindBonusEffect(
ability,
currentCombat.auras.filter((aura) => aura.targetId === member.id)
.flatMap((aura) => aura.definition.utilityTags ?? []),
currentCombat.effects.filter((active) => active.targetId === hostileTargetId && active.remainingTicks > 0)
.flatMap((active) => active.tag ? [active.tag] : []),
);
if (bonus) effectiveAmount += currentCombat.damageMob(
hostileTargetId, partyAbilityAmount(runtimeMember, ability, bonus), now,
{ actorId: member.id, role: member.role, abilityId: ability.id }, "physical",
).amount;
}
if (
result.amount > 0
&& effect.kind === "damage"
@@ -1355,7 +1419,7 @@ function executePartyAbility(
: effect.recipient === "caster"
? [member.id]
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
for (const recipientId of recipients) {
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== member.id)) {
const appliedEffect = ability.name === "Rupture"
? { ...effect, aura: { ...effect.aura, durationMs: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) * 2_000 } }
: ability.name === "Envenom"
@@ -1535,6 +1599,34 @@ function executePartyAbility(
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");
if (ability.dbcSpellId === 498759) {
const currentCombat = useCombatStore.getState();
const reset = romFireLightningBurstReset(
ability,
Boolean(currentCombat.mobs[targetId] && !currentCombat.mobs[targetId].dead
&& currentCombat.mobs[targetId].health > 0),
currentCombat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes(ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG)),
);
if (reset.reset) {
useCombatStore.getState().applyActorAura(member.id, member.id, ability, {
kind: "apply-aura",
recipient: "caster",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 621514,
aura: {
id: "rom-fire-lightning-burst-reset-guard",
name: "Fire Lightning Burst reset guard",
disposition: "buff",
durationMs: reset.guardDurationMs,
utilityTags: [ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG],
},
}, now);
runtimeMember = { ...runtimeMember, cooldowns: { ...runtimeMember.cooldowns, [ability.id]: 0 } };
}
}
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))) {
+2
View File
@@ -350,6 +350,7 @@ function createMember(
ownerId: id,
ownerKind: "party",
classId: template.classId,
secondaryClassId: level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
specializationId,
role,
level,
@@ -696,6 +697,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
+58 -11
View File
@@ -14,6 +14,9 @@ import {
type RomGeneratedAbilityLike,
type RomSourceComponent,
} from "./romAbilitySemantics";
import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides";
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -204,7 +207,7 @@ function auraModifiers(semantic: Extract<RomAbilitySemantic, { kind: "aura" }>):
}
if (type === 142) return magicDamageTaken(-percent(value));
if (type === 143) return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -percent(value) }];
if (type === 144) return [{ kind: "healing", direction: "taken", operation: "percent", value: -percent(value) }];
if (type === 144) return [{ kind: "healing", direction: "taken", operation: "percent", value: percent(value) }];
if (type === 148) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "flat", value }];
if (type === 149) return [{ kind: "healing", direction: "done", operation: "percent", value: percent(value) }];
if (type === 150) return [{ kind: "healing", direction: "done", operation: "flat", value }];
@@ -271,8 +274,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
const sourcePoints = coefficientCalculation ? 0 : rawPower;
const pointsPerLevel = sourcePoints * Math.max(0, semantic.powerPerSkillLevel) / 100;
const basePoints = sourcePoints + pointsPerLevel;
return {
kind: semantic.kind,
const amount = {
coefficient,
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
...(basePoints > 0 ? {
@@ -281,9 +283,16 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
sourceLevel: 1,
...(coefficient > 0 ? { bonusPowerCoefficient: coefficient } : {}),
} : {}),
...(semantic.kind === "heal" && semantic.percentMaxHealth !== undefined
? { percentMaxHealth: semantic.percentMaxHealth }
: {}),
};
if (semantic.kind === "damage") return {
kind: "damage", ...amount,
...(combatSchool(semantic.school) ? { school: combatSchool(semantic.school) } : {}),
};
return {
kind: "heal", ...amount,
...(semantic.percentMaxHealth !== undefined ? { percentMaxHealth: semantic.percentMaxHealth } : {}),
...(/\b(?:all teammates|(?:all|every) party members?)\b/i.test(row.description)
? { scope: "party" as const } : {}),
};
}
if (semantic.kind === "dot" || semantic.kind === "hot") {
@@ -292,6 +301,9 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
return {
kind: semantic.kind,
coefficient: 0,
...(semantic.kind === "dot" && combatSchool(semantic.school)
? { school: combatSchool(semantic.school) }
: {}),
basePoints: Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
sourceLevel: 1,
@@ -327,7 +339,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
if (semantic.kind === "aura") {
const recipient = auraRecipient(row, semantic);
const modifiers = auraModifiers(semantic);
const partyScope = /\b(?:all )?party members?\b/i.test(row.description);
const partyScope = /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description);
const aura: AuraDefinition = {
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
@@ -450,6 +462,21 @@ function stateAuraEffect(
}
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
if (row.sourceSkillId === 498744) {
return [{ kind: "cooldown-reset", mode: "reset", abilityName: "Electrostatic Charge" }];
}
if (row.sourceSkillId === 498758) {
return [
{ kind: "cooldown-reset", mode: "reset", abilityName: "Intensification" },
{ kind: "cooldown-reset", mode: "reset", abilityName: "Energy Well" },
];
}
if (row.sourceSkillId === 498759) {
// Its own cooldown resets only after the two hits leave the target alive.
return translateRomAbilitySemantics(row)
.map((semantic, index) => semanticEffect(row, semantic, index))
.filter((effect) => effect.kind === "damage");
}
if (row.sourceSkillId === 491163) {
return [stateAuraEffect(row, "frost-arrow", "Frost Arrow", 600_000, {
procs: [{
@@ -497,6 +524,10 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
const specialized = specializedEffects(row);
if (specialized) return specialized;
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
const pairOverride = overrideRomPriestKnightEffects(row, translated)
?? overrideRomScoutRogueEffects(row, translated)
?? overrideRomMageWarlockEffects(row, translated);
if (pairOverride) return pairOverride;
const outputStrength = (effect: AbilityEffect): number => (
"coefficient" in effect ? effect.coefficient : 0
) + ("coefficientPerLevel" in effect ? effect.coefficientPerLevel ?? 0 : 0)
@@ -640,9 +671,18 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
}
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|friendly targets within|targets within (?:a )?range)\b/i.test(row.description)) return undefined;
if (row.description.includes("up to 3 targets") && row.description.includes("Inflicts")) return 6;
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) within (?:a )?range)\b/i.test(row.description)) return undefined;
const sourceRange = row.description.match(/\b(?:range|radius) of\s+(\d+(?:\.\d+)?)/i)?.[1];
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range || 8));
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range > 1 ? row.range : 10));
}
function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"] | undefined {
if (sourceSkillId === 490400) return { shape: "line", maximumTargets: 8 };
if (sourceSkillId === 490457) return { shape: "chain", maximumTargets: 3, jumpRange: 6 };
if (sourceSkillId === 492920) return { shape: "circle", radius: 5, maximumTargets: 8 };
if (sourceSkillId === 497959 || sourceSkillId === 497966) return { shape: "circle", radius: 6, maximumTargets: 3 };
return undefined;
}
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
@@ -685,7 +725,10 @@ function ability(row: GeneratedSkill): AbilityDefinition {
description: row.description,
target: row.target,
range: { min: 0, max: row.range },
...(areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
? { radius: sourceTargeting(row.sourceSkillId)!.radius }
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
castMode: row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs,
cooldownMs: row.cooldownMs,
@@ -701,7 +744,11 @@ function ability(row: GeneratedSkill): AbilityDefinition {
};
}
export const ROM_ALL_ABILITIES = Object.freeze(generated.map(ability));
const generatedAbilities = generated.map(ability);
export const ROM_ALL_ABILITIES = Object.freeze([
...generatedAbilities,
...romPriestKnightAdditionalAbilities(generatedAbilities),
]);
const romAbilityByGeneratedId = new Map(ROM_ALL_ABILITIES.map((entry) => [entry.id, entry]));
const triggeredComponentOwners = new Map<number, { readonly parent: GeneratedSkill; readonly component: RomSourceComponent }>();
+1 -1
View File
@@ -114,7 +114,7 @@ describe("executable RuneWaker ability mappings", () => {
})),
]);
expect(versions).toHaveLength(1_863);
expect(versions).toHaveLength(1_864);
const scriptOnly = ROM_ALL_ABILITIES
.filter((ability) => !ability.passive && ability.effects.every((effect) => effect.kind === "scripted"))
.map((ability) => ability.name)
+4
View File
@@ -127,6 +127,7 @@ export type RomAbilitySemantic =
})
| (RomSemanticBase & {
readonly kind: "dot" | "hot";
readonly school: string;
readonly resource: string;
readonly intervalSeconds: number;
readonly durationSeconds: number;
@@ -530,6 +531,7 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
const healing = target !== "hostile" && dot.base > 0;
addIfMissing(rows, {
kind: healing ? "hot" : "dot",
school: schoolFor(component),
resource,
intervalSeconds: dot.timeSeconds,
durationSeconds: positiveDuration(component),
@@ -758,6 +760,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
if (/\bbleed|damage over time|damage every|damage each (?:second|time)/.test(lower) && !hasKind(rows, "dot")) {
rows.push({
kind: "dot",
school: "tooltip-defined",
resource: "health",
intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0,
durationSeconds,
@@ -769,6 +772,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
if (/restores? .*hp.*(?:every|each) /.test(lower) && !hasKind(rows, "hot")) {
rows.push({
kind: "hot",
school: "healing",
resource: "health",
intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0,
durationSeconds,
+25 -1
View File
@@ -1,4 +1,7 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
import { romMageWarlockCastEffects } from "./romMageWarlockOverrides";
import { romScoutRogueCastEffects } from "./romScoutRogueOverrides";
const ROGUE_BLEED_ABILITIES = new Set([
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
@@ -13,6 +16,8 @@ export interface RomCastMechanics {
readonly charged: boolean;
readonly mainHandDagger: boolean;
readonly casterMaxHealth: number;
readonly passiveTags?: readonly string[];
readonly targetTags?: readonly string[];
}
/** Source periodic values for self-sacrifice toggle skills are percentages of HP. */
@@ -61,10 +66,29 @@ function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
/** 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 priestKnightEffects = romPriestKnightCastEffects(
ability, ability.effects, context.passiveTags ?? [], context.casterMaxHealth, context.targetTags ?? [],
);
const mageWarlockEffects = romMageWarlockCastEffects(ability, priestKnightEffects, new Set(context.passiveTags ?? []));
const effects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []);
const damage = effects.filter((effect) => effect.kind === "damage");
const seals = Math.max(0, Math.min(4, context.sealStacks));
switch (ability.dbcSpellId) {
case 492625: // Infection's poison rider needs Poisonous active.
return context.passiveTags?.includes("rom-poisonous")
? effects : effects.filter((effect) => effect.kind !== "dot");
case 491156: // Electrostatic Charge's source shield is a percent of max HP.
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
? {
...effect,
aura: {
...effect.aura,
absorbs: effect.aura.absorbs.map((pool) => ({
...pool,
amount: Math.max(1, Math.round(context.casterMaxHealth * pool.amount / 100)),
})),
},
} : effect);
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;
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { romCreatureFamilyForServerEntry } from "./romCreatureFamilies";
describe("RuneWaker creature-family combat rules", () => {
beforeEach(() => clearMobRuntimeRegistry());
it("uses NPCObjectDB race records for living, Undead, and Demon targets", () => {
expect(romCreatureFamilyForServerEntry(103946)).toBe("living");
expect(romCreatureFamilyForServerEntry(106432)).toBe("undead");
expect(romCreatureFamilyForServerEntry(102698)).toBe("demon");
expect(romCreatureFamilyForServerEntry(null)).toBe("unknown");
});
it("restricts base Holy Chains and unlocks living targets with Evil Shackles", () => {
for (const level of [30, 60]) {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level,
});
useCombatStore.setState({ playerPosition: [0, 0, 0] });
useCombatStore.getState().registerMob("living", {
name: "Shadoj", serverEntry: 103946, maxHealth: 1_000_000,
});
setMobPosition("living", [3, 0, 0]);
useCombatStore.getState().selectMob("living");
expect(useCombatStore.getState().mobs.living?.creatureFamily).toBe("living");
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-25", undefined, 1_000))
.toMatchObject(level < 60 ? { ok: false, reason: "condition-not-met" } : { ok: true });
}
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30,
});
useCombatStore.getState().registerMob("undead", {
name: "Self-Loathing Ghost", serverEntry: 106432, maxHealth: 1_000_000,
});
setMobPosition("undead", [3, 0, 0]);
useCombatStore.getState().selectMob("undead");
expect(useCombatStore.getState().mobs.undead?.creatureFamily).toBe("undead");
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-25", undefined, 1_000))
.toMatchObject({ ok: true });
});
it("accepts the Poisonous state without a bag item and rejects Numbing Dagger on Undead", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 50,
});
useCombatStore.setState({ playerPosition: [0, 0, 0] });
for (const [id, entry] of [["undead", 106432], ["living", 103946]] as const) {
useCombatStore.getState().registerMob(id, { name: id, serverEntry: entry, maxHealth: 1_000_000 });
setMobPosition(id, [3, 0, 0]);
}
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 2_100);
useCombatStore.getState().selectMob("undead");
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.getState().selectMob("living");
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
.toMatchObject({ ok: true });
});
});
+21
View File
@@ -0,0 +1,21 @@
import source from "./generated/romCreatureFamilies.json";
export type CombatCreatureFamily = "living" | "undead" | "demon" | "unknown";
const known = new Set<number>(source.knownIds);
const undead = new Set<number>(source.undeadIds);
const demon = new Set<number>(source.demonIds);
/** RuneWaker NPCObjectDB race field, exported by export-rom-creature-families.mjs. */
export function romCreatureFamilyForServerEntry(serverEntry: number | null | undefined): CombatCreatureFamily {
if (serverEntry == null || !known.has(serverEntry)) return "unknown";
if (undead.has(serverEntry)) return "undead";
if (demon.has(serverEntry)) return "demon";
return "living";
}
export function romCreatureFamilyTargetTags(family: CombatCreatureFamily): readonly string[] {
if (family === "undead" || family === "demon") return ["rom-target-undead-demon"];
if (family === "living") return ["rom-target-living"];
return [];
}
+119 -5
View File
@@ -3,23 +3,54 @@ import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog"
import { isValidRomClassPair } from "../app/romDualClass";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { readinessStatusFor } from "./companionSpecReadiness";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import { classCanEquipItem, equipInventoryItem } from "./equipment";
import { equipmentStatsForItems, 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 { EMPTY_ITEM_STATS } from "./itemStats";
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
import { romGearProfile } from "./specializationCatalog";
import { baseAttributesFor, baseResourcesFor, deriveCharacterStats, healthFromTotalStamina, manaFromTotalIntellect, MANA_POOL_MULTIPLIER } from "./wotlkStats";
const CASES = [
{ classId: "rom-priest", role: "healer", layout: "caster" },
{ classId: "rom-priest", role: "healer", layout: "weapon-shield" },
{ 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" },
{ classId: "rom-mage", role: "damage", layout: "two-hand" },
] as const;
describe("RoM five class validation scope", () => {
it.each([1, 10, 30, 60, 79])("uses WoW base attribute and resource curves for the accepted pairs at level %i", (level) => {
const referenceRace: Record<string, "human" | "night-elf"> = {
"rom-knight": "human",
"rom-priest": "human",
"rom-scout": "night-elf",
"rom-rogue": "human",
"rom-mage": "human",
};
for (const identity of ROM_FIVE_IDENTITIES) {
const secondary = level >= 10 ? identity.secondaryClassId : null;
const primaryBase = baseAttributesFor(identity.classId, level, referenceRace[identity.classId] ?? "human");
const secondaryBase = secondary ? baseAttributesFor(secondary, level, referenceRace[secondary] ?? "human") : null;
const expected = Object.fromEntries((Object.keys(primaryBase) as (keyof typeof primaryBase)[]).map((stat) => [
stat, Math.round(primaryBase[stat] + (secondaryBase?.[stat] ?? 0) * .1),
]));
const combat = deriveCharacterStats(identity.classId, level, EMPTY_ITEM_STATS, identity.raceId, {}, secondary);
expect(combat, `${identity.name} at ${level}`).toMatchObject(expected);
const baseResources = baseResourcesFor(identity.classId, level);
const secondaryResources = secondary ? baseResourcesFor(secondary, level) : null;
expect(combat.health).toBe(Math.round(baseResources.health + (secondaryResources?.health ?? 0) * .1
+ healthFromTotalStamina(combat.stamina)));
if (identity.classId === "rom-knight" || identity.classId === "rom-priest" || identity.classId === "rom-mage") {
expect(combat.mana).toBe(MANA_POOL_MULTIPLIER * Math.round(baseResources.mana
+ (secondaryResources?.mana ?? 0) * .1 + manaFromTotalIntellect(combat.intellect)));
}
}
});
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);
@@ -121,15 +152,98 @@ describe("RoM five class validation scope", () => {
ownerId: `rom-five:${classId}`,
ownerKind: "party",
classId,
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
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-knight") {
expect(loadout["off-hand"]?.armorType).toBe("shield");
expect(loadout.ranged?.weaponType).toBe("bow");
}
if (classId === "rom-priest") {
expect(loadout["main-hand"]?.weaponType).toBe("mace");
expect(loadout["off-hand"]?.armorType).toBe("shield");
expect(loadout.ranged).toBeUndefined();
}
if (classId === "rom-scout") {
expect(loadout["main-hand"]?.weaponType).toBe("dagger");
expect(loadout["main-hand"]?.inventorySlot).toBe("one-hand");
expect(loadout.ranged?.weaponType).toBe("bow");
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
}
if (classId === "rom-rogue") {
expect(loadout["main-hand"]?.weaponType).toBe("dagger");
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
expect(loadout.ranged?.weaponType).toBe("projectile");
}
if (classId === "rom-mage") {
expect(loadout["main-hand"]?.weaponType).toBe("staff");
expect(loadout["main-hand"]?.inventorySlot).toBe("two-hand");
expect(loadout["off-hand"]).toBeUndefined();
expect(loadout.ranged).toBeUndefined();
}
expect(loadout.wrist).toBeUndefined();
for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!, {
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
level: 60,
})).toBe(true);
}
});
it("opens the independent ranged and off-hand positions at the source training levels", () => {
const loadout = (classId: "rom-knight" | "rom-scout" | "rom-rogue", secondaryClassId: "rom-scout" | "rom-rogue" | "rom-warrior", level: number) =>
resolvedEquipmentLoadout({}, [], {
ownerId: `rom-slots:${classId}`,
ownerKind: "player",
classId,
secondaryClassId: level >= 10 ? secondaryClassId : null,
specializationId: null,
role: classId === "rom-knight" ? "tank" : "damage",
level,
});
expect(loadout("rom-knight", "rom-scout", 9).ranged).toBeUndefined();
expect(loadout("rom-knight", "rom-scout", 10).ranged?.weaponType).toBe("bow");
expect(loadout("rom-scout", "rom-rogue", 15)["off-hand"]).toBeUndefined();
expect(loadout("rom-scout", "rom-rogue", 16)["off-hand"]?.weaponType).toBe("dagger");
expect(loadout("rom-rogue", "rom-warrior", 11).ranged).toBeUndefined();
expect(loadout("rom-rogue", "rom-warrior", 12).ranged?.weaponType).toBe("projectile");
expect(loadout("rom-rogue", "rom-warrior", 15)["off-hand"]).toBeUndefined();
expect(loadout("rom-rogue", "rom-warrior", 16)["off-hand"]?.weaponType).toBe("dagger");
const knightBow = loadout("rom-knight", "rom-scout", 60).ranged!;
expect(classCanEquipItem("rom-knight", knightBow, { level: 60 })).toBe(false);
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(true);
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
{ secondaryClassId: "rom-priest", level: 60 }).ok).toBe(false);
});
it("grows the five hybrid combat loadouts through the heirloom levels", () => {
for (const { classId, role } of CASES) {
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === classId && entry.role === role)!;
let priorHealth = 0;
let priorPower = 0;
for (const level of [1, 20, 40, 60, 79]) {
const loadout = resolvedEquipmentLoadout({}, [], {
ownerId: `rom-hybrid:${classId}`,
ownerKind: "player",
classId,
secondaryClassId: level >= 10 ? identity.secondaryClassId : null,
specializationId: null,
role,
level,
});
const gear = equipmentStatsForItems(Object.values(loadout).filter((item) => item !== undefined));
const combat = deriveCharacterStats(classId, level, gear, identity.raceId, {}, level >= 10 ? identity.secondaryClassId : null);
const power = classId === "rom-scout" ? combat.rangedAttackPower
: classId === "rom-rogue" || classId === "rom-knight" ? combat.attackPower
: combat.spellPower;
expect(combat.health, `${classId} health at ${level}`).toBeGreaterThan(priorHealth);
expect(power, `${classId} power at ${level}`).toBeGreaterThan(priorPower);
expect(Object.values(loadout).every((item) => item?.itemLevel === level)).toBe(true);
if (classId === "rom-knight" || classId === "rom-priest") expect(combat.blockValue).toBeGreaterThan(0);
priorHealth = combat.health;
priorPower = power;
}
}
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { classById } from "../app/characterCatalog";
import {
romHeirloomReference,
romSourceReferenceItem,
ROM_HEIRLOOM_REFERENCE_CLASSES,
ROM_HEIRLOOM_REFERENCE_SLOTS,
} from "./romHeirloomReference";
describe("RoM source-backed heirloom references", () => {
it("provides finite native stats and source item IDs at every level from 1 through 79", () => {
for (const classId of ROM_HEIRLOOM_REFERENCE_CLASSES) {
for (const slot of ROM_HEIRLOOM_REFERENCE_SLOTS) {
const first = romHeirloomReference(classId, slot, 1);
if (!first) continue;
for (let level = 1; level <= 79; level += 1) {
const reference = romHeirloomReference(classId, slot, level)!;
expect(reference.level).toBe(level);
expect(reference.referenceItemIds.length).toBeGreaterThan(0);
expect(Object.values(reference.stats).length).toBeGreaterThan(0);
for (const value of Object.values(reference.stats)) {
expect(Number.isFinite(value)).toBe(true);
expect(value).toBeGreaterThanOrEqual(0);
}
for (const itemId of reference.referenceItemIds) {
expect(romSourceReferenceItem(itemId)?.quality).toBe(2);
}
}
}
}
});
it("uses source package suit anchors and a leather reference for the Scout", () => {
expect(romHeirloomReference("rom-knight", "chest", 50)?.packageId).toBe(240384);
expect(romHeirloomReference("rom-scout", "chest", 60)?.packageId).toBe(240347);
expect(romHeirloomReference("rom-priest", "chest", 65)?.packageId).toBe(240313);
expect(classById("rom-scout").armor).toBe("leather");
const scoutChest = romHeirloomReference("rom-scout", "chest", 65)!;
expect(scoutChest.referenceItemIds.some((id) => romSourceReferenceItem(id)?.type === 2)).toBe(true);
expect(romHeirloomReference("rom-priest", "off-hand", 65)).toBeNull();
expect(romSourceReferenceItem(213416)?.stats).toMatchObject({ magicalWeaponDamage: 1094, wisdom: 56 });
expect(romSourceReferenceItem(229164)?.stats).toMatchObject({
physicalDefense: 1029,
magicalDefense: 1655,
health: 273,
magicalAttack: 290,
});
});
it("clamps queries to the designed 1–79 heirloom interval", () => {
expect(romHeirloomReference("rom-mage", "main-hand", -20)?.level).toBe(1);
expect(romHeirloomReference("rom-mage", "main-hand", 80)?.level).toBe(79);
expect(romHeirloomReference("rom-mage", "off-hand", 40)).toBeNull();
});
});
+77
View File
@@ -0,0 +1,77 @@
import catalog from "./generated/romHeirloomReference.json";
export const ROM_HEIRLOOM_REFERENCE_CLASSES = [
"rom-knight", "rom-priest", "rom-scout", "rom-rogue", "rom-mage",
] as const;
export type RomHeirloomReferenceClass = (typeof ROM_HEIRLOOM_REFERENCE_CLASSES)[number];
export const ROM_HEIRLOOM_REFERENCE_SLOTS = [
"head", "neck", "shoulder", "back", "chest", "hands", "waist",
"legs", "feet", "finger", "trinket", "main-hand", "off-hand", "ranged",
] as const;
export type RomHeirloomReferenceSlot = (typeof ROM_HEIRLOOM_REFERENCE_SLOTS)[number];
export interface RomNativeGearStats {
readonly strength?: number;
readonly stamina?: number;
readonly intellect?: number;
readonly wisdom?: number;
readonly agility?: number;
readonly health?: number;
readonly mana?: number;
readonly healthRecovery?: number;
readonly manaRecovery?: number;
readonly physicalAttack?: number;
readonly physicalDefense?: number;
readonly magicalDefense?: number;
readonly magicalAttack?: number;
readonly physicalHit?: number;
readonly magicalHit?: number;
readonly dodge?: number;
readonly parry?: number;
readonly physicalCritical?: number;
readonly magicalCritical?: number;
readonly weaponDamage?: number;
readonly magicalDamage?: number;
readonly magicalWeaponDamage?: number;
readonly healingPower?: number;
}
export interface RomHeirloomReference {
readonly level: number;
readonly stats: RomNativeGearStats;
readonly referenceItemIds: readonly number[];
readonly packageId?: number;
readonly speedTenths?: number;
}
interface RomSourceReferenceItem {
readonly itemId: number;
readonly name: string;
readonly level: number;
readonly quality: number;
readonly sourceCatalog: string;
readonly type: number;
readonly position: number;
readonly suitId: number;
readonly stats: RomNativeGearStats;
}
const profiles = catalog.profiles as unknown as Readonly<Record<RomHeirloomReferenceClass, Readonly<Partial<Record<RomHeirloomReferenceSlot, readonly RomHeirloomReference[]>>>>>;
const sourceItems = new Map((catalog.sourceItems as readonly RomSourceReferenceItem[]).map((item) => [item.itemId, item]));
/** Source-backed design values; not yet the combat runtime's equipped item stats. */
export function romHeirloomReference(
classId: RomHeirloomReferenceClass,
slot: RomHeirloomReferenceSlot,
level: number,
): RomHeirloomReference | null {
const curve = profiles[classId]?.[slot];
if (!curve) return null;
const safeLevel = Math.max(1, Math.min(79, Math.trunc(Number.isFinite(level) ? level : 1)));
return curve[safeLevel - 1] ?? null;
}
export function romSourceReferenceItem(itemId: number): RomSourceReferenceItem | null {
return sourceItems.get(itemId) ?? null;
}
+243
View File
@@ -0,0 +1,243 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
/** The absorb amounts in Electrostatic Charge's source row are HP percentages. */
export const ROM_ELECTROSTATIC_PERCENT_ABSORB_TAG = "rom-electrostatic-percent-max-health";
export const ROM_MAGE_WARLOCK_PASSIVE_TAGS = Object.freeze({
industryFire: "rom-mage-warlock-industry-fire",
lightningDisplay: "rom-mage-warlock-lightning-display",
elementsOfPride: "rom-mage-warlock-elements-of-pride",
soulSpeed: "rom-mage-warlock-soul-speed",
elementalExtraction: "rom-mage-warlock-elemental-extraction",
} as const);
export const ROM_SOUL_STEPPING_CAST_TAG = "rom-mage-warlock-soul-stepping-cast";
export const ROM_BREATH_ERASE_THREAT_TAG = "rom-mage-warlock-breath-erase-threat";
export const ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG = "rom-fire-lightning-burst-reset-guard";
export const ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_MS = 6_000;
// Hidden replacement skill 498746: 621504 is the initial Fire hit, and
// 621503 is its 24-second Fire DoT. These values come from MagicObject.db.
const INDUSTRY_FIRE_HIT_BASE = 140;
const INDUSTRY_FIRE_HIT_PER_LEVEL = 40;
const INDUSTRY_FIRE_DOT_BASE = 23.4;
const INDUSTRY_FIRE_DOT_PER_LEVEL = 10.4;
function stateAura(id: string, tag: string, durationMs: number | null): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-mage-warlock-state-${id}`,
name: "Mage/Warlock class state",
disposition: "buff",
durationMs,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [tag],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
};
}
/**
* Source-specific mappings for the accepted Mage/Warlock build. These auras
* are state markers; the combat runtime applies the conditional skill effects
* only when the named source spells execute or critically hit their target.
*/
export function overrideRomMageWarlockEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.sourceSkillId === 491156) {
return translated.map((effect) => {
if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return effect;
return {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_ELECTROSTATIC_PERCENT_ABSORB_TAG],
},
};
});
}
if (row.sourceSkillId === 498750) {
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_BREATH_ERASE_THREAT_TAG],
},
} : effect);
}
if (row.sourceSkillId === 498745) return [stateAura("industry-fire", ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire, null)];
if (row.sourceSkillId === 498748) return [stateAura("lightning-display", ROM_MAGE_WARLOCK_PASSIVE_TAGS.lightningDisplay, null)];
if (row.sourceSkillId === 498751) return [stateAura("elements-of-pride", ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementsOfPride, null)];
if (row.sourceSkillId === 499813) return [stateAura("soul-speed", ROM_MAGE_WARLOCK_PASSIVE_TAGS.soulSpeed, null)];
if (row.sourceSkillId === 499814) return [stateAura("elemental-extraction", ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction, null)];
if (row.sourceSkillId === 498757) return [
...translated,
stateAura("soul-stepping-cast", ROM_SOUL_STEPPING_CAST_TAG, 6_000),
];
return null;
}
function elementsOfPrideAura(school: "fire" | "nature", value: number): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-mage-warlock-elements-${school}`,
name: `Elements of Pride: ${school}`,
disposition: "buff",
durationMs: 13_000,
maxStacks: 2,
stackBehavior: "add",
modifiers: [{ kind: "damage", direction: "dealt", school, operation: "percent", value, perStack: true }],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 498751,
};
}
/** Add pair-elite riders to an executable cast after conditional source selection. */
export function romMageWarlockCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: ReadonlySet<string>,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return effects;
if (ability.dbcSpellId === 497964 && passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire)) {
return [
{
kind: "damage",
school: "fire",
coefficient: 0,
basePoints: INDUSTRY_FIRE_HIT_BASE,
pointsPerLevel: INDUSTRY_FIRE_HIT_PER_LEVEL,
sourceLevel: 1,
},
{
kind: "dot",
school: "fire",
coefficient: 0,
basePoints: INDUSTRY_FIRE_DOT_BASE,
pointsPerLevel: INDUSTRY_FIRE_DOT_PER_LEVEL,
sourceLevel: 1,
ticks: 24,
intervalMs: 1_000,
tag: "rom-industry-fire",
},
];
}
let result = effects;
if (passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.lightningDisplay)) {
result = result.map((effect) => {
if (effect.kind === "apply-aura" && [491169, 498749].includes(ability.dbcSpellId)) {
if (effect.aura.utilityTags?.includes("rom-charged")) {
return { ...effect, aura: { ...effect.aura, durationMs: 18_000 } };
}
if (effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "magical-critical-strike")) {
return {
...effect,
aura: {
...effect.aura,
durationMs: 18_000,
modifiers: [
...effect.aura.modifiers,
{ kind: "damage" as const, direction: "dealt" as const, school: "nature" as const,
operation: "percent" as const, value: 0.01 },
],
},
};
}
}
return effect;
});
}
if (passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementsOfPride)) {
if ([491150, 498755, 499812].includes(ability.dbcSpellId)) {
result = [...result, elementsOfPrideAura("nature", 0.02)];
}
if ([491170, 498756, 499851].includes(ability.dbcSpellId)) {
result = [...result, elementsOfPrideAura("fire", 0.01)];
}
}
if (ability.dbcSpellId === 491170 && passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction)) {
// Hidden level-70 Electric Explosion 499851 replaces the normal branch's
// 620232 hit with 623668; its Charged 623669 hit keeps the same values.
const damage = result.filter((effect) => effect.kind === "damage");
const normal = damage.at(-1);
if (damage.length >= 2 && normal) result = result.map((effect) => effect === normal ? {
kind: "damage" as const,
school: "nature" as const,
coefficient: 0,
basePoints: 65,
pointsPerLevel: 15,
sourceLevel: 1,
} : effect);
}
if (ability.dbcSpellId === 498757 && !passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.soulSpeed)) {
result = result.filter((effect) => effect.kind !== "apply-aura"
|| (effect.sourceAuraType !== 24 && effect.sourceAuraType !== 51));
}
return result;
}
/** Apply only after Electric Explosion or source 499812 critically hits. */
export function romMageWarlockCriticalEffects(
ability: AbilityDefinition,
passiveTags: ReadonlySet<string>,
): readonly AbilityEffect[] {
if (!passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction)
|| ![491150, 491170, 498755, 498756, 499812, 499851].includes(ability.dbcSpellId)) return [];
return [{
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-mage-warlock-elemental-extraction-debuff",
name: "Elemental Extraction",
disposition: "debuff",
durationMs: 10_000,
maxStacks: 1,
stackBehavior: "refresh",
modifiers: [
{ kind: "stat", stat: "armor", operation: "percent", value: -0.3 },
{ kind: "stat", stat: "magical-defense", operation: "percent", value: -0.3 },
],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 499814,
}];
}
/** Lua script 02557: reset Burst on a surviving target once per six seconds. */
export function romFireLightningBurstReset(
ability: AbilityDefinition,
targetAlive: boolean,
guardActive: boolean,
): { readonly reset: boolean; readonly guardDurationMs: number } {
const reset = ability.dbcSpellId === 498759 && targetAlive && !guardActive;
return { reset, guardDurationMs: reset ? ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_MS : 0 };
}
/** Breath Erase source 621506: ten percent less generated threat for five seconds. */
export function romMageWarlockThreatScale(activeTags: ReadonlySet<string>): number {
return activeTags.has(ROM_BREATH_ERASE_THREAT_TAG) ? 0.9 : 1;
}
@@ -0,0 +1,487 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, abilityById, learnedAbilityForBuild } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura as applyCombatAura } from "./combatAuras";
import { abilityDamageSchool, useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import {
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
ROM_BREATH_ERASE_THREAT_TAG,
ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_MS,
romMageWarlockCastEffects,
romMageWarlockCriticalEffects,
romFireLightningBurstReset,
romMageWarlockThreatScale,
} from "./romMageWarlockOverrides";
const ELITES = [
[15, 498744, "Static Resonance"],
[20, 498745, "Industry Fire"],
[25, 498748, "Lightning Display"],
[30, 498750, "Breath Erase"],
[35, 498751, "Elements of Pride"],
[40, 498757, "Soul Stepping"],
[45, 498758, "Deep Inspiration"],
[50, 498759, "Fire Lightning Burst"],
[60, 499813, "Soul Speed"],
[70, 499814, "Elemental Extraction"],
] as const;
function elite(level: number) {
const result = abilityById(`rom-mage-rom-warlock-elite-${level}`);
expect(result, `Mage/Warlock elite ${level}`).toBeDefined();
return result!;
}
function initialize(level: number): void {
useCombatStore.getState().initializeCharacter({
classId: "rom-mage", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", level,
});
}
function registerTarget(id = "mage-warlock-dummy", health = 100_000): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: health });
setMobPosition(id, [3, 0, 0]);
useCombatStore.getState().selectMob(id);
}
describe("accepted RoM Mage/Warlock dwarf pair", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it.each([10, 30, 60, 79])("learns the correct primary, secondary, and elite kit at level %i", (level) => {
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-mage")!;
expect(identity).toMatchObject({ raceId: "rom-dwarf", secondaryClassId: "rom-warlock", role: "damage" });
const learned = abilitiesForCharacter(identity.classId, identity.secondaryClassId)
.filter((ability) => learnedAbilityForBuild(ability, level, {}) !== null);
expect(learned.some((ability) => ability.id === "rom-mage-primary-1")).toBe(true);
expect(learned.some((ability) => ability.id === "rom-warlock-general-0")).toBe(true);
expect(learned.filter((ability) => ability.secondaryClassId === "rom-warlock")
.map((ability) => [ability.unlockLevel, ability.dbcSpellId, ability.name]))
.toEqual(ELITES.filter(([unlock]) => unlock <= level));
});
it("preserves source multi-hit and area damage, defensive absorb, control, and threat tools", () => {
expect(abilityById("rom-mage-primary-5")!.effects.filter((effect) => effect.kind === "damage")).toHaveLength(5);
expect(abilityById("rom-mage-primary-12")!.effects.filter((effect) => effect.kind === "damage")).toHaveLength(3);
expect(abilityById("rom-mage-primary-8")!.radius).toBeGreaterThan(0);
expect(abilityById("rom-mage-primary-2")!.effects.some((effect) => (
effect.kind === "apply-aura" && (effect.aura.absorbs?.length ?? 0) > 0
))).toBe(true);
expect(abilityById("rom-mage-primary-56")!.effects).toEqual([
expect.objectContaining({ kind: "status", maxTargets: 1, breakOnDamage: true }),
]);
expect(elite(30).effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "threat", operation: "percent", value: -0.1 }),
]),
}),
}),
]));
expect(elite(30).effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.utilityTags?.includes(ROM_BREATH_ERASE_THREAT_TAG))).toBe(true);
});
it("uses magic schools for Mage and Warlock magic projectiles and dark spells", () => {
for (const [id, school] of [
["rom-mage-primary-1", "fire"],
["rom-mage-primary-52", "nature"],
["rom-warlock-general-0", "shadow"],
["rom-warlock-general-4", "shadow"],
["rom-warlock-general-5", "shadow"],
] as const) {
expect(abilityDamageSchool("rom-mage", abilityById(id)!), id).toBe(school);
}
});
it("damages the selected target using both class resources", () => {
initialize(30);
registerTarget();
const manaBefore = useCombatStore.getState().resourcePools.mana!;
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 4_100);
expect(useCombatStore.getState().mobs["mage-warlock-dummy"]!.health).toBeLessThan(100_000);
expect(useCombatStore.getState().resourcePools.mana).toBeLessThan(manaBefore);
initialize(30);
registerTarget("warlock-dummy");
const focusBefore = useCombatStore.getState().resourcePools.focus!;
expect(useCombatStore.getState().castAbility("rom-warlock-general-0", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 3_100);
expect(useCombatStore.getState().mobs["warlock-dummy"]!.health).toBeLessThan(100_000);
expect(useCombatStore.getState().resourcePools.focus).toBeLessThan(focusBefore);
});
it("Electrostatic Charge creates its source percentage of maximum HP absorb", () => {
initialize(60);
const before = useCombatStore.getState();
expect(before.castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 1_100);
const charge = useCombatStore.getState().auras.find((aura) => (
aura.definition.name.includes("Electrostatic Charge") && aura.absorbs.length > 0
));
expect(charge).toBeDefined();
expect(charge!.absorbs[0]?.remaining).toBe(Math.round(before.maxHealth * 0.3));
});
it("hits nearby enemies with Mage and Warlock area spells, respecting the source target caps", () => {
initialize(30);
for (const [id, x] of [["area-a", 2], ["area-b", 3], ["area-c", 4], ["area-far", 20]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [x, 0, 0]);
}
useCombatStore.getState().selectMob("area-a");
expect(useCombatStore.getState().castAbility("rom-mage-primary-8", undefined, 1_000)).toMatchObject({ ok: true });
for (const id of ["area-a", "area-b", "area-c"]) {
expect(useCombatStore.getState().mobs[id]!.health, `Purgatory Fire ${id}`).toBeLessThan(100_000);
}
expect(useCombatStore.getState().mobs["area-far"]!.health).toBe(100_000);
initialize(30);
for (const [id, x] of [["weave-a", 2], ["weave-b", 3], ["weave-c", 4], ["weave-far", 20]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [x, 0, 0]);
}
useCombatStore.getState().selectMob("weave-a");
expect(useCombatStore.getState().castAbility("rom-warlock-general-1", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 2_100);
for (const id of ["weave-a", "weave-b", "weave-c"]) {
expect(useCombatStore.getState().mobs[id]!.health, `Weakening Weave ${id}`).toBeLessThan(100_000);
}
expect(useCombatStore.getState().mobs["weave-far"]!.health).toBe(100_000);
});
it("caps Surge of Malice at three targets even when four enemies are nearby", () => {
initialize(30);
for (const [id, x] of [
["surge-a", 2], ["surge-b", 3], ["surge-c", 4], ["surge-d", 5], ["surge-far", 20],
] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [x, 0, 0]);
}
useCombatStore.getState().selectMob("surge-a");
expect(useCombatStore.getState().castAbility("rom-warlock-general-5", undefined, 1_000))
.toMatchObject({ ok: true });
const damaged = ["surge-a", "surge-b", "surge-c", "surge-d"]
.filter((id) => useCombatStore.getState().mobs[id]!.health < 100_000);
expect(damaged).toHaveLength(3);
expect(useCombatStore.getState().mobs["surge-far"]!.health).toBe(100_000);
});
it("Static Resonance resets only Electrostatic Charge", () => {
initialize(60);
useCombatStore.setState({ cooldowns: {
"rom-mage-primary-2": 90_000,
"rom-mage-primary-9": 90_000,
"rom-mage-general-0": 90_000,
} });
expect(useCombatStore.getState().castAbility(elite(15).id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(0);
expect(useCombatStore.getState().cooldowns["rom-mage-primary-9"]).toBe(90_000);
expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(90_000);
});
it("Deep Inspiration resets Intensification and Energy Well only", () => {
initialize(60);
useCombatStore.setState({ cooldowns: {
"rom-mage-general-3": 90_000,
"rom-mage-primary-10": 90_000,
"rom-mage-primary-2": 90_000,
} });
expect(useCombatStore.getState().castAbility(elite(45).id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-general-3"]).toBe(0);
expect(useCombatStore.getState().cooldowns["rom-mage-primary-10"]).toBe(0);
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(90_000);
});
it("Soul Stepping permits a cast to finish after the Mage moves", () => {
initialize(60);
registerTarget("ordinary-moving-cast-dummy");
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
useCombatStore.getState().tick(0.1, 4_100);
expect(useCombatStore.getState().mobs["ordinary-moving-cast-dummy"]!.health).toBe(100_000);
initialize(60);
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
registerTarget("moving-cast-dummy");
expect(useCombatStore.getState().castAbility(elite(40).id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 2_600))
.toMatchObject({ ok: true });
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
useCombatStore.getState().tick(0.1, 5_700);
expect(useCombatStore.getState().mobs["moving-cast-dummy"]!.health).toBeLessThan(100_000);
});
it("Soul Speed adds its movement and spell speed buffs when Soul Stepping is cast", () => {
initialize(60);
expect(useCombatStore.getState().castAbility(elite(40).id, undefined, 1_000))
.toMatchObject({ ok: true });
const modifiers = useCombatStore.getState().auras
.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)
.flatMap((aura) => aura.definition.modifiers ?? []);
expect(modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "movement-speed", value: 0.2 }),
expect.objectContaining({ kind: "stat", stat: "spell-haste", value: 20 }),
]));
});
it("Fire Lightning Burst hits twice and resets its own cooldown only if the target survives", () => {
initialize(60);
registerTarget();
useCombatStore.setState({ cooldowns: { "rom-mage-primary-2": 90_000 } });
expect(elite(50).effects.filter((effect) => effect.kind === "damage")).toHaveLength(2);
expect(useCombatStore.getState().castAbility(elite(50).id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 3_100);
expect(useCombatStore.getState().mobs["mage-warlock-dummy"]!.health).toBeLessThan(100_000);
expect(useCombatStore.getState().cooldowns[elite(50).id] ?? 0).toBeLessThanOrEqual(3_100);
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(90_000);
expect(useCombatStore.getState().castAbility(elite(50).id, undefined, 4_600)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 6_700);
expect(useCombatStore.getState().cooldowns[elite(50).id]).toBeGreaterThan(6_700);
});
it("does not apply conditional passive buffs or enemy defense penalties to the caster on login", () => {
initialize(79);
const auraNames = useCombatStore.getState().auras.map((aura) => aura.definition.name);
for (const level of [25, 35, 60, 70]) {
expect(auraNames.some((name) => name.startsWith(elite(level).name)), elite(level).name).toBe(false);
}
expect(elite(20).effects.some((effect) => effect.kind === "scripted")).toBe(false);
});
it("uses Industry Fire's hidden Soul Pain payload during a live cast", () => {
initialize(60);
registerTarget("industry-fire-dummy");
expect(useCombatStore.getState().castAbility("rom-warlock-general-4", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 3_100);
const state = useCombatStore.getState();
expect(state.mobs["industry-fire-dummy"]!.health).toBeLessThan(100_000);
expect(state.effects.some((effect) => effect.tag === "rom-industry-fire"
&& effect.targetId === "industry-fire-dummy")).toBe(true);
});
it("triggers Lightning Display and Elements of Pride only after their source spells land", () => {
initialize(60);
registerTarget("elite-trigger-dummy");
expect(useCombatStore.getState().castAbility("rom-mage-primary-52", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 3_100);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "magical-critical-strike" && modifier.value === 20))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 4_600))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 7_700);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id === "rom-mage-warlock-elements-nature" && aura.stacks === 1)).toBe(true);
});
it("uses hidden skill 498746's exact fire hit and DoT after Industry Fire", () => {
const soulPain = abilityById("rom-warlock-general-4")!;
const ordinary = romMageWarlockCastEffects(soulPain, soulPain.effects, new Set());
const upgraded = romMageWarlockCastEffects(soulPain, soulPain.effects,
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire]));
expect(ordinary.some((effect) => effect.kind === "damage")).toBe(false);
expect(upgraded).toEqual([
expect.objectContaining({
kind: "damage", school: "fire", coefficient: 0,
basePoints: 140, pointsPerLevel: 40, sourceLevel: 1,
}),
expect.objectContaining({
kind: "dot", school: "fire", coefficient: 0,
basePoints: 23.4, pointsPerLevel: 10.4, sourceLevel: 1,
ticks: 24, intervalMs: 1_000,
}),
]);
});
it("extends Charged, creates conditional Elements stacks, and enhances Soul Stepping", () => {
const tags = new Set([
ROM_MAGE_WARLOCK_PASSIVE_TAGS.lightningDisplay,
ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementsOfPride,
ROM_MAGE_WARLOCK_PASSIVE_TAGS.soulSpeed,
]);
const plasma = abilityById("rom-mage-primary-52")!;
const charged = romMageWarlockCastEffects(plasma, plasma.effects, tags).find((effect) => (
effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("rom-charged")
));
expect(charged).toMatchObject({ aura: { durationMs: 18_000 } });
const lightningBuff = romMageWarlockCastEffects(plasma, plasma.effects, tags).find((effect) => (
effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "magical-critical-strike"
))
));
expect(lightningBuff).toMatchObject({
aura: {
durationMs: 18_000,
modifiers: expect.arrayContaining([
expect.objectContaining({ stat: "magical-critical-strike", value: 20 }),
expect.objectContaining({ kind: "damage", school: "nature", value: 0.01 }),
]),
},
});
const flame = abilityById("rom-mage-primary-1")!;
expect(romMageWarlockCastEffects(flame, flame.effects, tags)).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura", recipient: "caster",
aura: expect.objectContaining({ id: "rom-mage-warlock-elements-nature", maxStacks: 2 }),
}),
]));
const stepping = elite(40);
expect(romMageWarlockCastEffects(stepping, stepping.effects, new Set()).some((effect) => (
effect.kind === "apply-aura" && [24, 51].includes(effect.sourceAuraType)
))).toBe(false);
expect(romMageWarlockCastEffects(stepping, stepping.effects, tags).some((effect) => (
effect.kind === "apply-aura" && [24, 51].includes(effect.sourceAuraType)
))).toBe(true);
});
it("keeps Elemental Extraction's defense debuff on a critical-hit target", () => {
for (const id of ["rom-mage-primary-1", "rom-mage-primary-54"]) {
const attack = abilityById(id)!;
expect(romMageWarlockCriticalEffects(attack, new Set())).toEqual([]);
expect(romMageWarlockCriticalEffects(attack,
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))).toEqual([
expect.objectContaining({
kind: "apply-aura", recipient: "ability-target",
aura: expect.objectContaining({
disposition: "debuff", durationMs: 10_000,
modifiers: expect.arrayContaining([
expect.objectContaining({ stat: "armor", value: -0.3 }),
expect.objectContaining({ stat: "magical-defense", value: -0.3 }),
]),
}),
}),
]);
}
});
it("triggers Elemental Extraction's target debuff on a live critical Flame hit", () => {
initialize(79);
registerTarget("critical-extraction-dummy");
useCombatStore.setState((state) => ({
auras: applyCombatAura(state.auras, {
definition: {
id: "mage-warlock-test-guaranteed-crit",
name: "Guaranteed critical test trigger",
disposition: "buff",
durationMs: 10_000,
utilityTags: ["next-offensive-critical"],
},
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
now: 1_000,
}).auras,
}));
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 4_100);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "critical-extraction-dummy"
&& aura.definition.id === "rom-mage-warlock-elemental-extraction-debuff")).toBe(true);
});
it("uses hidden 499851's stronger normal Electric Explosion branch at level 70", () => {
const explosion = abilityById("rom-mage-primary-54")!;
const original = explosion.effects.filter((effect) => effect.kind === "damage");
const upgraded = romMageWarlockCastEffects(explosion, explosion.effects,
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))
.filter((effect) => effect.kind === "damage");
expect(upgraded).toHaveLength(2);
expect(upgraded[0]).toEqual(original[0]);
expect(upgraded[1]).toMatchObject({
school: "nature", coefficient: 0, basePoints: 65, pointsPerLevel: 15, sourceLevel: 1,
});
});
it("honors Fire Lightning Burst's source six-second reset guard", () => {
const burst = elite(50);
expect(romFireLightningBurstReset(burst, true, false)).toEqual({
reset: true, guardDurationMs: ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_MS,
});
expect(romFireLightningBurstReset(burst, true, true)).toEqual({ reset: false, guardDurationMs: 0 });
expect(romFireLightningBurstReset(burst, false, false)).toEqual({ reset: false, guardDurationMs: 0 });
expect(romFireLightningBurstReset(elite(45), true, false)).toEqual({ reset: false, guardDurationMs: 0 });
});
it("exposes Breath Erase's five-second threat reduction to the runtime", () => {
const breath = elite(30).effects.find((effect) => effect.kind === "apply-aura"
&& effect.aura.utilityTags?.includes(ROM_BREATH_ERASE_THREAT_TAG));
expect(breath).toMatchObject({ aura: { durationMs: 5_000 } });
expect(romMageWarlockThreatScale(new Set())).toBe(1);
expect(romMageWarlockThreatScale(new Set([ROM_BREATH_ERASE_THREAT_TAG]))).toBe(0.9);
});
it("reduces live spell threat while Breath Erase is active", () => {
const castFlame = (useBreath: boolean) => {
initialize(60);
registerTarget("breath-threat-dummy");
if (useBreath) {
expect(useCombatStore.getState().castAbility(elite(30).id, undefined, 1_000))
.toMatchObject({ ok: true });
}
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 2_600))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 5_700);
const target = useCombatStore.getState().mobs["breath-threat-dummy"]!;
return {
damage: target.maxHealth - target.health,
threat: target.threatByActor[PLAYER_AGGRO_ID] ?? 0,
};
};
const baseline = castFlame(false);
const reduced = castFlame(true);
expect(reduced.damage).toBeGreaterThan(0);
expect(reduced.damage).toBe(baseline.damage);
expect((reduced.threat - 1) / (baseline.threat - 1)).toBeCloseTo(0.9, 5);
});
it("applies Elemental Extraction's defense reduction to the target's live magic mitigation", () => {
const targetId = "extraction-defense-dummy";
const castFlame = (useDebuff: boolean) => {
initialize(60);
useCombatStore.getState().registerMob(targetId, {
name: targetId,
maxHealth: 100_000,
resistances: { fire: 1_000 },
});
setMobPosition(targetId, [3, 0, 0]);
useCombatStore.getState().selectMob(targetId);
if (useDebuff) {
const rider = romMageWarlockCriticalEffects(abilityById("rom-mage-primary-1")!,
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))[0];
expect(rider.kind).toBe("apply-aura");
if (rider.kind !== "apply-aura") throw new Error("Elemental Extraction debuff missing");
useCombatStore.setState((state) => ({
auras: applyCombatAura(state.auras, {
definition: rider.aura,
sourceId: PLAYER_AGGRO_ID,
targetId,
now: 1_000,
}).auras,
}));
}
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 4_100);
return useCombatStore.getState().mobs[targetId]!.maxHealth
- useCombatStore.getState().mobs[targetId]!.health;
};
const baselineDamage = castFlame(false);
const reducedDefenseDamage = castFlame(true);
expect(baselineDamage).toBeGreaterThan(0);
expect(reducedDefenseDamage).toBeGreaterThan(baselineDamage);
});
});
+13 -9
View File
@@ -9,7 +9,7 @@ import {
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
import { baseAttributesFor, baseResourcesFor, deriveCharacterStats, manaFromTotalIntellect, MANA_POOL_MULTIPLIER } from "./wotlkStats";
const RACE_MATRIX = {
"rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"],
@@ -40,23 +40,26 @@ describe("generated RuneWaker player catalog", () => {
expect(audit.hashes.interfaceFdb).toMatch(/^[a-f0-9]{64}$/);
});
it("resolves every ordered pair to all ten executable elite milestones", () => {
expect(ROM_ELITE_ABILITIES).toHaveLength(660);
it("resolves every ordered pair to all ten elite milestones and the source-backed Devotion Halo", () => {
expect(ROM_ELITE_ABILITIES).toHaveLength(661);
const pairGroups = new Map<string, typeof ROM_ELITE_ABILITIES[number][]>();
for (const ability of ROM_ELITE_ABILITIES) {
const key = `${ability.classId}:${ability.secondaryClassId}`;
pairGroups.set(key, [...(pairGroups.get(key) ?? []), ability]);
}
expect(pairGroups.size).toBe(66);
for (const elites of pairGroups.values()) {
expect(elites.map((ability) => ability.unlockLevel)).toEqual(ELITE_LEVELS);
for (const [pair, elites] of pairGroups) {
expect(elites.map((ability) => ability.unlockLevel)).toEqual(pair === "rom-priest:rom-knight"
? [...ELITE_LEVELS, 70] : ELITE_LEVELS);
expect(elites.every((ability) => ability.source === "runewaker" && ability.effects.length > 0)).toBe(true);
}
expect(ROM_ALL_ABILITIES).toHaveLength(929);
expect(ROM_ALL_ABILITIES.every((ability) => (
expect(ROM_ALL_ABILITIES).toHaveLength(930);
expect(ROM_ALL_ABILITIES.filter((ability) => ability.id !== "rom-priest-rom-knight-elite-70-devotion-halo").every((ability) => (
/^\/assets\/ui\/rom-spells\/\d+\.png$/.test(ability.icon)
&& ability.icon === generated.skills.find((skill) => skill.id === ability.id)?.icon
))).toBe(true);
expect(ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-rom-knight-elite-70-devotion-halo")?.icon)
.toBe(generated.skills.find((skill) => skill.sourceSkillId === 499957)?.icon);
expect(generated.skills.every((ability) => (
ability.iconResolution === "native-interface-fdb"
&& /^\/assets\/ui\/rom-spells\/\d+\.png$/.test(ability.icon)
@@ -117,9 +120,10 @@ describe("generated RuneWaker player catalog", () => {
}
});
it("gives level-one healers native mana pools and imports their real spell costs", () => {
it("uses hybrid Priest and native Druid mana curves while retaining source spell costs", () => {
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(
103 * MANA_POOL_MULTIPLIER,
(baseResourcesFor("rom-priest", 1).mana + manaFromTotalIntellect(baseAttributesFor("rom-priest", 1, "human").intellect))
* MANA_POOL_MULTIPLIER,
);
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
98 * MANA_POOL_MULTIPLIER,
+329
View File
@@ -0,0 +1,329 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
function passiveState(sourceId: number, name: string, tag: string): readonly AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-pair-passive-${sourceId}`,
name,
disposition: "buff",
durationMs: null,
utilityTags: [tag],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
}];
}
/** Source-specific pair mechanics that the generic component translator cannot infer. */
export function overrideRomPriestKnightEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
switch (row.sourceSkillId) {
case 492355: // Enhanced Grace of Life: Grace of Life also raises maximum MP.
return passiveState(492355, row.name, "rom-enhanced-grace-of-life");
case 492361: // Life Link: Urgent Heal also heals its caster.
return passiveState(492361, row.name, "rom-life-link");
case 499968: // Splendor Ripple: a triggered extra heal on source skill 499967.
return passiveState(499968, row.name, "rom-splendor-ripple");
case 499957: // Light Guidance: activate healing bonus only with source state 499966.
return passiveState(499957, row.name, "rom-light-guidance");
case 491610: // Enhanced Holy Strike: improves Holy Strike weapon damage.
return passiveState(491610, row.name, "rom-enhanced-holy-strike");
case 490066: // Weaken Weapon Strength: Heavenly Arrow weakens target physical damage.
return passiveState(490066, row.name, "rom-weaken-weapon-strength");
case 499926: // Evil Shackles: Holy Chains can root any creature with shorter duration.
return passiveState(499926, row.name, "rom-evil-shackles");
case 499923: // Blood Punishment: source skill 499924 grants conditional bonuses.
return passiveState(499923, row.name, "rom-blood-punishment");
case 491648: // Last Prayer affects only the next spell's cast time.
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-pair-last-prayer-next-spell",
name: row.name,
disposition: "buff",
durationMs: null,
utilityTags: ["rom-last-prayer"],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 51,
miscValue: -1_000,
triggerSpellId: 0,
}];
case 493271: // Light Fairy has source lifetime -1; keep it until dismissal or session end.
return translated.map((effect) => effect.kind === "summon"
? { ...effect, durationMs: 86_400_000 }
: effect);
case 492639: // Calm Heart, component 502908: 10 seconds of Fear immunity.
return [{
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-pair-calm-heart-fear-immunity",
name: row.name,
disposition: "buff",
durationMs: 10_000,
controlImmunities: ["fear"],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
}];
case 493046: // Sacred Protection, component 503290: +100 shield block for 20 seconds.
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-pair-sacred-protection-block",
name: row.name,
disposition: "buff",
durationMs: 20_000,
modifiers: [{ kind: "stat", stat: "block-chance", operation: "flat", value: 100 }],
utilityTags: ["rom-sacred-protection"],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 172,
miscValue: 100,
triggerSpellId: 0,
}];
case 493273: // Disregard Danger: redirect 40% of one hit; caster gains 20 Parry.
return [
{
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-pair-disregard-danger-redirect",
name: row.name,
disposition: "buff",
durationMs: 10_000,
redirectDamagePercent: 0.4,
utilityTags: ["rom-disregard-danger-one-hit"],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 143,
miscValue: 40,
triggerSpellId: 491614,
},
{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-pair-disregard-danger-parry",
name: `${row.name}: Parry`,
disposition: "buff",
durationMs: 10_000,
modifiers: [{ kind: "stat", stat: "parry", operation: "flat", value: 20 }],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 22,
miscValue: 20,
triggerSpellId: 0,
},
];
case 491613: // Holy Chains only roots; source flag 67 is not three controls.
return translated.filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root");
default:
return null;
}
}
function hasTag(tags: readonly string[] | ReadonlySet<string>, tag: string): boolean {
return Array.isArray(tags) ? tags.includes(tag) : (tags as ReadonlySet<string>).has(tag);
}
/** Apply passive pair upgrades when their parent skill is actually cast. */
export function romPriestKnightCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: readonly string[] | ReadonlySet<string>,
casterMaxHealth: number,
targetTags: readonly string[] | ReadonlySet<string> = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects;
switch (ability.dbcSpellId) {
case 490290: // Enhanced Grace of Life mirrors source Grace's +50 and +1% HP as MP.
if (!hasTag(passiveTags, "rom-enhanced-grace-of-life")) return effects;
return [...effects, {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-pair-enhanced-grace-mana",
name: "Enhanced Grace of Life: Mana",
disposition: "buff",
durationMs: 1_800_000,
modifiers: [
{ kind: "resource", resource: "mana", property: "maximum", operation: "flat", value: 50 },
{ kind: "resource", resource: "mana", property: "maximum", operation: "percent", value: 0.01 },
],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 8,
miscValue: 50,
triggerSpellId: 492355,
}];
case 491147: // Life Link duplicates Urgent Heal's effective payload on the caster.
if (!hasTag(passiveTags, "rom-life-link")) return effects;
return [...effects, ...effects.filter((effect) => effect.kind === "heal")
.map((effect) => ({ ...effect, recipient: "caster" as const }))];
case 490148: // Enhanced Holy Strike adds a conservative hybrid weapon bonus.
if (!hasTag(passiveTags, "rom-enhanced-holy-strike")) return effects;
return effects.map((effect) => effect.kind === "damage"
? { ...effect, coefficient: effect.coefficient * 1.1 }
: effect);
case 491354: // Weaken Weapon Strength debuffs targets hit by Heavenly Arrow.
if (!hasTag(passiveTags, "rom-weaken-weapon-strength")) return effects;
return [...effects, {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-pair-heavenly-arrow-weaken",
name: "Weaken Weapon Strength",
disposition: "debuff",
durationMs: 10_000,
modifiers: [{ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: -0.1 }],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: -10,
triggerSpellId: 490066,
}];
case 491613: { // Evil Shackles halves duration on non-undead/demon targets.
if (!hasTag(passiveTags, "rom-evil-shackles") || hasTag(targetTags, "rom-target-undead-demon")) return effects;
return effects.map((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"
? { ...effect, aura: { ...effect.aura, durationMs: Math.max(1_000, Math.round((effect.aura.durationMs ?? 10_000) / 2)) } }
: effect);
}
case 490273: // Source 499967 keeps Wave Armor's shield and adds heal component 623009.
if (!hasTag(passiveTags, "rom-splendor-ripple")) return effects;
return [...effects, {
kind: "heal",
recipient: "ability-target",
coefficient: 0,
basePoints: 106.25,
pointsPerLevel: 21.25,
sourceLevel: 1,
}];
case 499966: // Devotion Halo refreshes Light Guidance while the halo is active.
if (!hasTag(passiveTags, "rom-light-guidance")) return effects;
return [...effects, {
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-pair-light-guidance-healing",
name: "Light Guidance",
disposition: "buff",
durationMs: 900_000,
modifiers: [{ kind: "healing", direction: "done", operation: "percent", value: 0.05 }],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 149,
miscValue: 5,
triggerSpellId: 623015,
}];
case 490434: // Source 499924 adds component 623167 to the base Blood Arrow.
if (!hasTag(passiveTags, "rom-blood-punishment")) return effects;
return [...effects, {
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-pair-blood-arrow-healing-received",
name: "Blood Punishment",
disposition: "buff",
durationMs: 30_000,
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: 0.5 }],
utilityTags: ["rom-blood-arrow-upgrade"],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 144,
miscValue: 50,
triggerSpellId: 623167,
}];
default:
return effects;
}
}
/** Source skill 499966 is granted by the level-70 Priest/Knight elite pair. */
export function romPriestKnightAdditionalAbilities(
catalog: readonly AbilityDefinition[],
): readonly AbilityDefinition[] {
const lightGuidance = catalog.find((ability) => ability.dbcSpellId === 499957
&& ability.classId === "rom-priest" && ability.secondaryClassId === "rom-knight");
if (!lightGuidance) return [];
const effects: readonly AbilityEffect[] = [{
kind: "apply-aura",
recipient: "caster",
scope: "party",
aura: {
id: "rom-pair-devotion-halo",
name: "Devotion Halo",
disposition: "buff",
durationMs: 900_000,
modifiers: [{ kind: "stat", stat: "wisdom", operation: "percent", value: 0.05 }],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 164,
miscValue: 5,
triggerSpellId: 623016,
}, {
kind: "apply-aura",
recipient: "caster",
scope: "party",
excludeCaster: true,
aura: {
id: "rom-pair-devotion-halo-mana",
name: "Devotion Halo Mana",
disposition: "buff",
durationMs: 900_000,
modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: -0.06 }],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: 164,
miscValue: -6,
triggerSpellId: 623016,
}];
return [{
id: "rom-priest-rom-knight-elite-70-devotion-halo",
classId: "rom-priest",
secondaryClassId: "rom-knight",
dbcSpellId: 499966,
name: "Devotion Halo",
unlockLevel: 70,
icon: lightGuidance.icon,
sigil: "DH",
description: "Raises party Wisdom by 5% and lowers party mana costs by 6% for 15 minutes.",
target: "self",
range: { min: 0, max: 5 },
radius: 20,
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 1_500,
cost: { resource: "mana", amount: 0 },
costs: [],
effects,
passive: false,
source: "runewaker",
sourceUnlockLevel: 70,
romSkillGroup: "elite",
}];
}
@@ -0,0 +1,503 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, isAbilityUnlocked, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { commitPartyAbilityUse } from "./partyAbilityAi";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romPriestKnightAdditionalAbilities, romPriestKnightCastEffects } from "./romPriestKnightOverrides";
const PAIRED_SKILLS = [
["rom-priest", "rom-knight", 15, 492355, "Enhanced Grace of Life", "passive"],
["rom-priest", "rom-knight", 20, 492361, "Life Link", "passive"],
["rom-priest", "rom-knight", 25, 491648, "Last Prayer", "elite"],
["rom-priest", "rom-knight", 30, 491366, "Divine Incarnation", "elite"],
["rom-priest", "rom-knight", 35, 492639, "Calm Heart", "elite"],
["rom-priest", "rom-knight", 40, 492934, "Sacrificial Cure", "elite"],
["rom-priest", "rom-knight", 45, 493044, "Holy Power", "elite"],
["rom-priest", "rom-knight", 50, 493271, "Light Fairy", "elite"],
["rom-priest", "rom-knight", 60, 499968, "Splendor Ripple", "passive"],
["rom-priest", "rom-knight", 70, 499957, "Light Guidance", "passive"],
["rom-knight", "rom-scout", 15, 491616, "Arrow of Vengeance", "elite"],
["rom-knight", "rom-scout", 20, 491610, "Enhanced Holy Strike", "passive"],
["rom-knight", "rom-scout", 25, 491613, "Holy Chains", "elite"],
["rom-knight", "rom-scout", 30, 491354, "Heavenly Arrow", "elite"],
["rom-knight", "rom-scout", 35, 490066, "Weaken Weapon Strength", "passive"],
["rom-knight", "rom-scout", 40, 492939, "Sacred Resistance", "elite"],
["rom-knight", "rom-scout", 45, 493046, "Sacred Protection", "elite"],
["rom-knight", "rom-scout", 50, 493273, "Disregard Danger", "elite"],
["rom-knight", "rom-scout", 60, 499926, "Evil Shackles", "passive"],
["rom-knight", "rom-scout", 70, 499923, "Blood Punishment", "passive"],
] as const;
function pairedSkill(sourceId: number): AbilityDefinition {
const found = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === sourceId);
if (!found) throw new Error(`Missing paired RoM source skill ${sourceId}`);
return found;
}
function executable(effect: AbilityEffect): boolean {
if (effect.kind === "scripted" || effect.kind === "unsupported") return false;
if (effect.kind === "apply-aura") return Boolean(
effect.control || effect.aura.modifiers?.length || effect.aura.absorbs?.length
|| effect.aura.procs?.length || effect.aura.utilityTags?.length
|| effect.aura.controlImmunities?.length || effect.aura.redirectDamagePercent,
);
return true;
}
describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("has exactly the twenty RuneWaker paired skills, including passive upgrades", () => {
for (const [primary, secondary] of [["rom-priest", "rom-knight"], ["rom-knight", "rom-scout"]] as const) {
const pair = ROM_ELITE_ABILITIES.filter((ability) => ability.classId === primary
&& ability.secondaryClassId === secondary);
expect(pair, `${primary}/${secondary}`).toHaveLength(primary === "rom-priest" ? 11 : 10);
const kit = abilitiesForCharacter(primary, secondary);
for (const skill of pair) expect(kit).toContain(skill);
}
});
it.each(PAIRED_SKILLS)("maps %s/%s level %i source %i %s (%s) with the right gate", (
primary, secondary, unlockLevel, sourceId, name, group,
) => {
const skill = pairedSkill(sourceId);
expect(skill).toMatchObject({
classId: primary, secondaryClassId: secondary, unlockLevel,
dbcSpellId: sourceId, name, romSkillGroup: group, passive: group === "passive",
});
for (const level of [10, 30, 60, 79]) {
expect(isAbilityUnlocked(skill, level), `${name} at level ${level}`)
.toBe(level >= unlockLevel);
}
});
it.each(PAIRED_SKILLS)("gives %s/%s level %i source %i %s (%s) an executable mechanic", (
_primary, _secondary, _unlockLevel, sourceId, name,
) => {
const skill = pairedSkill(sourceId);
expect(skill.effects.some(executable), `${name} (${sourceId})`).toBe(true);
});
it("retains direct and area Knight/Scout taunts", () => {
expect(pairedSkill(491616).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "taunt" }),
]));
expect(pairedSkill(491354).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "taunt", maxTargets: expect.any(Number) }),
]));
});
it("retains Priest/Knight healing costs and offensive/defensive effects", () => {
const sacrifice = pairedSkill(492934);
expect(sacrifice.costs).toEqual(expect.arrayContaining([
expect.objectContaining({ resource: "health", amount: 70, percentage: true }),
]));
expect(sacrifice.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "heal", percentMaxHealth: expect.any(Number) }),
]));
expect(pairedSkill(491366).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({
modifiers: expect.arrayContaining([expect.objectContaining({ kind: "healing", direction: "done", value: 0.15 })]),
}) }),
]));
expect(pairedSkill(493271).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "summon", creatureId: 102108 }),
]));
});
it("retains Knight/Scout damage and Dexterity buff on Sacred Resistance", () => {
const skill = pairedSkill(492939);
expect(skill.effects.some((effect) => effect.kind === "damage" && effect.coefficient > 0)).toBe(true);
expect(skill.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "dexterity" && modifier.value > 0))).toBe(true);
});
it("makes Calm Heart resist Fear rather than applying an empty buff", () => {
expect(pairedSkill(492639).effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.controlImmunities?.includes("fear"))).toBe(true);
});
it("maps Sacred Protection to a real shield block bonus", () => {
expect(pairedSkill(493046).effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "block-chance" && modifier.value > 0))).toBe(true);
});
it("redirects damage with Disregard Danger", () => {
expect(pairedSkill(493273).effects.some((effect) => effect.kind === "apply-aura"
&& (effect.aura.redirectDamagePercent ?? 0) > 0)).toBe(true);
});
it("applies only the advertised root from Holy Chains", () => {
const controls = pairedSkill(491613).effects.flatMap((effect) => effect.kind === "apply-aura"
&& effect.control ? [effect.control.kind] : []);
expect(controls).toEqual(["root"]);
});
it("increases healing received through Blood Punishment", () => {
expect(pairedSkill(499923).effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.utilityTags?.includes("rom-blood-punishment"))).toBe(true);
expect(pairedSkill(499923).effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"))).toBe(false);
});
it("keeps passive pair upgrades conditional on their parent casts", () => {
for (const [sourceId, tag] of [
[492355, "rom-enhanced-grace-of-life"],
[492361, "rom-life-link"],
[499968, "rom-splendor-ripple"],
[499957, "rom-light-guidance"],
[491610, "rom-enhanced-holy-strike"],
[490066, "rom-weaken-weapon-strength"],
[499926, "rom-evil-shackles"],
[499923, "rom-blood-punishment"],
] as const) {
expect(pairedSkill(sourceId).effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.utilityTags?.includes(tag)), `${sourceId} ${tag}`).toBe(true);
}
expect(pairedSkill(491648).effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.utilityTags?.includes("rom-last-prayer"))).toBe(true);
});
it("applies passive upgrades to Grace, Urgent Heal, Holy Strike, and Heavenly Arrow casts", () => {
const ability = (sourceId: number) => ROM_ALL_ABILITIES.find((row) => row.dbcSpellId === sourceId)!;
const grace = ability(490290);
const enhancedGrace = romPriestKnightCastEffects(grace, grace.effects, ["rom-enhanced-grace-of-life"], 1_000);
expect(enhancedGrace.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "resource"
&& modifier.resource === "mana" && modifier.property === "maximum"))).toBe(true);
const urgent = ability(491147);
const linked = romPriestKnightCastEffects(urgent, urgent.effects, ["rom-life-link"], 1_000);
expect(linked.filter((effect) => effect.kind === "heal" && effect.recipient === "caster")).toHaveLength(1);
const holyStrike = ability(490148);
const enhancedStrike = romPriestKnightCastEffects(holyStrike, holyStrike.effects, ["rom-enhanced-holy-strike"], 1_000);
const originalDamage = holyStrike.effects.find((effect) => effect.kind === "damage");
const enhancedDamage = enhancedStrike.find((effect) => effect.kind === "damage");
expect(enhancedDamage?.kind === "damage" && originalDamage?.kind === "damage"
&& enhancedDamage.coefficient > originalDamage.coefficient).toBe(true);
const arrow = ability(491354);
const weakened = romPriestKnightCastEffects(arrow, arrow.effects, ["rom-weaken-weapon-strength"], 1_000);
expect(weakened.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "dealt" && modifier.school === "physical" && modifier.value < 0))).toBe(true);
});
it("halves Holy Chains duration against ordinary targets after Evil Shackles unlocks", () => {
const chains = pairedSkill(491613);
const rootDuration = (effects: readonly AbilityEffect[]) => effects
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
.find((effect) => effect.control?.kind === "root")?.aura.durationMs;
const ordinary = romPriestKnightCastEffects(chains, chains.effects, ["rom-evil-shackles"], 1_000);
const undead = romPriestKnightCastEffects(chains, chains.effects,
["rom-evil-shackles"], 1_000, ["rom-target-undead-demon"]);
expect(rootDuration(ordinary)).toBe(rootDuration(chains.effects)! / 2);
expect(rootDuration(undead)).toBe(rootDuration(chains.effects));
});
it("adds source 499967's extra Wave Armor heal only with Splendor Ripple", () => {
const waveArmor = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490273)!;
const ordinary = romPriestKnightCastEffects(waveArmor, waveArmor.effects, [], 1_000);
const upgraded = romPriestKnightCastEffects(waveArmor, waveArmor.effects, ["rom-splendor-ripple"], 1_000);
expect(ordinary.filter((effect) => effect.kind === "heal")).toHaveLength(0);
expect(upgraded).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "heal", basePoints: 106.25, pointsPerLevel: 21.25 }),
]));
});
it("adds source 499924's Blood Arrow healing bonus only with Blood Punishment", () => {
const bloodArrow = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490434)!;
const ordinary = romPriestKnightCastEffects(bloodArrow, bloodArrow.effects, [], 1_000);
const upgraded = romPriestKnightCastEffects(bloodArrow, bloodArrow.effects, ["rom-blood-punishment"], 1_000);
expect(ordinary.some((effect) => effect.kind === "apply-aura"
&& effect.aura.utilityTags?.includes("rom-blood-arrow-upgrade"))).toBe(false);
expect(upgraded.some((effect) => effect.kind === "apply-aura"
&& effect.aura.durationMs === 30_000
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"
&& modifier.direction === "taken" && modifier.value === 0.5))).toBe(true);
});
it("builds source 499966 Devotion Halo and grants conditional Light Guidance healing", () => {
const [halo] = romPriestKnightAdditionalAbilities(ROM_ALL_ABILITIES);
expect(halo).toMatchObject({
id: "rom-priest-rom-knight-elite-70-devotion-halo",
dbcSpellId: 499966,
classId: "rom-priest",
secondaryClassId: "rom-knight",
unlockLevel: 70,
radius: 20,
passive: false,
});
expect(abilitiesForCharacter("rom-priest", "rom-knight").some((ability) => ability.dbcSpellId === 499966)).toBe(true);
expect(halo!.effects.some((effect) => effect.kind === "apply-aura"
&& effect.scope === "party" && effect.aura.durationMs === 900_000
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "wisdom" && modifier.value === 0.05))).toBe(true);
const ordinary = romPriestKnightCastEffects(halo!, halo!.effects, [], 1_000);
const guided = romPriestKnightCastEffects(halo!, halo!.effects, ["rom-light-guidance"], 1_000);
expect(ordinary.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"))).toBe(false);
expect(guided.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"
&& modifier.direction === "done" && modifier.value === 0.05))).toBe(true);
});
it("heals multiple nearby allies with Priest Group Heal", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
});
usePartyStore.getState().initializeParty("priest-group-heal", 30, useCombatStore.getState().health, "healer", 3, "rom");
const members = usePartyStore.getState().members.slice(0, 2);
expect(members).toHaveLength(2);
members.forEach((member, index) => {
usePartyStore.getState().damageMember(member.id, Math.max(100, Math.floor(member.maxHealth * 0.4)));
registerPartyRuntimePosition(member.id, [3 + index * 2, 0, 0]);
});
const injured = members.map((member) => usePartyStore.getState().members.find((entry) => entry.id === member.id)!.health);
expect(useCombatStore.getState().castAbility("rom-priest-primary-8", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 4_100);
members.forEach((member, index) => {
const healed = usePartyStore.getState().members.find((entry) => entry.id === member.id)!.health;
expect(healed, `Group Heal recipient ${index + 1}`).toBeGreaterThan(injured[index]!);
});
});
it("preserves Soul Source's 90-source-unit all-teammate heal", () => {
const soulSource = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490288)!;
expect(soulSource.radius).toBe(9);
expect(soulSource.effects.some((effect) => effect.kind === "heal"
&& effect.scope === "party")).toBe(true);
});
it("gives Holy Shield physical immunity to the party", () => {
const holyShield = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490495)!;
expect(holyShield.effects.some((effect) => effect.kind === "apply-aura"
&& effect.scope === "party" && effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "taken" && modifier.school === "physical" && modifier.value <= -1))).toBe(true);
});
it("taunts nearby enemies with Knight/Scout Heavenly Arrow", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30, role: "tank",
});
for (const [id, x] of [["arrow-a", 3], ["arrow-b", 5]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000 });
setMobPosition(id, [x, 0, 0]);
}
useCombatStore.getState().selectMob("arrow-a");
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-30", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["arrow-a"]!.forcedTarget?.actorId).toBe(PLAYER_AGGRO_ID);
expect(useCombatStore.getState().mobs["arrow-b"]!.forcedTarget?.actorId).toBe(PLAYER_AGGRO_ID);
});
it.each([
[30, "living", false, 0],
[30, "undead", true, 10_000],
[60, "living", true, 5_000],
[60, "demon", true, 10_000],
] as const)("uses Holy Chains against %s-level %s for the correct duration", (
level, creatureFamily, allowed, durationMs,
) => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level, role: "tank",
});
useCombatStore.getState().registerMob("chains-target", {
name: "Chains Target", maxHealth: 10_000, creatureFamily,
});
setMobPosition("chains-target", [3, 0, 0]);
useCombatStore.getState().selectMob("chains-target");
const cast = useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-25", undefined, 1_000);
if (!allowed) {
expect(cast).toMatchObject({ ok: false, reason: "condition-not-met" });
return;
}
expect(cast).toMatchObject({ ok: true });
const root = useCombatStore.getState().mobs["chains-target"]!.statuses.find((status) => status.kind === "root");
expect(root?.endsAt).toBe(1_000 + durationMs);
});
it("damages multiple nearby enemies with Knight Whirlwind Shield", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30, role: "tank",
});
for (const [id, x] of [["shield-a", 3], ["shield-b", 4]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000 });
setMobPosition(id, [x, 0, 0]);
}
useCombatStore.getState().selectMob("shield-a");
expect(useCombatStore.getState().castAbility("rom-knight-primary-3", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["shield-a"]!.health).toBeLessThan(10_000);
expect(useCombatStore.getState().mobs["shield-b"]!.health).toBeLessThan(10_000);
});
it("pays Sacrificial Cure's HP cost while healing an ally", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
});
usePartyStore.getState().initializeParty("priest-sacrifice", 60, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.6));
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const casterHealth = useCombatStore.getState().health;
const allyHealth = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-40", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().health).toBeLessThan(casterHealth);
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth);
});
it("Life Link lets Urgent Heal also heal the Priest", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
});
usePartyStore.getState().initializeParty("priest-life-link", 30, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
useCombatStore.getState().damagePlayer(Math.floor(useCombatStore.getState().maxHealth * 0.5));
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.5));
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const casterHealth = useCombatStore.getState().health;
const allyHealth = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility("rom-priest-general-0", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 2_100);
expect(useCombatStore.getState().health).toBeGreaterThan(casterHealth);
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth);
});
it("Last Prayer shortens only the next cast to one second", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
});
usePartyStore.getState().initializeParty("priest-last-prayer", 30, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.5));
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-25", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 2_600))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeLessThanOrEqual(1_000);
useCombatStore.getState().tick(0.1, 3_700);
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 4_300))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(1_000);
});
it("Splendor Ripple adds healing when the Priest casts Wave Armor at level 60", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
});
usePartyStore.getState().initializeParty("priest-ripple", 60, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.5));
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const injured = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility("rom-priest-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injured);
});
it("casts Devotion Halo on the party and gains Light Guidance at level 70", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 70, role: "healer",
});
usePartyStore.getState().initializeParty("priest-halo", 70, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [3, 0, 0]);
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-70-devotion-halo", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& aura.definition.id === "rom-pair-devotion-halo")).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& aura.definition.id === "rom-pair-devotion-halo-mana")).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id === "rom-pair-devotion-halo-mana")).toBe(false);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id === "rom-pair-light-guidance-healing")).toBe(true);
});
it("reduces a companion's actual Mana spending while Devotion Halo is active", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 70, role: "healer",
});
usePartyStore.getState().initializeParty("priest-halo-cost", 70, useCombatStore.getState().health, "healer", 2, "rom");
const partyMember = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(partyMember.id, [3, 0, 0]);
const member = {
...partyMember,
classId: "rom-priest" as const,
secondaryClassId: "rom-knight" as const,
resourceType: "mana" as const,
resource: 100_000,
maxResource: 100_000,
resourcePools: { mana: 100_000 },
cooldowns: {},
globalCooldownEndsAt: 0,
};
const spell = abilitiesForCharacter("rom-priest", "rom-knight")
.find((ability) => !ability.passive && ability.unlockLevel <= 70
&& ability.cost.resource === "mana" && ability.cost.amount >= 10)!;
const ordinary = commitPartyAbilityUse(member, spell, 2_000)!;
expect(ordinary).not.toBeNull();
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-70-devotion-halo", undefined, 1_000))
.toMatchObject({ ok: true });
const discounted = commitPartyAbilityUse(member, spell, 2_000)!;
expect(discounted).not.toBeNull();
expect(discounted.resourcePools.mana).toBeGreaterThan(ordinary.resourcePools.mana!);
});
it("adds Blood Punishment's healing bonus during Knight/Scout Blood Arrow", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
});
expect(useCombatStore.getState().castAbility("rom-scout-general-4", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id === "rom-pair-blood-arrow-healing-received"
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "healing"
&& modifier.direction === "taken" && modifier.value === 0.5))).toBe(true);
});
it("summons the Priest/Knight Light Fairy companion", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
});
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-50", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 7_100);
expect(useCombatStore.getState().summons.some((summon) => summon.creatureId === 102108)).toBe(true);
useCombatStore.getState().tick(1, 8_100);
expect(useCombatStore.getState().summons.some((summon) => summon.creatureId === 102108)).toBe(true);
});
it("places Disregard Danger's redirect on a teammate and Parry on the Knight", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 60, role: "tank",
});
usePartyStore.getState().initializeParty("knight-redirect", 60, useCombatStore.getState().health, "tank", 2, "rom");
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-50", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& (aura.definition.redirectDamagePercent ?? 0) > 0)).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "parry"))).toBe(true);
});
});
+369
View File
@@ -0,0 +1,369 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition, AuraModifier, AuraProcDefinition } from "./combatAuras";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] {
return effects.filter((effect) => effect.kind === "damage").slice(0, 1);
}
function taggedPassive(
sourceId: number,
name: string,
tag: string,
proc?: AuraProcDefinition,
): AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-pair-passive-${sourceId}`,
name,
disposition: "buff",
durationMs: null,
utilityTags: [tag],
...(proc ? { procs: [proc] } : {}),
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
}];
}
function customProc(
sourceId: number,
trigger: "hit" | "crit",
actionId: string,
options: { readonly chance?: number; readonly internalCooldownMs?: number } = {},
): AuraProcDefinition {
return {
id: `rom-pair-proc-${sourceId}`,
triggers: trigger === "hit" ? ["hit", "crit"] : trigger,
chance: options.chance ?? 1,
...(options.internalCooldownMs ? { internalCooldownMs: options.internalCooldownMs } : {}),
action: { kind: "custom", id: actionId },
};
}
function timedCasterAura(
sourceId: number,
name: string,
durationMs: number,
modifiers: readonly AuraModifier[],
): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-pair-buff-${sourceId}`,
name,
disposition: "buff",
durationMs,
modifiers,
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
};
}
/**
* Source-specific translations for the accepted Scout/Rogue and Rogue/Warrior
* pairings. Passive tags and custom proc IDs are consumed by combat-store
* transitions; they intentionally do not apply unrelated generic stat buffs.
*/
export function overrideRomScoutRogueEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
switch (row.sourceSkillId) {
case 490400: // Piercing Arrow: one projectile, then a line hit behind its target.
return firstDamage(translated);
case 490424: { // Combo Shot: three impacts of the same source shot.
const hit = firstDamage(translated)[0];
return hit ? [hit, hit, hit] : translated;
}
case 490457: { // Reflected Shot: two jumps, each retaining half the prior hit.
const hit = firstDamage(translated)[0];
return hit?.kind === "damage" ? [{ ...hit, falloffPerJump: 0.5 }] : translated;
}
case 490354: // Vanish is an aggro/stealth transition, not a self-heal.
return translated.filter((effect) => effect.kind !== "heal").map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] } }
: effect);
case 491493: { // Weak Spot's buff is component 501895, not its permanent hit modifier.
const source = row.source?.components?.find((component) => component.id === 501895);
const critical = source?.abilityModifiers?.find((modifier) => modifier.type === 18)?.value ?? 20;
const durationMs = Math.max(1_000, Math.round((source?.effectTime ?? 15) * 1_000));
return [
...firstDamage(translated),
timedCasterAura(491493, "Weak Spot critical strike", durationMs, [
{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: critical },
]),
];
}
case 492342:
return taggedPassive(492342, "Lasso Master", "rom-lasso-master");
case 492916:
return taggedPassive(492916, "Exploiting Shot", "rom-exploiting-shot",
customProc(492916, "crit", "rom-exploiting-shot", { internalCooldownMs: 20_000 }));
case 493007:
return taggedPassive(493007, "Deadly Counterattack", "rom-deadly-counterattack",
customProc(493007, "hit", "rom-deadly-counterattack", { internalCooldownMs: 20_000 }));
case 493253:
return taggedPassive(493253, "Poisonous Spit", "rom-poisonous-spit",
customProc(493253, "hit", "rom-poisonous-spit", { internalCooldownMs: 20_000 }));
case 499534:
return taggedPassive(499534, "Master Reflected Shot", "rom-master-reflected-shot");
case 499536:
return taggedPassive(499536, "Meridian Attack", "rom-meridian-attack");
case 491526:
return taggedPassive(491526, "Throwing Mastery", "rom-throwing-mastery");
case 491527: // The bonus applies to one-hand axes only, never all melee damage.
return taggedPassive(491527, "One-Hand Axe Mastery", "rom-one-hand-axe-mastery");
case 492349:
return taggedPassive(492349, "Whirlwind Mastery", "rom-whirlwind-mastery");
case 491339: // A negative RuneWaker value means reduced received healing.
return translated.map((effect) => effect.kind === "apply-aura"
? {
...effect,
aura: {
...effect.aura,
modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "healing"
&& modifier.direction === "taken"
? { ...modifier, value: -Math.abs(modifier.value) }
: modifier),
},
}
: effect);
case 492625: // Source attack component 620287 must not make its victim immune to physical damage.
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot");
case 492920: { // Poison Spray spreads Infection's ten-second poison to nearby enemies.
const hit = firstDamage(translated)[0];
const poison: AbilityEffect = {
kind: "dot",
coefficient: 0,
basePoints: 12,
pointsPerLevel: 4.2,
sourceLevel: 1,
ticks: 5,
intervalMs: 2_000,
tag: "rom-poisonous-infection",
};
return hit ? [hit, poison] : [poison];
}
case 493017: // Decay's empty indefinite state is not its timed critical buff.
return translated.filter((effect) => effect.kind !== "apply-aura"
|| effect.aura.modifiers?.length || effect.aura.procs?.length || effect.aura.utilityTags?.length);
case 493257:
return taggedPassive(493257, "Reclaim Energy", "rom-reclaim-energy",
customProc(493257, "hit", "rom-reclaim-energy", { chance: 0.25 }));
case 499476:
return taggedPassive(499476, "Toxic Splash", "rom-toxic-splash",
// 493296 replaces Infection and can generate 20 rage on a successful hit.
customProc(499476, "hit", "rom-infection-rage", { chance: 0.25 }));
case 499480:
return taggedPassive(499480, "Poison Battle Technique", "rom-poison-battle-technique");
default:
return null;
}
}
/** Select source-conditional pair effects after the target's existing states are known. */
export function romScoutRogueCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: readonly string[],
targetTags: readonly string[],
): readonly AbilityEffect[] {
const passives = new Set(passiveTags);
const target = new Set(targetTags);
if (ability.dbcSpellId === 490457) {
// Replacement 499535 / component 623051 raises ranged DPS from 0.75 to 0.85.
const masteryFactor = passives.has("rom-master-reflected-shot") ? 0.85 / 0.75 : 1;
return effects.map((effect) => effect.kind === "damage" ? {
...effect,
coefficient: effect.coefficient * masteryFactor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * masteryFactor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * masteryFactor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * masteryFactor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * masteryFactor }),
...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * masteryFactor }),
falloffPerJump: passives.has("rom-poisonous-spit") ? 1 : effect.falloffPerJump,
} : effect);
}
if (ability.dbcSpellId === 490451 && passives.has("rom-meridian-attack")) {
// 499537/623061 replaces Wrist Attack: -30 physical and magical damage for 10s.
return effects.map((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster"
? {
...effect,
aura: {
...effect.aura,
durationMs: 10_000,
modifiers: [
{ kind: "damage" as const, direction: "dealt" as const, school: "physical" as const, operation: "percent" as const, value: -0.30 },
{ kind: "damage" as const, direction: "dealt" as const, attackKind: "spell" as const, operation: "percent" as const, value: -0.30 },
],
},
} : effect);
}
if (ability.dbcSpellId === 492920 && passives.has("rom-toxic-splash")) {
// 499477 replaces Poison Spray. 493296 is its additional infection hit;
// 622992 is the six-second poison it spreads to nearby targets.
return [
...effects.filter((effect) => effect.kind !== "dot"),
// 620287: 0.85 main-hand DPS + 0.20 Dexterity, then +7.5% skill-level DPS.
{ kind: "damage", coefficient: 1.11375, coefficientPerLevel: 0.06375, sourceLevel: 1 },
{ kind: "dot", coefficient: 0, basePoints: 16.2, pointsPerLevel: 4.2,
sourceLevel: 1, ticks: 3, intervalMs: 2_000, tag: "rom-622992" },
];
}
if (ability.dbcSpellId === 490133 && passives.has("rom-poison-battle-technique")) {
// 499481/620286 raises Whirlwind's main-hand DPS coefficient 0.70 -> 0.75.
return effects.map((effect) => effect.kind === "damage" ? {
...effect,
coefficient: effect.coefficient * 0.75 / 0.70,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 0.75 / 0.70 }),
} : effect);
}
if (ability.dbcSpellId === 490454 && passives.has("rom-lasso-master")) {
return [...effects, {
kind: "status",
status: "disarm",
durationMs: 10_000,
maxTargets: 1,
breakOnDamage: true,
}];
}
if (ability.dbcSpellId === 490309 && passives.has("rom-throwing-mastery")) {
return [...effects, { kind: "status", status: "slow", durationMs: 5_000, magnitude: 0.4 }];
}
if (ability.dbcSpellId === 492621) {
const hasSourcePoison = target.has("rom-deadly-poison") || target.has("rom-501690");
let seenHits = 0;
return effects.filter((effect) => {
if (effect.kind !== "damage") return true;
seenHits += 1;
return seenHits === 1 || (seenHits === 2 && hasSourcePoison);
});
}
return effects;
}
/** 499481/622993 adds a hit only to targets already carrying 622992's poison. */
export function romScoutRogueWhirlwindBonusEffect(
ability: AbilityDefinition,
passiveTags: readonly string[],
targetTags: readonly string[],
): Extract<AbilityEffect, { kind: "damage" }> | null {
if (ability.dbcSpellId !== 490133 || !passiveTags.includes("rom-poison-battle-technique")
|| !targetTags.includes("rom-622992")) return null;
return { kind: "damage", coefficient: 0.50, coefficientPerLevel: 0.03, sourceLevel: 1 };
}
export type RomScoutRogueProcActionSpec =
| {
readonly kind: "aura";
readonly recipient: "caster" | "target";
readonly aura: AuraDefinition;
readonly provenance: "source" | "hybrid-estimate";
}
| {
readonly kind: "dot";
readonly recipient: "target";
readonly effect: Extract<AbilityEffect, { kind: "dot" }>;
readonly provenance: "source" | "hybrid-estimate";
}
| {
readonly kind: "resource";
readonly recipient: "caster";
readonly resource: "energy" | "rage";
readonly amount: number;
readonly provenance: "source" | "hybrid-estimate";
}
| {
readonly kind: "damage";
readonly recipient: "target";
readonly effect: Extract<AbilityEffect, { kind: "damage" }>;
readonly provenance: "source" | "hybrid-estimate";
};
/**
* A pure result for combat-store proc execution. `triggeringSourceSkillId` is
* the RuneWaker skill ID of the attack that generated the hit/crit event.
* The level-60/70 replacement skills 499477 and 499481 are represented by
* their learned passive tags and base-ID cast upgrades, not custom procs.
*/
export function romScoutRogueProcActionSpec(
actionId: string,
triggeringSourceSkillId: number,
_casterPoisonous: boolean,
): RomScoutRogueProcActionSpec | null {
switch (actionId) {
case "rom-exploiting-shot":
if (triggeringSourceSkillId !== 490423) return null;
return {
kind: "aura",
recipient: "target",
aura: {
id: "rom-pair-exploiting-shot-vulnerability",
name: "Exploiting Shot",
disposition: "debuff",
durationMs: 12_000, // Source component 503169.
maxStacks: 5, // Explicit hybrid cap; source tooltip references a cap without a numeric value.
stackBehavior: "add",
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.02 }],
},
provenance: "hybrid-estimate",
};
case "rom-deadly-counterattack":
if (triggeringSourceSkillId !== 490400) return null;
return {
kind: "aura",
recipient: "caster",
aura: {
id: "rom-pair-deadly-counterattack-crit",
name: "Deadly Counterattack",
disposition: "buff",
durationMs: 5_000, // Hybrid estimate; source specifies a 20s internal cooldown, not buff duration.
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }],
},
provenance: "hybrid-estimate",
};
case "rom-poisonous-spit":
if (![490457, 490424].includes(triggeringSourceSkillId)) return null;
return {
kind: "dot",
recipient: "target",
effect: {
kind: "dot",
coefficient: 0,
basePoints: 7.1,
pointsPerLevel: 2.1,
sourceLevel: 1,
ticks: 4,
intervalMs: 2_000,
tag: "rom-poisonous-spit",
},
provenance: "source", // 499535/503522. Poison Powder is supplied without inventory gating.
};
case "rom-reclaim-energy":
if (triggeringSourceSkillId === 490306) return {
kind: "resource", recipient: "caster", resource: "energy", amount: 15, provenance: "hybrid-estimate",
};
if (triggeringSourceSkillId === 492625) return {
kind: "resource", recipient: "caster", resource: "rage", amount: 10, provenance: "hybrid-estimate",
};
return null;
case "rom-infection-rage":
return triggeringSourceSkillId === 492625
? { kind: "resource", recipient: "caster", resource: "rage", amount: 20, provenance: "hybrid-estimate" }
: null;
case "rom-toxic-splash": // 499477 is applied as an upgrade of base 492920.
case "rom-poison-battle-technique": // 499481 is applied as an upgrade of base 490133.
return null;
default:
return null;
}
}
@@ -0,0 +1,437 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { romScoutRogueCastEffects, romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
const scoutRogue = abilitiesForCharacter("rom-scout", "rom-rogue");
const rogueWarrior = abilitiesForCharacter("rom-rogue", "rom-warrior");
function skill(abilities: readonly AbilityDefinition[], id: string): AbilityDefinition {
const result = abilities.find((ability) => ability.id === id);
if (!result) throw new Error(`Missing skill ${id}`);
return result;
}
function modifiers(ability: AbilityDefinition) {
return ability.effects.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []);
}
function workingPassive(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "apply-aura" && Boolean(
effect.aura.modifiers?.length || effect.aura.procs?.length || effect.aura.utilityTags?.length,
));
}
function prime(primary: "rom-scout" | "rom-rogue", level: number): void {
useCombatStore.getState().initializeCharacter({
classId: primary,
secondaryClassId: primary === "rom-scout" ? "rom-rogue" : "rom-warrior",
raceId: primary === "rom-scout" ? "rom-elf" : "rom-human",
level,
});
for (const [id, x, z] of [["target", 3, 0], ["behind", 6, 0], ["nearby", 3, 3]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
setMobPosition(id, [x, 0, z]);
}
useCombatStore.getState().selectMob("target");
}
describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
beforeEach(() => clearMobRuntimeRegistry());
it.each([
["rom-scout", "rom-rogue", [491493, 491336, 492342, 491496, 492621, 492916, 493007, 493253, 499534, 499536]],
["rom-rogue", "rom-warrior", [491526, 491527, 492349, 491339, 492625, 492920, 493017, 493257, 499476, 499480]],
] as const)("learns all ten %s/%s source elites at their primary levels", (primary, secondary, ids) => {
const elites = abilitiesForCharacter(primary, secondary).filter((ability) => ability.secondaryClassId);
expect(elites.map((ability) => ability.dbcSpellId)).toEqual(ids);
expect(elites.map((ability) => ability.unlockLevel)).toEqual([15, 20, 25, 30, 35, 40, 45, 50, 60, 70]);
for (const [level, count] of [[10, 0], [30, 4], [60, 9], [79, 10]] as const) {
expect(elites.filter((ability) => learnedAbilityForBuild(ability, level, {}) !== null), `level ${level}`).toHaveLength(count);
}
});
it.each([10, 30, 60, 79])("deals ordinary single-target damage with both pairings at level %i", (level) => {
for (const [primary, id, pool] of [
["rom-scout", "rom-scout-primary-1", "focus"],
["rom-rogue", "rom-rogue-general-0", "energy"],
] as const) {
prime(primary, level);
const before = useCombatStore.getState().mobs.target!.health;
const resource = useCombatStore.getState().resourcePools[pool]!;
expect(useCombatStore.getState().castAbility(id, undefined, 1_000), id).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs.target!.health, id).toBeLessThan(before);
expect(useCombatStore.getState().resourcePools[pool], id).toBeLessThan(resource);
}
});
it("runs Scout and Rogue offensive cooldowns and Rogue's defensive Evasion", () => {
prime("rom-scout", 60);
expect(useCombatStore.getState().castAbility("rom-scout-primary-0", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.modifiers?.some((modifier) =>
modifier.kind === "damage" && modifier.direction === "dealt" && modifier.attackKind === "ranged"))).toBe(true);
prime("rom-rogue", 60);
expect(useCombatStore.getState().castAbility("rom-rogue-primary-0", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.modifiers?.some((modifier) =>
modifier.kind === "stat" && modifier.stat === "physical-critical-strike"))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-rogue-primary-14", undefined, 2_600)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.modifiers?.some((modifier) =>
modifier.kind === "stat" && modifier.stat === "dodge"))).toBe(true);
});
it("Piercing Arrow hits a line without applying an unrelated focus drain (490400)", () => {
prime("rom-scout", 60);
const cast = useCombatStore.getState().castAbility("rom-scout-primary-5", undefined, 1_000);
expect(cast).toMatchObject({ ok: true, affectedMobIds: ["target", "behind"] });
expect(useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-scout-primary-5"
&& event.kind === "damage" && event.rawAmount > 0).map((event) => event.targetActorId))
.toEqual(["target", "behind"]);
expect(modifiers(skill(scoutRogue, "rom-scout-primary-5")).some((modifier) =>
modifier.kind === "resource" && modifier.resource === "focus")).toBe(false);
});
it("Combo Shot hits three times and Reflected Shot has two weaker bounces (490424, 490457)", () => {
expect(skill(scoutRogue, "rom-scout-primary-9").effects.filter((effect) => effect.kind === "damage")).toHaveLength(3);
prime("rom-scout", 40);
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 1_000))
.toMatchObject({ ok: true, affectedMobIds: expect.arrayContaining(["target", "behind", "nearby"]) });
const rawDamage = useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-scout-primary-11"
&& event.kind === "damage").map((event) => event.rawAmount);
expect(rawDamage).toHaveLength(3);
expect(rawDamage[0]).toBeGreaterThan(rawDamage[1]!);
expect(rawDamage[1]).toBeGreaterThan(rawDamage[2]!);
});
it("Poisonous Spit removes bounce falloff and Master Reflected Shot adds two jumps (493253, 499534)", () => {
prime("rom-scout", 60);
setMobPosition("behind", [4, 0, 0]);
setMobPosition("nearby", [5, 0, 0]);
for (const [id, x, z] of [["fourth", 6, 0], ["fifth", 7, 0]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
setMobPosition(id, [x, 0, z]);
}
const cast = useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 1_000);
expect(cast).toMatchObject({ ok: true, affectedMobIds: expect.arrayContaining([
"target", "behind", "nearby", "fourth", "fifth",
]) });
const damage = useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-scout-primary-11"
&& event.kind === "damage").map((event) => event.rawAmount);
expect(damage).toHaveLength(5);
expect(new Set(damage).size).toBe(1);
});
it("Master Reflected Shot uses replacement 499535's 0.85 / 0.75 DPS ratio", () => {
const ability = skill(scoutRogue, "rom-scout-primary-11");
const base = ability.effects.find((effect) => effect.kind === "damage");
const mastered = romScoutRogueCastEffects(ability, ability.effects,
["rom-poisonous-spit", "rom-master-reflected-shot"], [])
.find((effect) => effect.kind === "damage");
expect(base?.kind).toBe("damage");
expect(mastered?.kind).toBe("damage");
if (base?.kind !== "damage" || mastered?.kind !== "damage") return;
expect(mastered.coefficient).toBeCloseTo(base.coefficient * 0.85 / 0.75);
expect(mastered.falloffPerJump).toBe(1);
});
it("proc resolver gates Shot crit vulnerability and preserves source 2% / 12s", () => {
expect(romScoutRogueProcActionSpec("rom-exploiting-shot", 490400, false)).toBeNull();
expect(romScoutRogueProcActionSpec("rom-exploiting-shot", 490423, false)).toMatchObject({
kind: "aura", recipient: "target", aura: {
durationMs: 12_000, modifiers: [
{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.02 },
],
},
});
const proc = skill(scoutRogue, "rom-scout-rom-rogue-elite-40").effects.find((effect) => effect.kind === "apply-aura");
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
});
it("proc resolver gates Piercing Arrow critical buff to its own hit", () => {
expect(romScoutRogueProcActionSpec("rom-deadly-counterattack", 490423, false)).toBeNull();
expect(romScoutRogueProcActionSpec("rom-deadly-counterattack", 490400, false)).toMatchObject({
kind: "aura", recipient: "caster", provenance: "hybrid-estimate", aura: {
durationMs: 5_000,
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }],
},
});
const proc = skill(scoutRogue, "rom-scout-rom-rogue-elite-45").effects.find((effect) => effect.kind === "apply-aura");
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
});
it("proc resolver poisons Reflected or Combo Shot with unlimited Poison Powder", () => {
for (const sourceId of [490457, 490424]) {
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toMatchObject({
kind: "dot", recipient: "target", provenance: "source",
effect: { kind: "dot", ticks: 4, intervalMs: 2_000, basePoints: 7.1, pointsPerLevel: 2.1 },
});
}
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", 490423, true)).toBeNull();
const proc = skill(scoutRogue, "rom-scout-rom-rogue-elite-50").effects.find((effect) => effect.kind === "apply-aura");
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
});
it("executes the Scout/Rogue poison rider on a successful reflected hit", () => {
prime("rom-scout", 60);
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 2_600))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-poisonous-spit"
&& effect.targetId === "target")).toBe(true);
});
it("proc resolver routes Reclaim Energy by trigger skill", () => {
expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 490306, false)).toMatchObject({
kind: "resource", resource: "energy", amount: 15, provenance: "hybrid-estimate",
});
expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 492625, true)).toMatchObject({
kind: "resource", resource: "rage", amount: 10, provenance: "hybrid-estimate",
});
expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 490309, false)).toBeNull();
const proc = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-50").effects.find((effect) => effect.kind === "apply-aura");
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.chance : null).toBe(0.25);
});
it("Toxic Splash's 493296 Infection upgrade can return 20 rage on its hit", () => {
expect(romScoutRogueProcActionSpec("rom-infection-rage", 490338, false)).toBeNull();
expect(romScoutRogueProcActionSpec("rom-infection-rage", 492625, false)).toMatchObject({
kind: "resource", recipient: "caster", resource: "rage", amount: 20,
provenance: "hybrid-estimate",
});
const proc = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-60").effects.find((effect) => effect.kind === "apply-aura");
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.chance : null).toBe(0.25);
});
it("maps 499477/499481 through cast upgrades rather than redundant procs", () => {
expect(romScoutRogueProcActionSpec("rom-toxic-splash", 499477, true)).toBeNull();
expect(romScoutRogueProcActionSpec("rom-poison-battle-technique", 499481, true)).toBeNull();
});
it("Meridian Attack upgrades Wrist Attack's physical and spell damage penalty", () => {
const ability = skill(scoutRogue, "rom-scout-general-6");
expect(ability.dbcSpellId).toBe(490451);
const upgraded = romScoutRogueCastEffects(ability, ability.effects, ["rom-meridian-attack"], []);
const debuff = upgraded.find((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster");
expect(debuff?.kind === "apply-aura" ? debuff.aura : null).toMatchObject({
durationMs: 10_000,
modifiers: expect.arrayContaining([
{ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: -0.3 },
{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: -0.3 },
]),
});
});
it("applies Meridian Attack's ten-second Wrist Attack debuff at level 79", () => {
prime("rom-scout", 79);
expect(useCombatStore.getState().castAbility("rom-scout-general-6", undefined, 1_000))
.toMatchObject({ ok: true });
const debuff = useCombatStore.getState().auras.find((aura) => aura.targetId === "target"
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "dealt" && modifier.attackKind === "spell"));
expect(debuff?.definition.durationMs).toBe(10_000);
});
it("Toxic Splash upgrades Poison Spray to 493296's hit and 622992's six-second poison", () => {
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-40");
const upgraded = romScoutRogueCastEffects(ability, ability.effects, ["rom-toxic-splash"], []);
expect(upgraded.filter((effect) => effect.kind === "damage")).toHaveLength(2);
expect(upgraded.filter((effect) => effect.kind === "damage")[1]).toMatchObject({
kind: "damage", coefficient: 1.11375, coefficientPerLevel: 0.06375,
});
expect(upgraded.find((effect) => effect.kind === "dot")).toMatchObject({
kind: "dot", ticks: 3, intervalMs: 2_000, tag: "rom-622992",
basePoints: 16.2, pointsPerLevel: 4.2,
});
});
it("spreads Toxic Splash's replacement poison to nearby enemies at level 79", () => {
prime("rom-rogue", 79);
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 100 } }));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(1.1, 2_100);
const infectionCast = useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-35", undefined, 2_600);
expect(infectionCast, infectionCast.reason).toMatchObject({ ok: true });
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-40", undefined, 4_200))
.toMatchObject({ ok: true, affectedMobIds: expect.arrayContaining(["target", "nearby"]) });
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-622992"
&& effect.targetId === "nearby")).toBe(true);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35" && effect.targetId === "target")).toBe(false);
});
it("Poison Battle Technique upgrades Whirlwind and adds a per-target poisoned hit", () => {
const ability = skill(rogueWarrior, "rom-warrior-general-2");
const base = ability.effects.find((effect) => effect.kind === "damage");
const upgraded = romScoutRogueCastEffects(ability, ability.effects, ["rom-poison-battle-technique"], [])
.find((effect) => effect.kind === "damage");
expect(base?.kind).toBe("damage");
expect(upgraded?.kind).toBe("damage");
if (base?.kind !== "damage" || upgraded?.kind !== "damage") return;
expect(upgraded.coefficient).toBeCloseTo(base.coefficient * 0.75 / 0.70);
expect(romScoutRogueWhirlwindBonusEffect(ability, ["rom-poison-battle-technique"], []))
.toBeNull();
expect(romScoutRogueWhirlwindBonusEffect(ability, ["rom-poison-battle-technique"], ["rom-622992"]))
.toMatchObject({ kind: "damage", coefficient: 0.5, coefficientPerLevel: 0.03 });
});
it("Whirlwind grants its Poison Battle Technique rider only to sprayed targets at level 79", () => {
prime("rom-rogue", 79);
useCombatStore.getState().registerMob("clean", { name: "clean", maxHealth: 1_000_000 });
setMobPosition("clean", [-4, 0, 0]); // Whirlwind range, outside Spray's target-centered radius.
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 100 } }));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(1.1, 2_100);
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-35", undefined, 2_600))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-40", undefined, 4_200))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-622992" && effect.targetId === "target"))
.toBe(true);
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-622992" && effect.targetId === "clean"))
.toBe(false);
expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 5_800))
.toMatchObject({ ok: true, affectedMobIds: expect.arrayContaining(["target", "clean"]) });
const hits = useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-warrior-general-2"
&& event.kind === "damage");
expect(hits.filter((event) => event.targetActorId === "target")).toHaveLength(2);
expect(hits.filter((event) => event.targetActorId === "clean")).toHaveLength(1);
});
it("Rogue Combo Throw has two hits and Poison grants a proc state (490338, 490329)", () => {
expect(skill(rogueWarrior, "rom-rogue-primary-15").effects.filter((effect) => effect.kind === "damage")).toHaveLength(2);
prime("rom-rogue", 60);
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 2_100);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-poisonous")
&& Boolean(aura.definition.procs?.length))).toBe(true);
});
it("Rogue Vanish drops combat without performing an unrelated heal (490354)", () => {
prime("rom-rogue", 60);
useCombatStore.getState().damagePlayer(100);
const health = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("rom-rogue-primary-11", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().health).toBe(health);
expect(skill(rogueWarrior, "rom-rogue-primary-11").effects.some((effect) => effect.kind === "heal")).toBe(false);
});
it("Weak Spot has one hit and a timed critical buff (491493)", () => {
const ability = skill(scoutRogue, "rom-scout-rom-rogue-elite-15");
expect(ability.effects.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(ability.effects.some((effect) => effect.kind === "apply-aura" && effect.recipient === "caster"
&& effect.aura.durationMs !== null && effect.aura.durationMs > 0
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "physical-critical-strike"))).toBe(true);
});
it("Blinding Powder control breaks on damage (491336)", () => {
prime("rom-scout", 30);
expect(useCombatStore.getState().castAbility("rom-scout-rom-rogue-elite-20", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs.target!.statuses.some((status) => status.kind === "blind")).toBe(true);
useCombatStore.getState().damageMob("target", 1, 1_100);
expect(useCombatStore.getState().mobs.target!.statuses.some((status) => status.kind === "blind")).toBe(false);
});
it("Lasso Master prevents movement and attacks (492342)", () => {
prime("rom-scout", 30);
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.target!.statuses.map((status) => status.kind))
.toEqual(expect.arrayContaining(["root", "disarm"]));
});
it("Sapping Arrow lowers physical and magical defense (491496)", () => {
const values = modifiers(skill(scoutRogue, "rom-scout-rom-rogue-elite-30"));
expect(values.some((modifier) => modifier.kind === "stat" && modifier.stat === "armor" && modifier.value < 0)).toBe(true);
expect(values.some((modifier) => modifier.kind === "stat" && modifier.stat === "magical-defense" && modifier.value < 0)).toBe(true);
});
it("Deadly Poison Bite has one base hit and a conditional bonus (492621)", () => {
prime("rom-scout", 60);
expect(useCombatStore.getState().castAbility("rom-scout-rom-rogue-elite-35", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 2_100);
expect(useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "rom-scout-rom-rogue-elite-35"
&& event.kind === "damage" && event.targetActorId === "target")).toHaveLength(1);
});
it.each([
["rom-scout-rom-rogue-elite-40", 492916],
["rom-scout-rom-rogue-elite-45", 493007],
] as const)("%s uses an attack-triggered passive (source %i)", (id, sourceId) => {
const ability = skill(scoutRogue, id);
expect(ability.dbcSpellId).toBe(sourceId);
expect(ability.effects.some((effect) => effect.kind === "apply-aura" && Boolean(effect.aura.procs?.length))).toBe(true);
expect(ability.effects.some((effect) => effect.kind === "apply-aura" && effect.recipient === "caster"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken"))).toBe(false);
});
it.each([
["rom-scout-rom-rogue-elite-50", 493253],
["rom-scout-rom-rogue-elite-60", 499534],
["rom-scout-rom-rogue-elite-70", 499536],
] as const)("%s has executable cross-skill effects (source %i)", (id, sourceId) => {
const ability = skill(scoutRogue, id);
expect(ability.dbcSpellId).toBe(sourceId);
expect(workingPassive(ability)).toBe(true);
});
it("Throwing Mastery gives Throw a five-second slow (491526)", () => {
prime("rom-rogue", 30);
expect(useCombatStore.getState().castAbility("rom-rogue-general-1", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs.target!.statuses.some((status) => status.kind === "slow"
&& status.endsAt >= 6_000)).toBe(true);
});
it("One-Hand Axe Mastery does not boost dagger damage (491527)", () => {
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-20");
expect(ability.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) =>
modifier.kind === "damage" && modifier.attackKind === "melee"))).toBe(false);
});
it("Whirlwind Mastery lowers rage cost and cooldown (492349)", () => {
prime("rom-rogue", 30);
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 40 } }));
expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().resourcePools.rage).toBe(10);
expect(useCombatStore.getState().cooldowns["rom-warrior-general-2"]).toBeLessThanOrEqual(11_000);
});
it("Death's Touch damages and reduces received healing (491339)", () => {
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-30");
expect(ability.effects.some((effect) => effect.kind === "damage")).toBe(true);
expect(modifiers(ability).some((modifier) => modifier.kind === "healing"
&& modifier.direction === "taken" && modifier.value < 0)).toBe(true);
});
it("Poisonous Infection damages and poisons without making the victim immune (492625)", () => {
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-35");
expect(ability.effects.some((effect) => effect.kind === "damage")).toBe(true);
expect(ability.effects.some((effect) => effect.kind === "dot")).toBe(true);
expect(modifiers(ability).some((modifier) => modifier.kind === "damage" && modifier.direction === "taken"
&& modifier.school === "physical" && modifier.value <= -1)).toBe(false);
});
it("Poison Spray spreads poison and damage to nearby enemies (492920)", () => {
const ability = skill(rogueWarrior, "rom-rogue-rom-warrior-elite-40");
expect(ability.radius).toBeGreaterThan(0);
expect(ability.effects.some((effect) => effect.kind === "damage")).toBe(true);
expect(ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "apply-aura")).toBe(true);
});
it("Decay requires Poisonous and grants timed critical power (493017)", () => {
prime("rom-rogue", 60);
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warrior-elite-45", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(modifiers(skill(rogueWarrior, "rom-rogue-rom-warrior-elite-45")).some((modifier) =>
modifier.kind === "stat" && modifier.stat === "physical-critical-strike")).toBe(true);
});
it.each([
["rom-rogue-rom-warrior-elite-50", 493257],
["rom-rogue-rom-warrior-elite-60", 499476],
["rom-rogue-rom-warrior-elite-70", 499480],
] as const)("%s has executable triggered effects (source %i)", (id, sourceId) => {
const ability = skill(rogueWarrior, id);
expect(ability.dbcSpellId).toBe(sourceId);
expect(workingPassive(ability)).toBe(true);
});
});
+2 -2
View File
@@ -50,10 +50,10 @@ describe("specialization registry", () => {
});
it("gives the five RoM primary roles their appropriate weapon layouts", () => {
expect(romGearProfile("rom-priest", "healer").weaponLayout).toBe("caster");
expect(romGearProfile("rom-priest", "healer").weaponLayout).toBe("weapon-shield");
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");
expect(romGearProfile("rom-mage", "damage").weaponLayout).toBe("two-hand");
});
});
+28
View File
@@ -45,6 +45,23 @@ const WEIGHTS: Readonly<Record<GearArchetype, Readonly<Partial<Record<NumericIte
"non-block-tank": Object.freeze({ agility: 0.66, strength: 0.28, stamina: 1, armor: 0.14, defenseRating: 0.78, dodgeRating: 0.86, parryRating: 0.46, expertiseRating: 0.4 }),
});
const ROM_FIVE_HYBRID_PROFILES: Readonly<Partial<Record<ClassId, Readonly<{
role: PartyRole;
gearArchetype: GearArchetype;
weaponLayout: WeaponLayout;
statWeights: Readonly<Partial<Record<NumericItemStatKey, number>>>;
}>>>> = Object.freeze({
"rom-knight": Object.freeze({ role: "tank", gearArchetype: "block-tank", weaponLayout: "weapon-shield", statWeights: Object.freeze({ ...WEIGHTS["block-tank"], armor: 0.16, blockValue: 0.76 }) }),
"rom-priest": Object.freeze({ role: "healer", gearArchetype: "healer", weaponLayout: "weapon-shield", statWeights: Object.freeze({ ...WEIGHTS.healer, spirit: 0.7, mp5: 0.95 }) }),
"rom-scout": Object.freeze({ role: "damage", gearArchetype: "ranged-dps", weaponLayout: "ranged", statWeights: Object.freeze({ ...WEIGHTS["ranged-dps"], rangedAttackPower: 0.6 }) }),
"rom-rogue": Object.freeze({ role: "damage", gearArchetype: "agility-dps", weaponLayout: "dual-wield", statWeights: Object.freeze({ ...WEIGHTS["agility-dps"], expertiseRating: 0.78 }) }),
"rom-mage": Object.freeze({ role: "damage", gearArchetype: "spell-dps", weaponLayout: "two-hand", statWeights: Object.freeze({ ...WEIGHTS["spell-dps"], spellPower: 1.1 }) }),
});
export function isRomHybridGearClass(classId: ClassId): boolean {
return ROM_FIVE_HYBRID_PROFILES[classId] !== undefined;
}
type SpecTuple = readonly [
classId: ClassId,
displayName: string,
@@ -212,6 +229,17 @@ export function specializationScore(stats: ItemStats, definition: Specialization
/** RoM has no saved specialization; derive a complete gear profile from primary class and activity role. */
export function romGearProfile(classId: ClassId, role: PartyRole): SpecializationDefinition {
const definition = classById(classId);
const hybrid = ROM_FIVE_HYBRID_PROFILES[classId];
if (hybrid?.role === role) return Object.freeze({
id: `${classId}:role-${role}`,
classId,
displayName: `${definition.name} ${role}`,
role,
talentTree: "Primary class",
gearArchetype: hybrid.gearArchetype,
weaponLayout: hybrid.weaponLayout,
statWeights: hybrid.statWeights,
});
const base = baseClassFor(classId);
const caster = ["priest", "mage", "warlock"].includes(base) || definition.primaryStat === "intellect" || definition.primaryStat === "spirit" || definition.primaryStat === "wisdom";
const gearArchetype: GearArchetype = role === "healer"
+41 -4
View File
@@ -114,6 +114,42 @@ const RACE_DBC = Object.fromEntries(RACES.map((entry) => [entry.id, entry.dbcId]
const classUsesMana = (classId: ClassId): boolean => classById(classId).resourceSummary.includes("Mana");
/** The five playable RoM pairs use Wrath base curves alongside their hybrid heirlooms. */
const ROM_HYBRID_BASE_RACE: Partial<Readonly<Record<ClassId, RaceId>>> = Object.freeze({
"rom-knight": "human",
"rom-priest": "human",
"rom-scout": "night-elf",
"rom-rogue": "human",
// Dwarf Mage was not a 3.3.5 race/class combination; Human supplies its class curve.
"rom-mage": "human",
});
function romHybridBase(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
level: number,
): { readonly attributes: PrimaryAttributes; readonly resources: { readonly health: number; readonly mana: number } } | null {
const race = ROM_HYBRID_BASE_RACE[classId];
if (!race) return null;
const primaryAttributes = baseAttributesFor(classId, level, race);
const primaryResources = baseResourcesFor(classId, level);
const secondary = level >= 10 && secondaryClassId && isRomClassId(secondaryClassId)
? secondaryClassId : null;
const secondaryAttributes = secondary
? baseAttributesFor(secondary, level, ROM_HYBRID_BASE_RACE[secondary] ?? "human") : null;
const secondaryResources = secondary ? baseResourcesFor(secondary, level) : null;
const attributes = Object.fromEntries((Object.keys(primaryAttributes) as (keyof PrimaryAttributes)[]).map((key) => [
key, primaryAttributes[key] + (secondaryAttributes?.[key] ?? 0) * .1,
])) as unknown as PrimaryAttributes;
return {
attributes,
resources: {
health: primaryResources.health + (secondaryResources?.health ?? 0) * .1,
mana: primaryResources.mana + (secondaryResources?.mana ?? 0) * .1,
},
};
}
/**
* Automatic racial auras from Ascension's effective Spell.dbc that can change
* a character's starting attributes or resource pools.
@@ -259,15 +295,16 @@ export function deriveCharacterStats(
secondaryClassId?: ClassId | null,
): CharacterStats {
const safe = safeLevel(level);
const romBase = romDualClassBaseStats(classId, secondaryClassId, safe);
const base = romBase ? {
const hybridBase = romHybridBase(classId, secondaryClassId, safe);
const romBase = hybridBase ? null : romDualClassBaseStats(classId, secondaryClassId, safe);
const base = hybridBase?.attributes ?? (romBase ? {
strength: romBase.strength,
agility: romBase.agility,
stamina: romBase.stamina,
intellect: romBase.intellect,
spirit: romBase.wisdom,
} : baseAttributesFor(classId, safe, raceId);
const resources = romBase ? { health: romBase.health, mana: romBase.mana } : baseResourcesFor(classId, safe);
} : baseAttributesFor(classId, safe, raceId));
const resources = hybridBase?.resources ?? (romBase ? { health: romBase.health, mana: romBase.mana } : baseResourcesFor(classId, safe));
const racial = RACIAL_STAT_PASSIVES[raceId];
const strength = percentAdjusted(base.strength + gear.strength, racial?.strength, modifiers.strength);
const agility = percentAdjusted(base.agility + gear.agility, racial?.agility, modifiers.agility);
+4 -1
View File
@@ -144,6 +144,7 @@ function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
const equipment = useCombatStore((state) => state.equipment);
const level = useCombatStore((state) => state.level);
const specializationId = useCombatStore((state) => state.specializationId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const gearRole = useCombatStore((state) => state.gearRole);
const [payload, setPayload] = useState<AvatarWarmupPayload | null>(null);
@@ -162,6 +163,7 @@ function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: activeCharacter.classId,
secondaryClassId,
specializationId,
role: gearRole,
level,
@@ -179,6 +181,7 @@ function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
@@ -210,7 +213,7 @@ function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
return () => {
active = false;
};
}, [activeCharacter, equipment, gearRole, inventory, level, members, onSettled, specializationId]);
}, [activeCharacter, equipment, gearRole, inventory, level, members, onSettled, secondaryClassId, specializationId]);
if (!payload) return null;
if (!payload.modelUrls.length) {
+1
View File
@@ -301,6 +301,7 @@ function PartyActors({
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
+3 -1
View File
@@ -476,6 +476,7 @@ export function PlayerRig() {
const equipment = useCombatStore((state) => state.equipment);
const level = useCombatStore((state) => state.level);
const specializationId = useCombatStore((state) => state.specializationId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const gearRole = useCombatStore((state) => state.gearRole);
const equipmentItems = useMemo(
() => activeCharacter ? resolvedEquipmentItems(
@@ -485,12 +486,13 @@ export function PlayerRig() {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: activeCharacter.classId,
secondaryClassId,
specializationId,
role: gearRole,
level,
},
) : [],
[activeCharacter, equipment, gearRole, inventory, level, specializationId],
[activeCharacter, equipment, gearRole, inventory, level, secondaryClassId, specializationId],
);
const bodyRef = useRef<RapierRigidBody>(null);
const avatarRef = useRef<Object3D>(null);
+9
View File
@@ -2402,6 +2402,15 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.main-menu-gm-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.main-menu-gm-grid .main-menu-mode { min-height: 78px; border-color: rgba(210,177,109,.34); background: linear-gradient(135deg, rgba(103,76,39,.28), rgba(29,39,40,.58)); }
.main-menu-gm-grid .main-menu-mode:disabled { cursor: not-allowed; opacity: .48; filter: saturate(.35); }
.main-menu-random-modal { width: min(450px, 100%); max-height: 100%; overflow-y: auto; padding: clamp(18px, 3vw, 28px); border: 1px solid rgba(159,217,223,.48); border-radius: 8px; background: linear-gradient(150deg, rgba(21,45,48,.98), rgba(5,15,20,.99)); box-shadow: 0 30px 80px #000, inset 3px 0 0 var(--main-menu-ice); }
.main-menu-random-modal h2 { margin: 5px 0 10px; color: #f4eddd; font: 28px Georgia, serif; font-weight: 400; }
.main-menu-random-modal > p:not(.front-kicker) { color: #b9c9c8; font-size: 12px; line-height: 1.5; }
.main-menu-random-modal__roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 8px; margin: 18px 0; }
.main-menu-random-modal__role { min-height: 48px; justify-content: center; border-color: rgba(159,217,223,.35); }
.main-menu-random-modal__role[aria-pressed="true"] { border-color: var(--main-menu-ice); background: rgba(64,116,121,.5); color: #fff; }
.main-menu-random-modal__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 18px; }
.main-menu-random-modal__actions .front-button { justify-content: center; min-height: 44px; }
.main-menu-random-modal__progress { min-height: 18px; }
/* Dungeon browser */
.dungeon-select-screen {
+3 -1
View File
@@ -30,6 +30,7 @@ function itemType(inventorySlot: string): string {
export function BossLootWindow() {
const reward = useCombatStore((state) => state.bossLootRewards[0] ?? null);
const classId = useCombatStore((state) => state.classId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const specializationId = useCombatStore((state) => state.specializationId);
const role = useCombatStore((state) => state.gearRole);
const level = useCombatStore((state) => state.level);
@@ -52,6 +53,7 @@ export function BossLootWindow() {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player" as const,
classId,
secondaryClassId,
specializationId,
role,
level,
@@ -88,7 +90,7 @@ export function BossLootWindow() {
<b aria-hidden="true">◆</b><p><strong>{BOSS_CARD_RARITY_INFO[rarity].label} Boss Card</strong><small>{reward.bossName} · Collection log</small></p><em>+1</em>
</article>)}
{reward.items.map((item) => {
const comparison = classCanEquipItem(classId, item)
const comparison = classCanEquipItem(classId, item, { secondaryClassId, level })
? compareEquipmentUpgrade(item, loadout, profile, heirloomContext)
: null;
return <article
+1 -4
View File
@@ -68,15 +68,12 @@ 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.id === dungeonSelectionId)
?? DUNGEON_CATALOG.find((dungeon) => dungeon.available)
?? DUNGEON_CATALOG[0];
const firstAvailable = 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);
+10 -4
View File
@@ -53,6 +53,7 @@ interface EquipmentOwner {
readonly name: string;
readonly detail: string;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
@@ -189,6 +190,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment);
const playerClassId = useCombatStore((state) => state.classId);
const playerSecondaryClassId = useCombatStore((state) => state.secondaryClassId);
const playerLevel = useCombatStore((state) => state.level);
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
const playerGearStats = useCombatStore((state) => state.gearStats);
@@ -204,6 +206,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
name: character?.name ?? "You",
detail: "Player",
classId: playerClassId,
secondaryClassId: playerSecondaryClassId,
specializationId: playerSpecializationId,
role: playerGearRole,
level: playerLevel,
@@ -214,12 +217,13 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
name: member.name,
detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`,
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
ownerKind: "party" as const,
})),
], [character?.name, members, playerClassId, playerGearRole, playerLevel, playerSpecializationId]);
], [character?.name, members, playerClassId, playerSecondaryClassId, playerGearRole, playerLevel, playerSpecializationId]);
const [ownerId, setOwnerId] = useState(PLAYER_EQUIPMENT_OWNER_ID);
const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? "");
const [statPage, setStatPage] = useState<StatPage>("attributes");
@@ -240,13 +244,14 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
ownerId: owner.id,
ownerKind: owner.ownerKind,
classId: owner.classId,
secondaryClassId: owner.secondaryClassId,
specializationId: owner.specializationId,
role: owner.role,
level: owner.level,
};
const loadout = useMemo(
() => resolvedEquipmentLoadout(equipment, inventory, heirloomContext),
[equipment, inventory, owner.classId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId],
[equipment, inventory, owner.classId, owner.secondaryClassId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId],
);
const earnedLoadout = equipment[owner.id] ?? {};
const sortedInventory = useMemo(() => [...inventory].sort((left, right) => (
@@ -285,7 +290,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null;
const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null;
const compatible = inspected ? classCanEquipItem(owner.classId, inspected) : false;
const compatible = inspected ? classCanEquipItem(owner.classId, inspected, owner) : false;
const gearLevel = resolvedAverageItemLevel(equipment, inventory, heirloomContext);
const gearProfile = gearProfileFor(owner.classId, owner.specializationId, owner.role);
const inspectedComparison = inspected && compatible
@@ -345,6 +350,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
ownerId: candidate.id,
ownerKind: candidate.ownerKind,
classId: candidate.classId,
secondaryClassId: candidate.secondaryClassId,
specializationId: candidate.specializationId,
role: candidate.role,
level: candidate.level,
@@ -415,7 +421,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
{sortedInventory.map((item) => {
const assignedId = equippedOwnerId(equipment, item.instanceId);
const assignedOwner = assignedId ? ownerById.get(assignedId) : null;
const canEquip = classCanEquipItem(owner.classId, item);
const canEquip = classCanEquipItem(owner.classId, item, owner);
const comparison = canEquip ? compareEquipmentUpgrade(item, loadout, gearProfile, heirloomContext) : null;
return (
<ControllerButton
+100 -6
View File
@@ -1,7 +1,12 @@
import { lazy, Suspense, useCallback, useMemo, useState } from "react";
import { classById } from "../app/characterCatalog";
import { queueRandomDungeon, type RandomDungeonQueueMode, type RandomDungeonQueueStage } from "../app/randomDungeonQueue";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
@@ -125,23 +130,78 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const [gmMenuOpen, setGmMenuOpen] = useState(false);
const [randomQueueMode, setRandomQueueMode] = useState<RandomDungeonQueueMode | null>(null);
const [selectedRole, setSelectedRole] = useState<PartyRole | null>(null);
const [queueing, setQueueing] = useState(false);
const [queueStage, setQueueStage] = useState<RandomDungeonQueueStage>("rolling");
const [queuedDungeonTitle, setQueuedDungeonTitle] = useState("");
const [queueError, setQueueError] = useState("");
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 openRandomDungeon = useCallback(() => { setRandomQueueMode("regular"); setSelectedRole(null); setQueueError(""); }, []);
const openRandomBiteSizedDungeon = useCallback(() => { setRandomQueueMode("bite-sized"); setSelectedRole(null); setQueueError(""); }, []);
const openManastorms = useShellStore((state) => state.openManastorms);
const openArenas = useShellStore((state) => state.openArenas);
const openClassHall = useShellStore((state) => state.openClassHall);
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
const notice = useShellStore((state) => state.notice);
const session = useShellStore((state) => state.session);
const enterDungeon = useShellStore((state) => state.enterDungeon);
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
const fillWithAi = useOnlineGroupStore((state) => state.fillWithAi);
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
const contentState = useContentManagerSnapshot();
const character = activeCharacter ?? rosterCharacter;
const isRom = character?.categoryId === "rom";
const isGm = sessionCanUseGmTools(session);
const savedSpecialization = specializationById(character?.specializationId);
const availableRoles = character
? isRom ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
: [];
const actions = useMemo<MenuAction[]>(() => gmMenuOpen ? [
const closeRandomQueue = useCallback(() => {
if (!queueing) setRandomQueueMode(null);
}, [queueing]);
const startRandomQueue = useCallback(async () => {
if (!randomQueueMode || !selectedRole || queueing) return;
setQueueing(true);
setQueueStage("rolling");
setQueueError("");
setQueuedDungeonTitle("");
const result = await queueRandomDungeon({
mode: randomQueueMode,
role: selectedRole,
character,
session,
group: onlineGroup,
includeBetaSpecs,
fillWithAi,
onProgress: (stage, dungeon) => { setQueueStage(stage); setQueuedDungeonTitle(dungeon?.title ?? ""); },
enterDungeon: (dungeonId, role, settings) => enterDungeon(dungeonId, role, settings),
updateOnlineMember,
startOnlineActivity: (groupSession, input) => startOnlineActivity(groupSession, input),
failureNotice: () => useOnlineGroupStore.getState().error || useShellStore.getState().notice,
});
if (result.status === "error") setQueueError(result.message);
else if (result.status === "waiting") {
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[selectedRole]}. Waiting for the group leader to start.`);
setRandomQueueMode(null);
} else if (result.status === "started") {
useShellStore.getState().setNotice(`Starting ${result.dungeonTitle} with your group.`);
setRandomQueueMode(null);
}
setQueueing(false);
}, [character, enterDungeon, fillWithAi, includeBetaSpecs, onlineGroup, queueing, randomQueueMode, selectedRole, session, startOnlineActivity, updateOnlineMember]);
const actions = useMemo<MenuAction[]>(() => randomQueueMode ? [
...availableRoles.map((role) => ({ id: `random-queue-role-${role}`, run: () => setSelectedRole(role), enabled: !queueing })),
{ id: "random-queue-confirm", run: () => void startRandomQueue(), enabled: Boolean(selectedRole) && !queueing },
{ id: "random-queue-cancel", run: closeRandomQueue, enabled: !queueing },
] : gmMenuOpen ? [
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
@@ -184,11 +244,11 @@ 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, openRandomBiteSizedDungeon, openRandomDungeon, returnToCharacters]);
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, startRandomQueue]);
const controller = useMenuController(actions, {
columns: 2,
initialId: gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
onBack: gmMenuOpen ? () => setGmMenuOpen(false) : returnToCharacters,
initialId: randomQueueMode ? `random-queue-role-${availableRoles[0]}` : gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
onBack: randomQueueMode ? closeRandomQueue : gmMenuOpen ? () => setGmMenuOpen(false) : returnToCharacters,
});
const characterClass = character ? classById(character.classId) : null;
@@ -315,6 +375,40 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
</main>
<ControllerLegend back />
{randomQueueMode && (
<div className="front-modal-backdrop">
<section className="main-menu-random-modal" role="dialog" aria-modal="true" aria-labelledby="random-queue-title">
<p className="front-kicker">{randomQueueMode === "regular" ? "Random Dungeon" : "Random Regular Bite-Sized Dungeon"}</p>
<h2 id="random-queue-title">Choose your role</h2>
<p>Pick how you want to play, then queue. A dungeon will be rolled and downloaded before you enter.</p>
<div className="main-menu-random-modal__roles" aria-label="Party role">
{availableRoles.map((role, index) => (
<ControllerButton key={role} controlId={`random-queue-role-${role}`}
selectedId={controller.selectedId} select={controller.select} autoFocus={index === 0}
className="front-button main-menu-random-modal__role" type="button"
aria-pressed={selectedRole === role} disabled={queueing} onClick={() => setSelectedRole(role)}>
{PARTY_ROLE_LABELS[role]}
</ControllerButton>
))}
</div>
{!availableRoles.length && <p className="front-notice">Choose a specialization in your Class Hall before queuing.</p>}
{queueing && <p className="main-menu-random-modal__progress" role="status" aria-live="polite">
{queueStage === "rolling" ? "Rolling a dungeon…" : queueStage === "downloading"
? `Downloading ${queuedDungeonTitle}${contentState.installingPackId && contentState.totalBytes > 0
? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "…"}`
: `Entering ${queuedDungeonTitle}…`}
</p>}
{queueError && <p className="front-notice" role="alert">{queueError}</p>}
<div className="main-menu-random-modal__actions">
<ControllerButton controlId="random-queue-cancel" selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" disabled={queueing} onClick={closeRandomQueue}>Cancel</ControllerButton>
<ControllerButton controlId="random-queue-confirm" selectedId={controller.selectedId} select={controller.select}
className="front-button front-button--primary" type="button" disabled={!selectedRole || queueing}
onClick={() => void startRandomQueue()}>{queueing ? "Queuing…" : "Queue"}</ControllerButton>
</div>
</section>
</div>
)}
{gmMenuOpen && (
<div className="front-modal-backdrop">
<section className="main-menu-gm-modal" role="dialog" aria-modal="true" aria-labelledby="gm-menu-title">