Validate RoM primary five hybrid stats and pair mechanics
This commit is contained in:
1 parent
8e95480d4f
commit
c8b3a3d95a
18 files changed
+2061
-48
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# RoM five class validation
|
# 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
|
## Cohort and rules
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ The five named builds and live NPCs use these race-legal pairings. Their seconda
|
|||||||
| DPS | Scout / Rogue | Caelan | Elf |
|
| DPS | Scout / Rogue | Caelan | Elf |
|
||||||
| DPS | Mage / Warlock | Durnik | Dwarf |
|
| 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 hybrid heirloom layouts and level growth, ordinary ability coverage, and focused runtime mechanics. Knight is a tank seat in RoM generated parties. The five loadouts use Knight mace/shield, Priest mace/shield, Scout dagger/bow, Rogue dual daggers, and Mage staff. Their equipment uses WoW combat stats while RoM skills and class-pair mechanics remain distinct.
|
`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 five loadouts use Knight mace/shield, Priest mace/shield, Scout dagger/bow, Rogue dual daggers, and Mage staff. 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
|
## Findings and fixes in this pass
|
||||||
|
|
||||||
@@ -29,6 +29,17 @@ 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.
|
- 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.
|
- 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 196 tests across ten files on 2026-09-23. These include direct healing, area healing and damage, taunts, resource changes, offensive and defensive auras, crowd control, and source-conditional skill checks. Pure effect tests are evidence for translation and selection, not proof that every NPC cast and encounter timing is correct.
|
||||||
|
|
||||||
|
Remaining source and runtime gaps:
|
||||||
|
|
||||||
|
1. Source replacement skills referenced by several level-60/70 elites are absent from the playable generated catalog: Priest/Knight `499967`/`499966`, Knight/Scout `499925`/`499924`/`499910`, and Scout/Rogue or Rogue/Warrior `499535`/`499537`/`499477`/`499481`. Toxic Splash and Poison Battle Technique cannot execute their replacement-skill proc payloads yet. Several known magnitudes and chances use explicitly tagged hybrid estimates.
|
||||||
|
2. NPC passive tags now feed shared conditional casts. Player-side Poisonous Spit has a live proc test; companion proc execution and cooldown/resource side effects still need focused end-to-end coverage. This matters for paired NPCs even when the player-side tests pass.
|
||||||
|
3. Holy Chains and Evil Shackles need creature-family data for their Undead/Demon rule. Numbing Dagger also needs living/Humanoid target classification and projectile inventory consumption. Poisonous Spit currently recognizes a Poisonous aura as a stand-in for carrying Poison Powder; an actual Powder inventory item is not modeled. Knight/Scout bow-specific effects need a legal ranged loadout; the current Knight heirloom uses mace/shield. Rogue/Warrior One-Hand Axe Mastery cannot benefit the current dual-dagger layout.
|
||||||
|
4. 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
|
## 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-*`.
|
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-*`.
|
||||||
@@ -53,6 +64,8 @@ After the mechanics corrections, the targeted Shadoj run at level 60 with the sa
|
|||||||
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.
|
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 both Rogue daggers visually from an unobstructed angle. The live roster and dungeon screenshots in `artifacts/playtest/rom-five/` confirm rendered bodies and weapon silhouettes, but the off-hand dagger is unclear at their camera angles. Keep RoM entries at Needs Testing until the full class mechanics and role checks pass.
|
||||||
|
|
||||||
The RoM source catalog and generated data remain authoritative for ability descriptions and components. The recorded combat results cover primary-only builds; rerun targeted diagnostics before drawing conclusions about the new pairings. Elite skill validation is deferred.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# RoM stat weights and gearing audit
|
# RoM stat weights and gearing audit
|
||||||
|
|
||||||
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, class growth, resources, and class pairings, while equipment and final combat stats use the existing WoW-shaped model. See `docs/ROM_HEIRLOOM_REFERENCE.md` for the client-derived blue-item atlas and proposed native-stat curves; those numbers are reference points and are not applied directly to combat.
|
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
|
## 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` 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 RoM Wisdom into WoW Spirit, 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, Stamina-to-HP rule, and `0.35 * Intellect + 0.15 * Spirit` spell-power rule are not source RoM formulas.
|
- `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 legal layouts are Knight mace/shield, Priest mace/shield, Scout dagger/bow, Rogue dual daggers, and Mage staff. 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/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 legal layouts are Knight mace/shield, Priest mace/shield, Scout dagger/bow, Rogue dual daggers, and Mage staff. 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.
|
- `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.
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Priest healing cannot be scored by MATK alone: the source healing path also uses
|
|||||||
|
|
||||||
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.
|
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. A 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. Reports are under `artifacts/balance-lab/rom-hybrid-l20-shadoj`, `rom-hybrid-l60-shadoj`, and `rom-hybrid-l79-shadoj`. They are screening runs against one encounter, not a balance certification. RoM remains Needs Testing and elite-skill validation remains deferred.
|
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
|
## Optional native-fidelity path
|
||||||
|
|
||||||
@@ -56,6 +56,6 @@ RoM equipment has base item properties plus additional attribute sources. [Gamef
|
|||||||
|
|
||||||
## Online and validation boundary
|
## 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 elite-skill source gaps and the Mage damage outlier are investigated.
|
||||||
|
|
||||||
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.
|
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
@@ -125,7 +125,7 @@
|
|||||||
"balance:lab:playtest": "node scripts/playtest-balance-lab.cjs",
|
"balance:lab:playtest": "node scripts/playtest-balance-lab.cjs",
|
||||||
"readiness:playtest": "node scripts/playtest-companion-readiness.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: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 src/game/romHeirloomReference.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",
|
||||||
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
|
"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",
|
"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",
|
"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",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export type ResourceType =
|
|||||||
| `custom:${string}`;
|
| `custom:${string}`;
|
||||||
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
|
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
|
||||||
export type AbilityCastMode = "instant" | "cast" | "channel";
|
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 {
|
export interface AbilityTargetingRules {
|
||||||
readonly shape: AbilityTargetShape;
|
readonly shape: AbilityTargetShape;
|
||||||
@@ -157,7 +157,7 @@ export interface AbilityDamageResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AbilityEffect =
|
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 kind: "finisher-damage";
|
||||||
readonly baseCoefficient: number;
|
readonly baseCoefficient: number;
|
||||||
@@ -167,7 +167,7 @@ export type AbilityEffect =
|
|||||||
}
|
}
|
||||||
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||||
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: 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: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||||
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
||||||
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
||||||
|
|||||||
+154
-15
@@ -156,6 +156,8 @@ import {
|
|||||||
type HeirloomContext,
|
type HeirloomContext,
|
||||||
} from "./heirloomEquipment";
|
} from "./heirloomEquipment";
|
||||||
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics";
|
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics";
|
||||||
|
import { romMageWarlockCriticalEffects } from "./romMageWarlockOverrides";
|
||||||
|
import { romScoutRogueProcActionSpec } from "./romScoutRogueOverrides";
|
||||||
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
||||||
import { specializationById } from "./specializationCatalog";
|
import { specializationById } from "./specializationCatalog";
|
||||||
import {
|
import {
|
||||||
@@ -1269,6 +1271,12 @@ function talentAdjustedMaxHealth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
|
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 searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
||||||
const healing = ability.effects.some((effect) => (
|
const healing = ability.effects.some((effect) => (
|
||||||
effect.kind === "heal"
|
effect.kind === "heal"
|
||||||
@@ -1285,6 +1293,10 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
|
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 (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature";
|
||||||
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
|
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
|
||||||
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
||||||
@@ -1306,7 +1318,8 @@ export function equipmentAdjustedCombatAmount(
|
|||||||
classId: ClassId,
|
classId: ClassId,
|
||||||
ability: AbilityDefinition,
|
ability: AbilityDefinition,
|
||||||
): number {
|
): 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 attackPower = equipmentAttackPower(classId, gearStats, kind);
|
||||||
const weapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null;
|
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;
|
const weaponSpeed = weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8;
|
||||||
@@ -2202,7 +2215,7 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
|
|||||||
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
|
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
|
||||||
dodgePercent: stat(base.dodgePercent, "dodge"),
|
dodgePercent: stat(base.dodgePercent, "dodge"),
|
||||||
parryPercent: stat(base.parryPercent, "parry"),
|
parryPercent: stat(base.parryPercent, "parry"),
|
||||||
blockPercent: stat(base.blockPercent, "block"),
|
blockPercent: stat(base.blockPercent, "block", "block-chance"),
|
||||||
expertise: stat(base.expertise, "expertise"),
|
expertise: stat(base.expertise, "expertise"),
|
||||||
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
|
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
|
||||||
fireResistance: resistance(base.fireResistance, "fire"),
|
fireResistance: resistance(base.fireResistance, "fire"),
|
||||||
@@ -2309,6 +2322,52 @@ function executeTriggeredAuraProcsInWork(
|
|||||||
): void {
|
): void {
|
||||||
for (const triggered of triggeredProcs) {
|
for (const triggered of triggeredProcs) {
|
||||||
const action = triggered.action;
|
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"
|
const actionTarget = action.kind === "custom"
|
||||||
? null
|
? null
|
||||||
: action.target === "aura-source"
|
: action.target === "aura-source"
|
||||||
@@ -2897,6 +2956,19 @@ function resolveAndDamageMobInWork(
|
|||||||
work.gearStats.spellPenetration,
|
work.gearStats.spellPenetration,
|
||||||
combatResult,
|
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) {
|
if (combustion && combatResult.landed && result.amount > 0) {
|
||||||
const criticalHits = (combustion.combustionCriticalHits ?? 0) + Number(combatResult.critical);
|
const criticalHits = (combustion.combustionCriticalHits ?? 0) + Number(combatResult.critical);
|
||||||
work.auras = work.auras.flatMap((aura) => aura.instanceId !== combustion.instanceId
|
work.auras = work.auras.flatMap((aura) => aura.instanceId !== combustion.instanceId
|
||||||
@@ -3063,6 +3135,7 @@ function executeTriggeredSpellInWork(
|
|||||||
ability,
|
ability,
|
||||||
);
|
);
|
||||||
for (const mobId of mobTargets) {
|
for (const mobId of mobTargets) {
|
||||||
|
const effectSchool = effect.school ?? school;
|
||||||
const result = resolveAndDamageMobInWork(
|
const result = resolveAndDamageMobInWork(
|
||||||
work.classId,
|
work.classId,
|
||||||
work,
|
work,
|
||||||
@@ -3070,8 +3143,8 @@ function executeTriggeredSpellInWork(
|
|||||||
amount,
|
amount,
|
||||||
now,
|
now,
|
||||||
source,
|
source,
|
||||||
attackKind,
|
effectSchool === "physical" ? attackKind : "spell",
|
||||||
school,
|
effectSchool,
|
||||||
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
||||||
ability,
|
ability,
|
||||||
);
|
);
|
||||||
@@ -4773,10 +4846,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const catalogAbility = abilityById(abilityId);
|
const catalogAbility = abilityById(abilityId);
|
||||||
const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
|
const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
|
||||||
const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
|
const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
|
||||||
const ability = catalogAbility
|
let ability = catalogAbility
|
||||||
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
||||||
: null;
|
: null;
|
||||||
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
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) => (
|
const innerFocusAura = activePlayerAuras.find((aura) => (
|
||||||
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
||||||
));
|
));
|
||||||
@@ -4815,7 +4899,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
||||||
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
||||||
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
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 spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
||||||
const runeCost = ability && baseClassFor(state.classId) === "death-knight"
|
const runeCost = ability && baseClassFor(state.classId) === "death-knight"
|
||||||
? runeCostForAbility(ability)
|
? runeCostForAbility(ability)
|
||||||
@@ -4843,6 +4930,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
|
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-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.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 (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";
|
||||||
else if (abilityRequiresOutOfCombat(ability)
|
else if (abilityRequiresOutOfCombat(ability)
|
||||||
&& !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish"))
|
&& !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish"))
|
||||||
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
|
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
|
||||||
@@ -5005,6 +5096,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
affectedMobIds.push(id);
|
affectedMobIds.push(id);
|
||||||
previous = position;
|
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") {
|
} else if (targeting?.shape === "cone" && effectiveTarget === "hostile") {
|
||||||
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
||||||
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => {
|
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => {
|
||||||
@@ -5024,14 +5130,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
} else if (effectiveTarget === "hostile" && targetId) {
|
} else if (effectiveTarget === "hostile" && targetId) {
|
||||||
affectedMobIds = [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,
|
ability,
|
||||||
modifiers,
|
modifiers,
|
||||||
state.gearStats,
|
state.gearStats,
|
||||||
state.level,
|
state.level,
|
||||||
state.classId,
|
state.classId,
|
||||||
);
|
);
|
||||||
|
const castTimeMs = lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs;
|
||||||
const gcdMs = talentAdjustedGcd(
|
const gcdMs = talentAdjustedGcd(
|
||||||
ability,
|
ability,
|
||||||
modifiers,
|
modifiers,
|
||||||
@@ -5070,7 +5182,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
originalDurationMs: castTimeMs,
|
originalDurationMs: castTimeMs,
|
||||||
pushbackHits: 0,
|
pushbackHits: 0,
|
||||||
school: spellSchool,
|
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]],
|
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
|
||||||
groundPosition,
|
groundPosition,
|
||||||
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0,
|
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0,
|
||||||
@@ -5083,6 +5196,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
activeCast,
|
activeCast,
|
||||||
|
...(lastPrayerReady ? { auras: state.auras.filter((aura) => !(
|
||||||
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||||
|
&& aura.definition.utilityTags?.includes("rom-last-prayer")
|
||||||
|
)) } : {}),
|
||||||
...autoAttackUpdate,
|
...autoAttackUpdate,
|
||||||
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
|
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
|
||||||
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
|
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
|
||||||
@@ -5231,10 +5348,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
charged: hasRuneWakerState("rom-charged"),
|
charged: hasRuneWakerState("rom-charged"),
|
||||||
mainHandDagger: romMainHand?.weaponType === "dagger",
|
mainHandDagger: romMainHand?.weaponType === "dagger",
|
||||||
casterMaxHealth: state.maxHealth,
|
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(
|
||||||
|
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"] : []),
|
||||||
|
]),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const effectsToResolve = completingCast?.mode === "channel"
|
const effectsToResolve = completingCast?.mode === "channel"
|
||||||
? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
|
? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
|
||||||
: castEffects;
|
: castEffects;
|
||||||
|
let clearedSingleTargetControls = false;
|
||||||
for (const effect of effectsToResolve) {
|
for (const effect of effectsToResolve) {
|
||||||
if (ability.name === "Penance" && effectiveTarget === "friendly"
|
if (ability.name === "Penance" && effectiveTarget === "friendly"
|
||||||
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
|
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
|
||||||
@@ -5265,7 +5394,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
"damage-percent",
|
"damage-percent",
|
||||||
ability,
|
ability,
|
||||||
);
|
);
|
||||||
for (const id of affectedMobIds) {
|
for (const [targetIndex, id] of affectedMobIds.entries()) {
|
||||||
|
const effectSchool = effect.school ?? damageSchool;
|
||||||
const sunderStacks = effect.bonusPerSunderStack
|
const sunderStacks = effect.bonusPerSunderStack
|
||||||
? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
|
? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
|
||||||
: 0;
|
: 0;
|
||||||
@@ -5273,11 +5403,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
state.classId,
|
state.classId,
|
||||||
work,
|
work,
|
||||||
id,
|
id,
|
||||||
amount + sunderStacks * (effect.bonusPerSunderStack ?? 0),
|
Math.round((amount + sunderStacks * (effect.bonusPerSunderStack ?? 0))
|
||||||
|
* Math.pow(effect.falloffPerJump ?? 1, targetIndex)),
|
||||||
now,
|
now,
|
||||||
castThreatSource,
|
castThreatSource,
|
||||||
attackKind,
|
effectSchool === "physical" ? attackKind : "spell",
|
||||||
damageSchool,
|
effectSchool,
|
||||||
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
||||||
ability,
|
ability,
|
||||||
false,
|
false,
|
||||||
@@ -5754,7 +5885,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
effect.intervalMs,
|
effect.intervalMs,
|
||||||
now,
|
now,
|
||||||
effect.tag,
|
effect.tag,
|
||||||
damageSchool,
|
effect.school ?? damageSchool,
|
||||||
work.gearStats.spellPenetration,
|
work.gearStats.spellPenetration,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -5808,12 +5939,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
} else if (effect.kind === "status") {
|
} else if (effect.kind === "status") {
|
||||||
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
|
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)) {
|
for (const [mobId, mob] of Object.entries(work.mobs)) {
|
||||||
work.mobs[mobId] = { ...mob, statuses: mob.statuses.filter((status) => !(
|
work.mobs[mobId] = { ...mob, statuses: mob.statuses.filter((status) => !(
|
||||||
status.sourceAbilityId === ability.id && status.sourceActorId === PLAYER_AGGRO_ID
|
status.sourceAbilityId === ability.id && status.sourceActorId === PLAYER_AGGRO_ID
|
||||||
)) };
|
)) };
|
||||||
}
|
}
|
||||||
|
clearedSingleTargetControls = true;
|
||||||
}
|
}
|
||||||
for (const id of candidates) {
|
for (const id of candidates) {
|
||||||
const mob = work.mobs[id];
|
const mob = work.mobs[id];
|
||||||
@@ -6298,6 +6430,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ability.dbcSpellId === 498759 && targetId && work.mobs[targetId]
|
||||||
|
&& !work.mobs[targetId].dead && work.mobs[targetId].health > 0) {
|
||||||
|
cooldowns[ability.id] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
|
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
|
||||||
const levelMessage = work.progression.levelsGained > 0
|
const levelMessage = work.progression.levelsGained > 0
|
||||||
@@ -8716,6 +8852,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID
|
const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID
|
||||||
? Math.round(namedAbsorb.remainingDamage * redirectFraction)
|
? Math.round(namedAbsorb.remainingDamage * redirectFraction)
|
||||||
: 0;
|
: 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(
|
const damaged = Math.min(
|
||||||
authoritativeActor.health,
|
authoritativeActor.health,
|
||||||
Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)),
|
Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)),
|
||||||
@@ -8761,7 +8900,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
actors: { ...get().actors, [nextActor.id]: nextActor },
|
actors: { ...get().actors, [nextActor.id]: nextActor },
|
||||||
auras: consumeInnerFireCharges(
|
auras: consumeInnerFireCharges(
|
||||||
namedAbsorb.auras, member.id, combatResult.landed, enemyAttackKind(hit.attack),
|
aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack),
|
||||||
),
|
),
|
||||||
combatEvents: recordCombatEvent(get().combatEvents, {
|
combatEvents: recordCombatEvent(get().combatEvents, {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
|
|||||||
@@ -1130,7 +1130,21 @@ function executePartyAbility(
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
castStartAlreadyEmitted = false,
|
castStartAlreadyEmitted = false,
|
||||||
): PartyAbilityExecutionResult {
|
): 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)) {
|
if (ability.name === "Enraged Regeneration" && !combat.consumeActorEnrage(member.id, now)) {
|
||||||
return { member, effectiveAmount: 0 };
|
return { member, effectiveAmount: 0 };
|
||||||
}
|
}
|
||||||
@@ -1200,6 +1214,20 @@ function executePartyAbility(
|
|||||||
specializationId: member.specializationId, role: member.role, level: member.level,
|
specializationId: member.specializationId, role: member.role, level: member.level,
|
||||||
})["main-hand"]?.weaponType === "dagger",
|
})["main-hand"]?.weaponType === "dagger",
|
||||||
casterMaxHealth: member.maxHealth,
|
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(
|
||||||
|
combat.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
|
||||||
|
.flatMap((effect) => effect.tag ? [effect.tag] : []),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
for (const effect of castEffects) {
|
for (const effect of castEffects) {
|
||||||
if (ability.name === "Penance" && combat.mobs[targetId]
|
if (ability.name === "Penance" && combat.mobs[targetId]
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
type RomGeneratedAbilityLike,
|
type RomGeneratedAbilityLike,
|
||||||
type RomSourceComponent,
|
type RomSourceComponent,
|
||||||
} from "./romAbilitySemantics";
|
} from "./romAbilitySemantics";
|
||||||
|
import { overrideRomPriestKnightEffects } from "./romPriestKnightOverrides";
|
||||||
|
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
|
||||||
|
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
|
||||||
|
|
||||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
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 === 142) return magicDamageTaken(-percent(value));
|
||||||
if (type === 143) return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -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 === 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 === 149) return [{ kind: "healing", direction: "done", operation: "percent", value: percent(value) }];
|
||||||
if (type === 150) return [{ kind: "healing", direction: "done", operation: "flat", 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 sourcePoints = coefficientCalculation ? 0 : rawPower;
|
||||||
const pointsPerLevel = sourcePoints * Math.max(0, semantic.powerPerSkillLevel) / 100;
|
const pointsPerLevel = sourcePoints * Math.max(0, semantic.powerPerSkillLevel) / 100;
|
||||||
const basePoints = sourcePoints + pointsPerLevel;
|
const basePoints = sourcePoints + pointsPerLevel;
|
||||||
return {
|
const amount = {
|
||||||
kind: semantic.kind,
|
|
||||||
coefficient,
|
coefficient,
|
||||||
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
|
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
|
||||||
...(basePoints > 0 ? {
|
...(basePoints > 0 ? {
|
||||||
@@ -281,9 +283,16 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
sourceLevel: 1,
|
sourceLevel: 1,
|
||||||
...(coefficient > 0 ? { bonusPowerCoefficient: coefficient } : {}),
|
...(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") {
|
if (semantic.kind === "dot" || semantic.kind === "hot") {
|
||||||
@@ -292,6 +301,9 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
return {
|
return {
|
||||||
kind: semantic.kind,
|
kind: semantic.kind,
|
||||||
coefficient: 0,
|
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),
|
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,
|
pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
|
||||||
sourceLevel: 1,
|
sourceLevel: 1,
|
||||||
@@ -327,7 +339,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
if (semantic.kind === "aura") {
|
if (semantic.kind === "aura") {
|
||||||
const recipient = auraRecipient(row, semantic);
|
const recipient = auraRecipient(row, semantic);
|
||||||
const modifiers = auraModifiers(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 = {
|
const aura: AuraDefinition = {
|
||||||
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
|
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
|
||||||
name: `${row.name}: ${semantic.effect}`,
|
name: `${row.name}: ${semantic.effect}`,
|
||||||
@@ -450,6 +462,21 @@ function stateAuraEffect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
|
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) {
|
if (row.sourceSkillId === 491163) {
|
||||||
return [stateAuraEffect(row, "frost-arrow", "Frost Arrow", 600_000, {
|
return [stateAuraEffect(row, "frost-arrow", "Frost Arrow", 600_000, {
|
||||||
procs: [{
|
procs: [{
|
||||||
@@ -497,6 +524,10 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
|||||||
const specialized = specializedEffects(row);
|
const specialized = specializedEffects(row);
|
||||||
if (specialized) return specialized;
|
if (specialized) return specialized;
|
||||||
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
|
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 => (
|
const outputStrength = (effect: AbilityEffect): number => (
|
||||||
"coefficient" in effect ? effect.coefficient : 0
|
"coefficient" in effect ? effect.coefficient : 0
|
||||||
) + ("coefficientPerLevel" in effect ? effect.coefficientPerLevel ?? 0 : 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 {
|
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];
|
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 {
|
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
|
||||||
@@ -685,7 +725,10 @@ function ability(row: GeneratedSkill): AbilityDefinition {
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
target: row.target,
|
target: row.target,
|
||||||
range: { min: 0, max: row.range },
|
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",
|
castMode: row.castTimeMs > 0 ? "cast" : "instant",
|
||||||
castTimeMs: row.castTimeMs,
|
castTimeMs: row.castTimeMs,
|
||||||
cooldownMs: row.cooldownMs,
|
cooldownMs: row.cooldownMs,
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export type RomAbilitySemantic =
|
|||||||
})
|
})
|
||||||
| (RomSemanticBase & {
|
| (RomSemanticBase & {
|
||||||
readonly kind: "dot" | "hot";
|
readonly kind: "dot" | "hot";
|
||||||
|
readonly school: string;
|
||||||
readonly resource: string;
|
readonly resource: string;
|
||||||
readonly intervalSeconds: number;
|
readonly intervalSeconds: number;
|
||||||
readonly durationSeconds: number;
|
readonly durationSeconds: number;
|
||||||
@@ -530,6 +531,7 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
|
|||||||
const healing = target !== "hostile" && dot.base > 0;
|
const healing = target !== "hostile" && dot.base > 0;
|
||||||
addIfMissing(rows, {
|
addIfMissing(rows, {
|
||||||
kind: healing ? "hot" : "dot",
|
kind: healing ? "hot" : "dot",
|
||||||
|
school: schoolFor(component),
|
||||||
resource,
|
resource,
|
||||||
intervalSeconds: dot.timeSeconds,
|
intervalSeconds: dot.timeSeconds,
|
||||||
durationSeconds: positiveDuration(component),
|
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")) {
|
if (/\bbleed|damage over time|damage every|damage each (?:second|time)/.test(lower) && !hasKind(rows, "dot")) {
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "dot",
|
kind: "dot",
|
||||||
|
school: "tooltip-defined",
|
||||||
resource: "health",
|
resource: "health",
|
||||||
intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0,
|
intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
@@ -769,6 +772,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
|
|||||||
if (/restores? .*hp.*(?:every|each) /.test(lower) && !hasKind(rows, "hot")) {
|
if (/restores? .*hp.*(?:every|each) /.test(lower) && !hasKind(rows, "hot")) {
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "hot",
|
kind: "hot",
|
||||||
|
school: "healing",
|
||||||
resource: "health",
|
resource: "health",
|
||||||
intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0,
|
intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||||
|
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
|
||||||
|
import { romMageWarlockCastEffects } from "./romMageWarlockOverrides";
|
||||||
|
import { romScoutRogueCastEffects } from "./romScoutRogueOverrides";
|
||||||
|
|
||||||
const ROGUE_BLEED_ABILITIES = new Set([
|
const ROGUE_BLEED_ABILITIES = new Set([
|
||||||
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
|
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
|
||||||
@@ -13,6 +16,8 @@ export interface RomCastMechanics {
|
|||||||
readonly charged: boolean;
|
readonly charged: boolean;
|
||||||
readonly mainHandDagger: boolean;
|
readonly mainHandDagger: boolean;
|
||||||
readonly casterMaxHealth: number;
|
readonly casterMaxHealth: number;
|
||||||
|
readonly passiveTags?: readonly string[];
|
||||||
|
readonly targetTags?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Source periodic values for self-sacrifice toggle skills are percentages of HP. */
|
/** 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. */
|
/** Select source-conditional RoM payloads once for both player and companion casts. */
|
||||||
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
|
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
|
||||||
if (ability.source !== "runewaker") return ability.effects;
|
if (ability.source !== "runewaker") return ability.effects;
|
||||||
const effects = ability.effects;
|
const priestKnightEffects = romPriestKnightCastEffects(
|
||||||
|
ability, ability.effects, context.passiveTags ?? [], context.casterMaxHealth,
|
||||||
|
);
|
||||||
|
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 damage = effects.filter((effect) => effect.kind === "damage");
|
||||||
const seals = Math.max(0, Math.min(4, context.sealStacks));
|
const seals = Math.max(0, Math.min(4, context.sealStacks));
|
||||||
switch (ability.dbcSpellId) {
|
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 490148: // A new Holy Strike increases the target's Holy Seal bleed stack.
|
||||||
case 490150: { // Charge adds two stacks.
|
case 490150: { // Charge adds two stacks.
|
||||||
const gained = ability.dbcSpellId === 490148 ? 1 : 2;
|
const gained = ability.dbcSpellId === 490148 ? 1 : 2;
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { partyRolesForClass } from "./partyRoles";
|
|||||||
import { createConfiguredPartyMember, createDungeonParty, createSpectatorDungeonParty, usePartyStore } from "./partyStore";
|
import { createConfiguredPartyMember, createDungeonParty, createSpectatorDungeonParty, usePartyStore } from "./partyStore";
|
||||||
import { ROM_ORDINARY_ABILITIES_BY_CLASS } from "./romAbilityCatalog";
|
import { ROM_ORDINARY_ABILITIES_BY_CLASS } from "./romAbilityCatalog";
|
||||||
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
||||||
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||||
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
||||||
import { romGearProfile } from "./specializationCatalog";
|
import { romGearProfile } from "./specializationCatalog";
|
||||||
import { deriveCharacterStats } from "./wotlkStats";
|
import { baseAttributesFor, baseResourcesFor, deriveCharacterStats, healthFromTotalStamina, manaFromTotalIntellect, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||||
|
|
||||||
const CASES = [
|
const CASES = [
|
||||||
{ classId: "rom-priest", role: "healer", layout: "weapon-shield" },
|
{ classId: "rom-priest", role: "healer", layout: "weapon-shield" },
|
||||||
@@ -22,6 +23,34 @@ const CASES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe("RoM five class validation scope", () => {
|
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) => {
|
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) {
|
for (const identity of ROM_FIVE_IDENTITIES) {
|
||||||
const primary = romNativeClassBaseStats(identity.classId, level);
|
const primary = romNativeClassBaseStats(identity.classId, level);
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// Industry Fire's player tooltip gives no magnitude for its extra fire hit.
|
||||||
|
// One Soul Pain tick is a conservative source-derived starting value.
|
||||||
|
const INDUSTRY_FIRE_DOT_MULTIPLIER = 1.1;
|
||||||
|
|
||||||
|
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 === 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 scaleDamage(effect: AbilityEffect, factor: number): AbilityEffect {
|
||||||
|
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
|
||||||
|
return {
|
||||||
|
...effect,
|
||||||
|
coefficient: effect.coefficient * factor,
|
||||||
|
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
|
||||||
|
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
|
||||||
|
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
|
||||||
|
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
const transformed = effects.map((effect) => effect.kind === "dot"
|
||||||
|
? { ...scaleDamage(effect, INDUSTRY_FIRE_DOT_MULTIPLIER), school: "fire" as const }
|
||||||
|
: effect);
|
||||||
|
const dot = transformed.find((effect) => effect.kind === "dot");
|
||||||
|
if (dot?.kind !== "dot") return transformed;
|
||||||
|
return [
|
||||||
|
...transformed,
|
||||||
|
{
|
||||||
|
kind: "damage",
|
||||||
|
school: "fire",
|
||||||
|
coefficient: dot.coefficient,
|
||||||
|
basePoints: dot.basePoints ?? 0,
|
||||||
|
pointsPerLevel: dot.pointsPerLevel ?? 0,
|
||||||
|
sourceLevel: dot.sourceLevel ?? 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = effects;
|
||||||
|
if (passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.lightningDisplay)) {
|
||||||
|
result = result.map((effect) => {
|
||||||
|
if (effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("rom-charged")
|
||||||
|
&& ability.dbcSpellId === 491169) {
|
||||||
|
return { ...effect, aura: { ...effect.aura, durationMs: 18_000 } };
|
||||||
|
}
|
||||||
|
return (effect.kind === "damage" || effect.kind === "dot") && effect.school === "nature"
|
||||||
|
? scaleDamage(effect, 1.01)
|
||||||
|
: effect;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementsOfPride)) {
|
||||||
|
if (ability.dbcSpellId === 491150) result = [...result, elementsOfPrideAura("nature", 0.02)];
|
||||||
|
if (ability.dbcSpellId === 491170) result = [...result, elementsOfPrideAura("fire", 0.01)];
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|| (ability.dbcSpellId !== 491170 && ability.dbcSpellId !== 499812)) 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,
|
||||||
|
}];
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilitiesForCharacter, abilityById, learnedAbilityForBuild } from "./abilityCatalog";
|
||||||
|
import { abilityDamageSchool, useCombatStore } from "./combatStore";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
||||||
|
import {
|
||||||
|
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
|
||||||
|
romMageWarlockCastEffects,
|
||||||
|
romMageWarlockCriticalEffects,
|
||||||
|
} 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 }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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("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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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("converts Soul Pain to fire with an extra hit only while Industry Fire is learned", () => {
|
||||||
|
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.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "dot", school: "fire" }),
|
||||||
|
expect.objectContaining({ kind: "damage", school: "fire" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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", () => {
|
||||||
|
const explosion = abilityById("rom-mage-primary-54")!;
|
||||||
|
expect(romMageWarlockCriticalEffects(explosion, new Set())).toEqual([]);
|
||||||
|
expect(romMageWarlockCriticalEffects(explosion,
|
||||||
|
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 }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||||
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
||||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
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 = {
|
const RACE_MATRIX = {
|
||||||
"rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"],
|
"rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"],
|
||||||
@@ -117,9 +117,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(
|
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(
|
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
|
||||||
98 * MANA_POOL_MULTIPLIER,
|
98 * MANA_POOL_MULTIPLIER,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
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,
|
||||||
|
): 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(passiveTags, "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);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return effects;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
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 { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||||
|
import { 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(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", "rom-target-undead-demon"], 1_000);
|
||||||
|
expect(rootDuration(ordinary)).toBe(rootDuration(chains.effects)! / 2);
|
||||||
|
expect(rootDuration(undead)).toBe(rootDuration(chains.effects));
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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("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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
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",
|
||||||
|
customProc(499476, "hit", "rom-toxic-splash"));
|
||||||
|
case 499480:
|
||||||
|
return taggedPassive(499480, "Poison Battle Technique", "rom-poison-battle-technique",
|
||||||
|
customProc(499480, "hit", "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) {
|
||||||
|
const masteryFactor = passives.has("rom-master-reflected-shot") ? 1.2 : 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 === 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 absent from the
|
||||||
|
* playable source catalog; those unresolved actions deliberately return null.
|
||||||
|
*/
|
||||||
|
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 (!casterPoisonous || ![490457, 490424].includes(triggeringSourceSkillId)) return null;
|
||||||
|
return {
|
||||||
|
kind: "dot",
|
||||||
|
recipient: "target",
|
||||||
|
effect: {
|
||||||
|
kind: "dot",
|
||||||
|
coefficient: 0,
|
||||||
|
basePoints: 16,
|
||||||
|
pointsPerLevel: 8,
|
||||||
|
sourceLevel: 1,
|
||||||
|
ticks: 5,
|
||||||
|
intervalMs: 2_000,
|
||||||
|
tag: "rom-poisonous-spit",
|
||||||
|
},
|
||||||
|
provenance: "hybrid-estimate", // 501690 poison scale is a proxy; Poison Powder payload is not in this catalog.
|
||||||
|
};
|
||||||
|
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-toxic-splash": // Requires absent replacement skill 499477 and triggered payload 493296.
|
||||||
|
case "rom-poison-battle-technique": // Requires absent replacement skill 499481.
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilitiesForCharacter, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { romScoutRogueCastEffects, romScoutRogueProcActionSpec } 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 applies a labeled 20% hybrid damage bonus", () => {
|
||||||
|
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 * 1.2);
|
||||||
|
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 only Reflected or Combo Shot while carrying Poison Powder", () => {
|
||||||
|
for (const sourceId of [490457, 490424]) {
|
||||||
|
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toBeNull();
|
||||||
|
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, true)).toMatchObject({
|
||||||
|
kind: "dot", recipient: "target", provenance: "hybrid-estimate",
|
||||||
|
effect: { kind: "dot", ticks: 5, intervalMs: 2_000 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const poison = skill(rogueWarrior, "rom-rogue-primary-6");
|
||||||
|
const state = poison.effects
|
||||||
|
.filter((effect): effect is Extract<AbilityDefinition["effects"][number], { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
||||||
|
.find((effect) => effect.aura.utilityTags?.includes("rom-poisonous") === true);
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(useCombatStore.getState().applyActorAura(
|
||||||
|
PLAYER_AGGRO_ID, PLAYER_AGGRO_ID, poison, state!, 1_000,
|
||||||
|
)).toBe(true);
|
||||||
|
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("keeps absent 499477/499481 replacement-skill procs explicitly unresolved", () => {
|
||||||
|
expect(romScoutRogueProcActionSpec("rom-toxic-splash", 499477, true)).toBeNull();
|
||||||
|
expect(romScoutRogueProcActionSpec("rom-poison-battle-technique", 499481, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+41
-4
@@ -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");
|
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
|
* Automatic racial auras from Ascension's effective Spell.dbc that can change
|
||||||
* a character's starting attributes or resource pools.
|
* a character's starting attributes or resource pools.
|
||||||
@@ -259,15 +295,16 @@ export function deriveCharacterStats(
|
|||||||
secondaryClassId?: ClassId | null,
|
secondaryClassId?: ClassId | null,
|
||||||
): CharacterStats {
|
): CharacterStats {
|
||||||
const safe = safeLevel(level);
|
const safe = safeLevel(level);
|
||||||
const romBase = romDualClassBaseStats(classId, secondaryClassId, safe);
|
const hybridBase = romHybridBase(classId, secondaryClassId, safe);
|
||||||
const base = romBase ? {
|
const romBase = hybridBase ? null : romDualClassBaseStats(classId, secondaryClassId, safe);
|
||||||
|
const base = hybridBase?.attributes ?? (romBase ? {
|
||||||
strength: romBase.strength,
|
strength: romBase.strength,
|
||||||
agility: romBase.agility,
|
agility: romBase.agility,
|
||||||
stamina: romBase.stamina,
|
stamina: romBase.stamina,
|
||||||
intellect: romBase.intellect,
|
intellect: romBase.intellect,
|
||||||
spirit: romBase.wisdom,
|
spirit: romBase.wisdom,
|
||||||
} : baseAttributesFor(classId, safe, raceId);
|
} : baseAttributesFor(classId, safe, raceId));
|
||||||
const resources = romBase ? { health: romBase.health, mana: romBase.mana } : baseResourcesFor(classId, safe);
|
const resources = hybridBase?.resources ?? (romBase ? { health: romBase.health, mana: romBase.mana } : baseResourcesFor(classId, safe));
|
||||||
const racial = RACIAL_STAT_PASSIVES[raceId];
|
const racial = RACIAL_STAT_PASSIVES[raceId];
|
||||||
const strength = percentAdjusted(base.strength + gear.strength, racial?.strength, modifiers.strength);
|
const strength = percentAdjusted(base.strength + gear.strength, racial?.strength, modifiers.strength);
|
||||||
const agility = percentAdjusted(base.agility + gear.agility, racial?.agility, modifiers.agility);
|
const agility = percentAdjusted(base.agility + gear.agility, racial?.agility, modifiers.agility);
|
||||||
|
|||||||
Reference in new issue
Block a user