feat(bar): implement Barbarian Combat Masteries (IDs 127-129, 134-136, 141, 145, 148, 153)
- Authoritative 1.13c parity for 10 Barbarian Combat Masteries - Exact linear AR, Damage, Stamina, Defense progression - Exact diminishing returns Critical Strike (dm56), Speed, Resist All (dm12) - Sequential roll for Critical Strike & Deadly Strike - Adversarial stress tests (slvl 0, 1, 10, 20, 99) - Enforces BATCH1_SKILLS length invariant = strictly 38 - Fixes #465
This commit is contained in:
parent
bd4920f3aa
commit
0c3388b18b
|
|
@ -0,0 +1,474 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Barbarian Combat Masteries Module
|
||||
*
|
||||
* Dedicated pure calculation and kinematics formulas for the 10 Barbarian Combat Masteries:
|
||||
* - Skill 127: Sword Mastery (ReqLevel 1)
|
||||
* - Skill 128: Axe Mastery (ReqLevel 1)
|
||||
* - Skill 129: Mace Mastery (ReqLevel 1)
|
||||
* - Skill 134: Polearm Mastery (ReqLevel 6)
|
||||
* - Skill 135: Throwing Mastery (ReqLevel 6)
|
||||
* - Skill 136: Spear Mastery (ReqLevel 6)
|
||||
* - Skill 141: Increased Stamina (ReqLevel 12)
|
||||
* - Skill 145: Iron Skin (ReqLevel 18)
|
||||
* - Skill 148: Increased Speed (ReqLevel 24)
|
||||
* - Skill 153: Natural Resistance (ReqLevel 30)
|
||||
*
|
||||
* 1.13c Ground Truth:
|
||||
* - Skills.txt (Patch_D2.mpq)
|
||||
* - Exact linear progressions ln12 (AR), ln34 (Dmg), ln12 (Stamina, Defense)
|
||||
* - Diminishing returns dm56 (Crit), dm12 (Speed, Resist All)
|
||||
* - Sequential roll for Critical Strike & Deadly Strike (no double-crit / 4x dmg)
|
||||
* - Zero runtime MPQ reading
|
||||
*/
|
||||
|
||||
import { computeDiminishingReturns } from '../engine/calc-ast.ts'
|
||||
|
||||
// --- Interfaces for Skill Calculations ---
|
||||
|
||||
export interface WeaponMasteryStats {
|
||||
readonly skillId: number
|
||||
readonly skillName: string
|
||||
readonly weaponCategory: string
|
||||
readonly attackRatingBonusPct: number
|
||||
readonly damageBonusPct: number
|
||||
readonly criticalStrikeChancePct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface IncreasedStaminaStats {
|
||||
readonly staminaBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface IronSkinStats {
|
||||
readonly defenseBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface IncreasedSpeedStats {
|
||||
readonly runWalkSpeedBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface NaturalResistanceStats {
|
||||
readonly resistAllPct: number
|
||||
readonly fireResistPct: number
|
||||
readonly coldResistPct: number
|
||||
readonly lightningResistPct: number
|
||||
readonly poisonResistPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
// --- Calculation Implementations ---
|
||||
|
||||
/**
|
||||
* Skill 127: Sword Mastery
|
||||
* AR: 28 + 8 * (slvl - 1)%
|
||||
* Dmg: 28 + 5 * (slvl - 1)%
|
||||
* Crit: dm(0, 35, slvl)%
|
||||
*/
|
||||
export function calculateSwordMasteryStats(slvl: number): WeaponMasteryStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
skillId: 127,
|
||||
skillName: 'Sword Mastery',
|
||||
weaponCategory: 'sword',
|
||||
attackRatingBonusPct: 0,
|
||||
damageBonusPct: 0,
|
||||
criticalStrikeChancePct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skillId: 127,
|
||||
skillName: 'Sword Mastery',
|
||||
weaponCategory: 'sword',
|
||||
attackRatingBonusPct: 28 + 8 * (lvl - 1),
|
||||
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 128: Axe Mastery
|
||||
* AR: 28 + 8 * (slvl - 1)%
|
||||
* Dmg: 28 + 5 * (slvl - 1)%
|
||||
* Crit: dm(0, 35, slvl)%
|
||||
*/
|
||||
export function calculateAxeMasteryStats(slvl: number): WeaponMasteryStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
skillId: 128,
|
||||
skillName: 'Axe Mastery',
|
||||
weaponCategory: 'axe',
|
||||
attackRatingBonusPct: 0,
|
||||
damageBonusPct: 0,
|
||||
criticalStrikeChancePct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skillId: 128,
|
||||
skillName: 'Axe Mastery',
|
||||
weaponCategory: 'axe',
|
||||
attackRatingBonusPct: 28 + 8 * (lvl - 1),
|
||||
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 129: Mace Mastery
|
||||
* AR: 28 + 8 * (slvl - 1)%
|
||||
* Dmg: 28 + 5 * (slvl - 1)%
|
||||
* Crit: dm(0, 35, slvl)%
|
||||
* Applies to: Maces, Clubs, Hammers, Flails, Scepters
|
||||
*/
|
||||
export function calculateMaceMasteryStats(slvl: number): WeaponMasteryStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
skillId: 129,
|
||||
skillName: 'Mace Mastery',
|
||||
weaponCategory: 'mace',
|
||||
attackRatingBonusPct: 0,
|
||||
damageBonusPct: 0,
|
||||
criticalStrikeChancePct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skillId: 129,
|
||||
skillName: 'Mace Mastery',
|
||||
weaponCategory: 'mace',
|
||||
attackRatingBonusPct: 28 + 8 * (lvl - 1),
|
||||
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 134: Polearm Mastery
|
||||
* AR: 30 + 8 * (slvl - 1)%
|
||||
* Dmg: 28 + 5 * (slvl - 1)%
|
||||
* Crit: dm(0, 35, slvl)%
|
||||
*/
|
||||
export function calculatePolearmMasteryStats(slvl: number): WeaponMasteryStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
skillId: 134,
|
||||
skillName: 'Polearm Mastery',
|
||||
weaponCategory: 'polearm',
|
||||
attackRatingBonusPct: 0,
|
||||
damageBonusPct: 0,
|
||||
criticalStrikeChancePct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skillId: 134,
|
||||
skillName: 'Polearm Mastery',
|
||||
weaponCategory: 'polearm',
|
||||
attackRatingBonusPct: 30 + 8 * (lvl - 1),
|
||||
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 135: Throwing Mastery
|
||||
* AR: 30 + 8 * (slvl - 1)%
|
||||
* Dmg: 28 + 5 * (slvl - 1)%
|
||||
* Crit: dm(0, 35, slvl)%
|
||||
* Applies to: Throwing Knives, Throwing Axes, Javelins (when thrown)
|
||||
*/
|
||||
export function calculateThrowingMasteryStats(slvl: number): WeaponMasteryStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
skillId: 135,
|
||||
skillName: 'Throwing Mastery',
|
||||
weaponCategory: 'throwing',
|
||||
attackRatingBonusPct: 0,
|
||||
damageBonusPct: 0,
|
||||
criticalStrikeChancePct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skillId: 135,
|
||||
skillName: 'Throwing Mastery',
|
||||
weaponCategory: 'throwing',
|
||||
attackRatingBonusPct: 30 + 8 * (lvl - 1),
|
||||
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 136: Spear Mastery
|
||||
* AR: 30 + 8 * (slvl - 1)%
|
||||
* Dmg: 28 + 5 * (slvl - 1)%
|
||||
* Crit: dm(0, 35, slvl)%
|
||||
* Applies to: Spears and Javelins (in melee)
|
||||
*/
|
||||
export function calculateSpearMasteryStats(slvl: number): WeaponMasteryStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
skillId: 136,
|
||||
skillName: 'Spear Mastery',
|
||||
weaponCategory: 'spear',
|
||||
attackRatingBonusPct: 0,
|
||||
damageBonusPct: 0,
|
||||
criticalStrikeChancePct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skillId: 136,
|
||||
skillName: 'Spear Mastery',
|
||||
weaponCategory: 'spear',
|
||||
attackRatingBonusPct: 30 + 8 * (lvl - 1),
|
||||
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 141: Increased Stamina
|
||||
* Stamina %: 30 + 15 * (slvl - 1)%
|
||||
*/
|
||||
export function calculateIncreasedStaminaStats(slvl: number): IncreasedStaminaStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
staminaBonusPct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
staminaBonusPct: 30 + 15 * (lvl - 1),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 145: Iron Skin
|
||||
* Defense %: 30 + 10 * (slvl - 1)%
|
||||
*/
|
||||
export function calculateIronSkinStats(slvl: number): IronSkinStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
defenseBonusPct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defenseBonusPct: 30 + 10 * (lvl - 1),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 148: Increased Speed
|
||||
* Velocity %: dm(7, 50, slvl)%
|
||||
*/
|
||||
export function calculateIncreasedSpeedStats(slvl: number): IncreasedSpeedStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
runWalkSpeedBonusPct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runWalkSpeedBonusPct: computeDiminishingReturns(7, 50, lvl),
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 153: Natural Resistance
|
||||
* Elemental Resistances (Fire, Cold, Lightning, Poison): dm(0, 80, slvl)%
|
||||
*/
|
||||
export function calculateNaturalResistanceStats(slvl: number): NaturalResistanceStats {
|
||||
const lvl = Math.max(0, Math.floor(slvl))
|
||||
if (lvl === 0) {
|
||||
return {
|
||||
resistAllPct: 0,
|
||||
fireResistPct: 0,
|
||||
coldResistPct: 0,
|
||||
lightningResistPct: 0,
|
||||
poisonResistPct: 0,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const res = computeDiminishingReturns(0, 80, lvl)
|
||||
return {
|
||||
resistAllPct: res,
|
||||
fireResistPct: res,
|
||||
coldResistPct: res,
|
||||
lightningResistPct: res,
|
||||
poisonResistPct: res,
|
||||
manaCost: 0,
|
||||
manaCost256: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Weapon & Crit Evaluation Helpers ---
|
||||
|
||||
/**
|
||||
* Checks if a given weapon type string matches the target Barbarian Mastery skill.
|
||||
*/
|
||||
export function isWeaponMatchingMastery(weaponType: string, skillId: number): boolean {
|
||||
const norm = weaponType.toLowerCase().trim()
|
||||
switch (skillId) {
|
||||
case 127: // Sword Mastery
|
||||
return (
|
||||
norm.includes('sword') ||
|
||||
norm.includes('blade') ||
|
||||
norm.includes('falchion') ||
|
||||
norm.includes('scimitar') ||
|
||||
norm.includes('saber') ||
|
||||
norm.includes('claymore') ||
|
||||
norm.includes('zweihander')
|
||||
)
|
||||
case 128: // Axe Mastery
|
||||
return norm.includes('axe') || norm.includes('cleaver') || norm.includes('hatchet')
|
||||
case 129: // Mace Mastery
|
||||
return (
|
||||
norm.includes('mace') ||
|
||||
norm.includes('club') ||
|
||||
norm.includes('hammer') ||
|
||||
norm.includes('flail') ||
|
||||
norm.includes('scepter') ||
|
||||
norm.includes('morning star')
|
||||
)
|
||||
case 134: // Polearm Mastery
|
||||
return (
|
||||
norm.includes('polearm') ||
|
||||
norm.includes('scythe') ||
|
||||
norm.includes('voulge') ||
|
||||
norm.includes('thresher') ||
|
||||
norm.includes('halberd') ||
|
||||
norm.includes('bardiche') ||
|
||||
norm.includes('lochaber') ||
|
||||
norm.includes('bill')
|
||||
)
|
||||
case 135: // Throwing Mastery
|
||||
return (
|
||||
norm.includes('throw') ||
|
||||
norm.includes('javelin') ||
|
||||
norm.includes('dart') ||
|
||||
norm.includes('pilum') ||
|
||||
norm.includes('harpoon')
|
||||
)
|
||||
case 136: // Spear Mastery
|
||||
return (
|
||||
norm.includes('spear') ||
|
||||
norm.includes('pike') ||
|
||||
norm.includes('lance') ||
|
||||
norm.includes('trident') ||
|
||||
norm.includes('javelin')
|
||||
)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates effective double-damage proc chance combining Mastery Critical Strike and Deadly Strike.
|
||||
*
|
||||
* In Diablo II 1.13c:
|
||||
* - Critical Strike (Mastery/Amazon) rolls first.
|
||||
* - If CS does not proc, Deadly Strike (items) rolls second.
|
||||
* - They NEVER stack to quadruple damage; damage is doubled at most once:
|
||||
* P(double damage) = CS% + DS% * (1 - CS% / 100)
|
||||
*/
|
||||
export function calculateEffectiveCriticalStrike(
|
||||
masteryCritPct: number,
|
||||
deadlyStrikePct: number
|
||||
): number {
|
||||
const cs = Math.max(0, Math.min(100, masteryCritPct)) / 100
|
||||
const ds = Math.max(0, Math.min(100, deadlyStrikePct)) / 100
|
||||
const combined = cs + ds * (1 - cs)
|
||||
return Math.round(combined * 1000) / 10 // rounded to 1 decimal place %
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified dispatch evaluation for Barbarian Combat Masteries.
|
||||
*/
|
||||
export function evaluateBarbarianMastery(
|
||||
skillId: number,
|
||||
slvl: number
|
||||
): WeaponMasteryStats | IncreasedStaminaStats | IronSkinStats | IncreasedSpeedStats | NaturalResistanceStats | null {
|
||||
switch (skillId) {
|
||||
case 127:
|
||||
return calculateSwordMasteryStats(slvl)
|
||||
case 128:
|
||||
return calculateAxeMasteryStats(slvl)
|
||||
case 129:
|
||||
return calculateMaceMasteryStats(slvl)
|
||||
case 134:
|
||||
return calculatePolearmMasteryStats(slvl)
|
||||
case 135:
|
||||
return calculateThrowingMasteryStats(slvl)
|
||||
case 136:
|
||||
return calculateSpearMasteryStats(slvl)
|
||||
case 141:
|
||||
return calculateIncreasedStaminaStats(slvl)
|
||||
case 145:
|
||||
return calculateIronSkinStats(slvl)
|
||||
case 148:
|
||||
return calculateIncreasedSpeedStats(slvl)
|
||||
case 153:
|
||||
return calculateNaturalResistanceStats(slvl)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Milestone M18
|
||||
* Barbarian Combat Masteries Adversarial Stress & Parity Suite
|
||||
*
|
||||
* Ground Truth:
|
||||
* - Skills.txt (Patch_D2.mpq)
|
||||
* - Exact linear progressions ln12 (AR), ln34 (Dmg), ln12 (Stamina, Defense)
|
||||
* - Diminishing returns dm56 (Crit), dm12 (Speed, Resist All)
|
||||
* - Sequential roll for Critical Strike & Deadly Strike
|
||||
* - Strict BATCH1_SKILLS invariant: length === 38
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateSwordMasteryStats,
|
||||
calculateAxeMasteryStats,
|
||||
calculateMaceMasteryStats,
|
||||
calculatePolearmMasteryStats,
|
||||
calculateThrowingMasteryStats,
|
||||
calculateSpearMasteryStats,
|
||||
calculateIncreasedStaminaStats,
|
||||
calculateIronSkinStats,
|
||||
calculateIncreasedSpeedStats,
|
||||
calculateNaturalResistanceStats,
|
||||
isWeaponMatchingMastery,
|
||||
calculateEffectiveCriticalStrike,
|
||||
evaluateBarbarianMastery,
|
||||
} from '../../../src/game/skills/barbarian-masteries.ts'
|
||||
import { BATCH1_SKILLS } from '../../../src/game/skills.ts'
|
||||
|
||||
describe('Milestone M18 — Barbarian Combat Masteries Parity & Adversarial Stress Suite', () => {
|
||||
describe('Dimension 1: Exact 1.13c Numerical Scaling & Formulas', () => {
|
||||
it('1.1 Sword Mastery (127): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateSwordMasteryStats(1)
|
||||
expect(s1.attackRatingBonusPct).toBe(28)
|
||||
expect(s1.damageBonusPct).toBe(28)
|
||||
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||
expect(s1.manaCost).toBe(0)
|
||||
|
||||
const s10 = calculateSwordMasteryStats(10)
|
||||
expect(s10.attackRatingBonusPct).toBe(100) // 28 + 8 * 9
|
||||
expect(s10.damageBonusPct).toBe(73) // 28 + 5 * 9
|
||||
expect(s10.criticalStrikeChancePct).toBe(24) // 110*10*35 / (100*16) = 24.0625 -> 24
|
||||
|
||||
const s20 = calculateSwordMasteryStats(20)
|
||||
expect(s20.attackRatingBonusPct).toBe(180) // 28 + 8 * 19
|
||||
expect(s20.damageBonusPct).toBe(123) // 28 + 5 * 19
|
||||
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||
})
|
||||
|
||||
it('1.2 Axe Mastery (128): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateAxeMasteryStats(1)
|
||||
expect(s1.attackRatingBonusPct).toBe(28)
|
||||
expect(s1.damageBonusPct).toBe(28)
|
||||
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||
|
||||
const s10 = calculateAxeMasteryStats(10)
|
||||
expect(s10.attackRatingBonusPct).toBe(100)
|
||||
expect(s10.damageBonusPct).toBe(73)
|
||||
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||
|
||||
const s20 = calculateAxeMasteryStats(20)
|
||||
expect(s20.attackRatingBonusPct).toBe(180)
|
||||
expect(s20.damageBonusPct).toBe(123)
|
||||
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||
})
|
||||
|
||||
it('1.3 Mace Mastery (129): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateMaceMasteryStats(1)
|
||||
expect(s1.attackRatingBonusPct).toBe(28)
|
||||
expect(s1.damageBonusPct).toBe(28)
|
||||
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||
|
||||
const s10 = calculateMaceMasteryStats(10)
|
||||
expect(s10.attackRatingBonusPct).toBe(100)
|
||||
expect(s10.damageBonusPct).toBe(73)
|
||||
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||
|
||||
const s20 = calculateMaceMasteryStats(20)
|
||||
expect(s20.attackRatingBonusPct).toBe(180)
|
||||
expect(s20.damageBonusPct).toBe(123)
|
||||
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||
})
|
||||
|
||||
it('1.4 Polearm Mastery (134): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||
const s1 = calculatePolearmMasteryStats(1)
|
||||
expect(s1.attackRatingBonusPct).toBe(30) // Param1 = 30
|
||||
expect(s1.damageBonusPct).toBe(28)
|
||||
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||
|
||||
const s10 = calculatePolearmMasteryStats(10)
|
||||
expect(s10.attackRatingBonusPct).toBe(102) // 30 + 8 * 9
|
||||
expect(s10.damageBonusPct).toBe(73)
|
||||
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||
|
||||
const s20 = calculatePolearmMasteryStats(20)
|
||||
expect(s20.attackRatingBonusPct).toBe(182) // 30 + 8 * 19
|
||||
expect(s20.damageBonusPct).toBe(123)
|
||||
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||
})
|
||||
|
||||
it('1.5 Throwing Mastery (135): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateThrowingMasteryStats(1)
|
||||
expect(s1.attackRatingBonusPct).toBe(30)
|
||||
expect(s1.damageBonusPct).toBe(28)
|
||||
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||
|
||||
const s10 = calculateThrowingMasteryStats(10)
|
||||
expect(s10.attackRatingBonusPct).toBe(102)
|
||||
expect(s10.damageBonusPct).toBe(73)
|
||||
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||
|
||||
const s20 = calculateThrowingMasteryStats(20)
|
||||
expect(s20.attackRatingBonusPct).toBe(182)
|
||||
expect(s20.damageBonusPct).toBe(123)
|
||||
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||
})
|
||||
|
||||
it('1.6 Spear Mastery (136): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateSpearMasteryStats(1)
|
||||
expect(s1.attackRatingBonusPct).toBe(30)
|
||||
expect(s1.damageBonusPct).toBe(28)
|
||||
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||
|
||||
const s10 = calculateSpearMasteryStats(10)
|
||||
expect(s10.attackRatingBonusPct).toBe(102)
|
||||
expect(s10.damageBonusPct).toBe(73)
|
||||
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||
|
||||
const s20 = calculateSpearMasteryStats(20)
|
||||
expect(s20.attackRatingBonusPct).toBe(182)
|
||||
expect(s20.damageBonusPct).toBe(123)
|
||||
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||
})
|
||||
|
||||
it('1.7 Increased Stamina (141): Evaluates stamina % bonus at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateIncreasedStaminaStats(1)
|
||||
expect(s1.staminaBonusPct).toBe(30)
|
||||
expect(s1.manaCost).toBe(0)
|
||||
|
||||
const s10 = calculateIncreasedStaminaStats(10)
|
||||
expect(s10.staminaBonusPct).toBe(165) // 30 + 15 * 9
|
||||
|
||||
const s20 = calculateIncreasedStaminaStats(20)
|
||||
expect(s20.staminaBonusPct).toBe(315) // 30 + 15 * 19
|
||||
})
|
||||
|
||||
it('1.8 Iron Skin (145): Evaluates defense % bonus at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateIronSkinStats(1)
|
||||
expect(s1.defenseBonusPct).toBe(30)
|
||||
expect(s1.manaCost).toBe(0)
|
||||
|
||||
const s10 = calculateIronSkinStats(10)
|
||||
expect(s10.defenseBonusPct).toBe(120) // 30 + 10 * 9
|
||||
|
||||
const s20 = calculateIronSkinStats(20)
|
||||
expect(s20.defenseBonusPct).toBe(220) // 30 + 10 * 19
|
||||
})
|
||||
|
||||
it('1.9 Increased Speed (148): Evaluates run/walk velocity % at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateIncreasedSpeedStats(1)
|
||||
expect(s1.runWalkSpeedBonusPct).toBe(13) // dm(7, 50, 1) = 7 + floor(43 * 15 / 100) = 13
|
||||
expect(s1.manaCost).toBe(0)
|
||||
|
||||
const s10 = calculateIncreasedSpeedStats(10)
|
||||
expect(s10.runWalkSpeedBonusPct).toBe(36) // dm(7, 50, 10) = 7 + floor(43 * 68 / 100) = 36
|
||||
|
||||
const s20 = calculateIncreasedSpeedStats(20)
|
||||
expect(s20.runWalkSpeedBonusPct).toBe(43) // dm(7, 50, 20) = 7 + floor(43 * 84 / 100) = 43
|
||||
})
|
||||
|
||||
it('1.10 Natural Resistance (153): Evaluates resist all % at slvl 1, 10, 20', () => {
|
||||
const s1 = calculateNaturalResistanceStats(1)
|
||||
expect(s1.resistAllPct).toBe(12) // dm(0, 80, 1) = floor(80 * 15 / 100) = 12
|
||||
expect(s1.fireResistPct).toBe(12)
|
||||
expect(s1.coldResistPct).toBe(12)
|
||||
expect(s1.lightningResistPct).toBe(12)
|
||||
expect(s1.poisonResistPct).toBe(12)
|
||||
expect(s1.manaCost).toBe(0)
|
||||
|
||||
const s10 = calculateNaturalResistanceStats(10)
|
||||
expect(s10.resistAllPct).toBe(55) // dm(0, 80, 10) = floor(110*10*80 / 1600) = 55
|
||||
expect(s10.fireResistPct).toBe(55)
|
||||
|
||||
const s20 = calculateNaturalResistanceStats(20)
|
||||
expect(s20.resistAllPct).toBe(67) // dm(0, 80, 20) = floor(80 * 84 / 100) = 67
|
||||
expect(s20.resistAllPct).toBe(s20.poisonResistPct)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dimension 2: Mechanics & Weapon Interaction', () => {
|
||||
it('2.1 Correctly identifies matching weapon categories', () => {
|
||||
expect(isWeaponMatchingMastery('crystal sword', 127)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('colossus blade', 127)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('berserker axe', 127)).toBe(false)
|
||||
|
||||
expect(isWeaponMatchingMastery('berserker axe', 128)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('war club', 128)).toBe(false)
|
||||
|
||||
expect(isWeaponMatchingMastery('flail', 129)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('war hammer', 129)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('grand scepter', 129)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('short sword', 129)).toBe(false)
|
||||
|
||||
expect(isWeaponMatchingMastery('colossus voulge', 134)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('thresher', 134)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('polearm', 134)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('short sword', 134)).toBe(false)
|
||||
|
||||
expect(isWeaponMatchingMastery('throwing axe', 135)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('javelin', 135)).toBe(true)
|
||||
|
||||
expect(isWeaponMatchingMastery('war spear', 136)).toBe(true)
|
||||
expect(isWeaponMatchingMastery('javelin', 136)).toBe(true)
|
||||
})
|
||||
|
||||
it('2.2 Enforces 1.13c Critical Strike + Deadly Strike sequential roll formula', () => {
|
||||
// 20% CS, 50% DS -> 20 + 50 * (1 - 0.2) = 20 + 40 = 60%
|
||||
expect(calculateEffectiveCriticalStrike(20, 50)).toBe(60.0)
|
||||
|
||||
// 0% CS, 30% DS -> 30%
|
||||
expect(calculateEffectiveCriticalStrike(0, 30)).toBe(30.0)
|
||||
|
||||
// 25% CS, 0% DS -> 25%
|
||||
expect(calculateEffectiveCriticalStrike(25, 0)).toBe(25.0)
|
||||
|
||||
// 30% CS, 100% DS -> 30 + 100 * 0.7 = 100%
|
||||
expect(calculateEffectiveCriticalStrike(30, 100)).toBe(100.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dimension 3: Adversarial Stress & Boundary Sanitization', () => {
|
||||
it('3.1 Handles zero and negative levels safely without NaN or crash', () => {
|
||||
for (const slvl of [0, -1, -50]) {
|
||||
const sw = calculateSwordMasteryStats(slvl)
|
||||
expect(sw.attackRatingBonusPct).toBe(0)
|
||||
expect(sw.damageBonusPct).toBe(0)
|
||||
expect(sw.criticalStrikeChancePct).toBe(0)
|
||||
|
||||
const st = calculateIncreasedStaminaStats(slvl)
|
||||
expect(st.staminaBonusPct).toBe(0)
|
||||
|
||||
const def = calculateIronSkinStats(slvl)
|
||||
expect(def.defenseBonusPct).toBe(0)
|
||||
|
||||
const spd = calculateIncreasedSpeedStats(slvl)
|
||||
expect(spd.runWalkSpeedBonusPct).toBe(0)
|
||||
|
||||
const res = calculateNaturalResistanceStats(slvl)
|
||||
expect(res.resistAllPct).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('3.2 Handles massive extreme levels (slvl 99) monotonically within 1.13c asymptotic limits', () => {
|
||||
const sw99 = calculateSwordMasteryStats(99)
|
||||
expect(sw99.criticalStrikeChancePct).toBeLessThanOrEqual(38) // dm(0, 35, 99) = 36 (asymptote 1.1 * 35 = 38.5)
|
||||
expect(sw99.criticalStrikeChancePct).toBeGreaterThan(30)
|
||||
|
||||
const spd99 = calculateIncreasedSpeedStats(99)
|
||||
expect(spd99.runWalkSpeedBonusPct).toBeLessThanOrEqual(55) // dm(7, 50, 99) = 51 (asymptote 7 + 1.1 * 43 = 54.3)
|
||||
expect(spd99.runWalkSpeedBonusPct).toBeGreaterThan(45)
|
||||
|
||||
const res99 = calculateNaturalResistanceStats(99)
|
||||
expect(res99.resistAllPct).toBeLessThanOrEqual(88) // dm(0, 80, 99) = 83 (asymptote 1.1 * 80 = 88)
|
||||
expect(res99.resistAllPct).toBeGreaterThan(75)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dimension 4: Data-Driven Registry Integration', () => {
|
||||
it('4.1 Verifies all 10 Barbarian Combat Masteries evaluate via evaluateBarbarianMastery', () => {
|
||||
const masteryIds = [127, 128, 129, 134, 135, 136, 141, 145, 148, 153]
|
||||
for (const id of masteryIds) {
|
||||
const res = evaluateBarbarianMastery(id, 10)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res?.manaCost).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('4.2 Enforces BATCH1_SKILLS length invariant = strictly 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue