diablo2-web/tests/challenger-m7-skill-formula...

489 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II v1.13c Skill Formula & Preview Adversarial Stress Test Suite.
*
* Authored by: teamwork_preview_challenger
* Milestone: M7 (Issue #152 — 技能 Tooltip 未对齐原版)
*
* Adversarially challenges:
* 1. Attack Rating (ToHit) bonus scaling: Jab, Sacrifice, Zeal (1.13c ground truth vs worker claims).
* 2. Duration scaling: Shout (defense vs duration disambiguation), Battle Orders (base vs synergized duration),
* and 3-band duration progression (piecewise scaling across bands 1-8, 9-16, 17+).
* 3. Next-Level Preview Invariant: unlearned (null next), intermediate (eff + 1), and capped at 20 (null next).
* 4. 1.13c Marrowwalk Fix Synergy Isolation: hard points vs soft points (+skills / charges).
* 5. Differential fuzzing & property validation across 1000 generated inputs.
*/
import { describe, it, expect } from 'vitest'
import {
createInitialState,
getEffectiveLevel,
allocatePoint,
evaluateSynergies,
evaluateMasteries,
calculateManaCost,
calculateDetailedDamage,
buildTooltipViewModel,
evaluateSkillEffects,
evalSkillDescFormula,
type SkillCalcState,
} from '../src/game/skill-calc-engine.ts'
import { compute3BandDuration } from '../src/game/engine/calc-ast.ts'
import { SKILLS_CATALOG, SKILLS_BY_ID } from '../src/data/skills-catalog.ts'
import { SkillTreePanel } from '../src/ui/skill-tree-panel.ts'
describe('Challenger M7: 1.13c Skill Formulas, Scaling & Tooltip Stress Tests', () => {
// ==========================================================================
// 1. Attack Rating Bonus Scaling
// ==========================================================================
describe('1. Attack Rating (ToHit) Bonus Scaling', () => {
it('verifies Jab (id 10) scaling: level 1 (+10%), level 10 (+91%), level 20 (+181%)', () => {
const jab = SKILLS_BY_ID[10]!
expect(jab).toBeDefined()
expect(jab.toHit).toBe(10)
expect(jab.toHitLev).toBe(9)
// Level 1: 10 + (1 - 1) * 9 = 10%
const state1 = createInitialState('ama')
state1.hardPoints[10] = 1
const vm1 = buildTooltipViewModel(state1, 10, 99)
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(ar1).toBeDefined()
expect(ar1?.value).toBe(10)
expect(ar1?.textZh).toBe('準確率:+10%')
// Level 10: 10 + (10 - 1) * 9 = 91%
const state10 = createInitialState('ama')
state10.hardPoints[10] = 10
const vm10 = buildTooltipViewModel(state10, 10, 99)
const ar10 = vm10?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(ar10).toBeDefined()
expect(ar10?.value).toBe(91)
expect(ar10?.textZh).toBe('準確率:+91%')
// Level 20: 10 + (20 - 1) * 9 = 181%
const state20 = createInitialState('ama')
state20.hardPoints[10] = 20
const vm20 = buildTooltipViewModel(state20, 10, 99)
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(ar20).toBeDefined()
expect(ar20?.value).toBe(181)
expect(ar20?.textZh).toBe('準確率:+181%')
})
it('verifies Sacrifice (id 96) scaling: level 1 (+20%), level 10 (+83%), level 20 (+153%)', () => {
const sacrifice = SKILLS_BY_ID[96]!
expect(sacrifice).toBeDefined()
expect(sacrifice.toHit).toBe(20)
expect(sacrifice.toHitLev).toBe(7)
// Level 1: 20 + (1 - 1) * 7 = 20%
const state1 = createInitialState('pal')
state1.hardPoints[96] = 1
const vm1 = buildTooltipViewModel(state1, 96, 99)
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('準確率'))
expect(ar1).toBeDefined()
expect(ar1?.value).toBe(20)
expect(ar1?.textZh).toBe('變動準確率:+20%')
// Level 10: 20 + (10 - 1) * 7 = 83%
const state10 = createInitialState('pal')
state10.hardPoints[96] = 10
const vm10 = buildTooltipViewModel(state10, 96, 99)
const ar10 = vm10?.current.effects.find(e => e.textZh.includes('準確率'))
expect(ar10).toBeDefined()
expect(ar10?.value).toBe(83)
expect(ar10?.textZh).toBe('變動準確率:+83%')
// Level 20: 20 + (20 - 1) * 7 = 153%
const state20 = createInitialState('pal')
state20.hardPoints[96] = 20
const vm20 = buildTooltipViewModel(state20, 96, 99)
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('準確率'))
expect(ar20).toBeDefined()
expect(ar20?.value).toBe(153)
expect(ar20?.textZh).toBe('變動準確率:+153%')
})
it('adversarially challenges Zeal (id 106): tests 1.13c MPQ ground truth (lvl 1=+10%, lvl 20=+200%, lvl 21=+210%) vs worker handoff claim (lvl 1=0%, lvl 21=+200%)', () => {
const zeal = SKILLS_BY_ID[106]!
expect(zeal).toBeDefined()
// Authentic 1.13c Skills.txt row 106 ground truth: ToHit = 10, LevToHit = 10
expect(zeal.toHit).toBe(10)
expect(zeal.toHitLev).toBe(10)
// Empirical Level 1: 10 + (1 - 1) * 10 = +10%
const state1 = createInitialState('pal')
state1.hardPoints[106] = 1
const vm1 = buildTooltipViewModel(state1, 106, 99)
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
expect(ar1).toBeDefined()
expect(ar1?.value).toBe(10)
expect(ar1?.textZh).toBe('攻擊加成:+10%')
// Empirical Level 20: 10 + (20 - 1) * 10 = +200%
const state20 = createInitialState('pal')
state20.hardPoints[106] = 20
const vm20 = buildTooltipViewModel(state20, 106, 99)
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
expect(ar20).toBeDefined()
expect(ar20?.value).toBe(200)
expect(ar20?.textZh).toBe('攻擊加成:+200%')
// Empirical Level 21: 10 + (21 - 1) * 10 = +210%
const state21 = createInitialState('pal')
state21.hardPoints[106] = 20
state21.allSkillsSoftPoints = 1 // 20 hard + 1 soft = eff 21
const vm21 = buildTooltipViewModel(state21, 106, 99)
const ar21 = vm21?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
expect(ar21).toBeDefined()
expect(ar21?.value).toBe(210)
expect(ar21?.textZh).toBe('攻擊加成:+210%')
// Note on Challenger Finding:
// Worker M7's handoff text stated: "Zeal (id 106) with lineType 2 StrSkill30 starts at 0 and adds +10%/lvl, giving +200% at level 21."
// That was a documentation typo in Worker M7's report. The actual code extracted from 1.13c MPQ has ToHit=10,
// which yields +10% at lvl 1, +200% at lvl 20, and +210% at lvl 21.
expect(ar1?.value).not.toBe(0)
expect(ar21?.value).not.toBe(200)
})
})
// ==========================================================================
// 2. Duration Scaling
// ==========================================================================
describe('2. Duration Scaling & Formula Disambiguation', () => {
it('verifies Shout (id 138) duration and defense are NOT conflated: level 1 dur=20s def=+100%, level 20 dur=210s def=+290%', () => {
const shout = SKILLS_BY_ID[138]!
expect(shout).toBeDefined()
// Param1/Param2 (ln12) = Defense bonus: 100 + (lvl - 1) * 10
// Param3/Param4 (ln34) = Duration frames: 500 + (lvl - 1) * 250
expect(shout.params.par1).toBe(100)
expect(shout.params.par2).toBe(10)
expect(shout.params.par3).toBe(500)
expect(shout.params.par4).toBe(250)
// Level 1
const state1 = createInitialState('bar')
state1.hardPoints[138] = 1
const vm1 = buildTooltipViewModel(state1, 138, 99)
const dur1 = vm1?.current.effects.find(e => e.lineType === 12)
const def1 = vm1?.current.effects.find(e => e.textZh.includes('防禦'))
expect(dur1).toBeDefined()
expect(dur1?.value).toBe(500) // 500 frames
expect(dur1?.textZh).toBe('持續:20 秒') // 500 / 25 = 20s
expect(def1).toBeDefined()
expect(def1?.value).toBe(100)
expect(def1?.textZh).toBe('防禦:+100%')
// Level 20
const state20 = createInitialState('bar')
state20.hardPoints[138] = 20
const vm20 = buildTooltipViewModel(state20, 138, 99)
const dur20 = vm20?.current.effects.find(e => e.lineType === 12)
const def20 = vm20?.current.effects.find(e => e.textZh.includes('防禦'))
expect(dur20).toBeDefined()
// 500 + 19 * 250 = 5250 frames
expect(dur20?.value).toBe(5250)
expect(dur20?.textZh).toBe('持續:210 秒') // 5250 / 25 = 210s
expect(def20).toBeDefined()
// 100 + 19 * 10 = 290%
expect(def20?.value).toBe(290)
expect(def20?.textZh).toBe('防禦:+290%')
})
it('verifies Battle Orders (id 149) duration scaling: level 1 = 30s, level 20 base = 220s, level 20 with Shout synergy = 240s', () => {
const bo = SKILLS_BY_ID[149]!
expect(bo).toBeDefined()
// Param1 = 750 frames (30s), Param2 = 250 frames (10s), Param8 = 125 frames (5s) per synergy point
expect(bo.params.par1).toBe(750)
expect(bo.params.par2).toBe(250)
expect(bo.params.par8).toBe(125)
// Level 1: 750 frames = 30 seconds
const state1 = createInitialState('bar')
state1.hardPoints[149] = 1
const vm1 = buildTooltipViewModel(state1, 149, 99)
const dur1 = vm1?.current.effects.find(e => e.lineType === 12)
expect(dur1).toBeDefined()
expect(dur1?.value).toBe(750)
expect(dur1?.textZh).toBe('持續:30 秒')
// Level 20 (base without synergy): 750 + 19 * 250 = 5500 frames = 220 seconds
const state20 = createInitialState('bar')
state20.hardPoints[149] = 20
const vm20 = buildTooltipViewModel(state20, 149, 99)
const dur20 = vm20?.current.effects.find(e => e.lineType === 12)
expect(dur20).toBeDefined()
expect(dur20?.value).toBe(5500)
expect(dur20?.textZh).toBe('持續:220 秒')
// Level 20 with 4 points in Shout synergy (4 * 125 = 500 frames = 20s): 5500 + 500 = 6000 frames = 240s!
const state20Syn = createInitialState('bar')
state20Syn.hardPoints[149] = 20
state20Syn.hardPoints[138] = 4 // 4 points in Shout
const vm20Syn = buildTooltipViewModel(state20Syn, 149, 99)
const dur20Syn = vm20Syn?.current.effects.find(e => e.lineType === 12)
expect(dur20Syn).toBeDefined()
expect(dur20Syn?.value).toBe(6000)
expect(dur20Syn?.textZh).toBe('持續:240 秒')
// Level 22 (base without synergy): 750 + 21 * 250 = 6000 frames = 240 seconds
const state22 = createInitialState('bar')
state22.hardPoints[149] = 20
state22.allSkillsSoftPoints = 2
const vm22 = buildTooltipViewModel(state22, 149, 99)
const dur22 = vm22?.current.effects.find(e => e.lineType === 12)
expect(dur22?.value).toBe(6000)
expect(dur22?.textZh).toBe('持續:240 秒')
})
it('verifies 3-band duration progression across levels 1-8, 9-16, and 17+ on authentic elemental skills', () => {
// 1. Shiver Armor (id 50): eLen = 100, eLevLen = [0, 25, 50]
// Band 1 (slvl 1-8): rate +0
expect(compute3BandDuration(1, 100, 0, 25, 50)).toBe(100)
expect(compute3BandDuration(8, 100, 0, 25, 50)).toBe(100)
// Band 2 (slvl 9-16): rate +25
expect(compute3BandDuration(9, 100, 0, 25, 50)).toBe(125)
expect(compute3BandDuration(16, 100, 0, 25, 50)).toBe(300)
// Band 3 (slvl 17+): rate +50
expect(compute3BandDuration(17, 100, 0, 25, 50)).toBe(350)
expect(compute3BandDuration(20, 100, 0, 25, 50)).toBe(500)
expect(compute3BandDuration(30, 100, 0, 25, 50)).toBe(1000)
// 2. Stun (id 139): eLen = 30, eLevLen = [5, 5, 2]
// Band 1 (slvl 1-8): rate +5
expect(compute3BandDuration(1, 30, 5, 5, 2)).toBe(30)
expect(compute3BandDuration(8, 30, 5, 5, 2)).toBe(30 + 7 * 5) // 65
// Band 2 (slvl 9-16): rate +5
expect(compute3BandDuration(9, 30, 5, 5, 2)).toBe(65 + 1 * 5) // 70
expect(compute3BandDuration(16, 30, 5, 5, 2)).toBe(65 + 8 * 5) // 105
// Band 3 (slvl 17+): rate +2
expect(compute3BandDuration(17, 30, 5, 5, 2)).toBe(105 + 1 * 2) // 107
expect(compute3BandDuration(20, 30, 5, 5, 2)).toBe(105 + 4 * 2) // 113
// 3. Poison Javelin (id 15): eLen = 200, eLevLen = [50, 50, 50]
expect(compute3BandDuration(1, 200, 50, 50, 50)).toBe(200)
expect(compute3BandDuration(8, 200, 50, 50, 50)).toBe(550)
expect(compute3BandDuration(9, 200, 50, 50, 50)).toBe(600)
expect(compute3BandDuration(16, 200, 50, 50, 50)).toBe(950)
expect(compute3BandDuration(17, 200, 50, 50, 50)).toBe(1000)
expect(compute3BandDuration(20, 200, 50, 50, 50)).toBe(1150)
})
})
// ==========================================================================
// 3. Next-Level Preview Invariant
// ==========================================================================
describe('3. Next-Level Preview Invariant', () => {
it('enforces slvl === 0 invariant: nextLevel === null, isLearned === false, previewing level 1 in current stats', () => {
const testSkills = [
{ code: 'sor', id: 47 }, // Fire Ball
{ code: 'ama', id: 10 }, // Jab
{ code: 'pal', id: 106 }, // Zeal
{ code: 'bar', id: 149 }, // Battle Orders
{ code: 'nec', id: 84 }, // Bone Spear
{ code: 'dru', id: 245 }, // Tornado
{ code: 'ass', id: 271 }, // Lightning Sentry
] as const
for (const { code, id } of testSkills) {
const state = createInitialState(code)
const vm = buildTooltipViewModel(state, id, 99)
expect(vm, `Skill ${id} VM must not be null`).not.toBeNull()
expect(vm!.isLearned).toBe(false)
expect(vm!.effectiveLevel).toBe(0)
expect(vm!.hardPoints).toBe(0)
expect(vm!.current.level).toBe(1)
expect(vm!.nextLevel).toBeNull()
}
})
it('enforces 1 <= hardPoints < 20 invariant: nextLevel !== null, level === effectiveLevel + 1', () => {
const state = createInitialState('sor')
state.hardPoints[36] = 1 // Fire Bolt req
state.hardPoints[47] = 5 // Fire Ball 5 hard points
state.allSkillsSoftPoints = 3 // +3 soft skills -> eff 8
const vm = buildTooltipViewModel(state, 47, 99)
expect(vm).not.toBeNull()
expect(vm!.isLearned).toBe(true)
expect(vm!.hardPoints).toBe(5)
expect(vm!.effectiveLevel).toBe(8)
expect(vm!.current.level).toBe(8)
expect(vm!.nextLevel).not.toBeNull()
expect(vm!.nextLevel!.level).toBe(9)
expect(vm!.nextLevel!.damage.finalMin).toBeGreaterThan(vm!.current.damage.finalMin)
})
it('enforces hardPoints === 20 invariant: nextLevel === null regardless of soft points', () => {
const state = createInitialState('sor')
state.hardPoints[36] = 1
state.hardPoints[47] = 20 // Max base points
// Check with 0 soft points
let vm = buildTooltipViewModel(state, 47, 99)
expect(vm).not.toBeNull()
expect(vm!.isLearned).toBe(true)
expect(vm!.hardPoints).toBe(20)
expect(vm!.effectiveLevel).toBe(20)
expect(vm!.nextLevel).toBeNull()
// Check with +15 soft points
state.allSkillsSoftPoints = 15
vm = buildTooltipViewModel(state, 47, 99)
expect(vm!.hardPoints).toBe(20)
expect(vm!.effectiveLevel).toBe(35)
expect(vm!.current.level).toBe(35)
expect(vm!.nextLevel).toBeNull()
})
})
// ==========================================================================
// 4. 1.13c Marrowwalk Fix Synergy Isolation
// ==========================================================================
describe('4. 1.13c Marrowwalk Fix Synergy Isolation', () => {
it('verifies Fire Ball (id 47) receives exactly +140% from 10 hard points in Fire Bolt, and soft points do NOT increase multiplier', () => {
const state = createInitialState('sor')
const fb = SKILLS_BY_ID[47]!
// 0 hard points -> 0% synergy
let syn = evaluateSynergies(state, fb)
expect(syn.totalPercent).toBe(0)
expect(syn.multiplier).toBe(1.0)
// Allocate 10 hard points to Fire Bolt (id 36) -> 10 * 14% = 140%
allocatePoint(state, 36, 10)
syn = evaluateSynergies(state, fb)
expect(syn.totalPercent).toBe(140)
expect(syn.multiplier).toBe(2.4)
// Add soft points: +10 all skills, +10 specific soft points, +10 tab soft points
state.allSkillsSoftPoints = 10
state.specificSoftPoints[36] = 10
state.tabSoftPoints[0] = 10
// Effective level of Fire Bolt is now 10 + 10 + 10 + 10 = 40!
expect(getEffectiveLevel(state, 36)).toBe(40)
// BUT synergy multiplier MUST remain strictly +140% (multiplier 2.4)
syn = evaluateSynergies(state, fb)
expect(syn.totalPercent).toBe(140)
expect(syn.multiplier).toBe(2.4)
// Verify via buildTooltipViewModel
allocatePoint(state, 47, 1)
const vm = buildTooltipViewModel(state, 47, 99)
const synInfo = vm?.synergiesReceived.find(s => s.providerId === 36)
expect(synInfo).toBeDefined()
expect(synInfo?.hardPoints).toBe(10)
expect(synInfo?.currentBonusPct).toBe(140)
})
it('verifies Bone Spear (id 84) synergies are isolated from item charges and soft skill points', () => {
const state = createInitialState('nec')
const bs = SKILLS_BY_ID[84]!
// Teeth (67), Bone Wall (78), Bone Prison (88), Bone Spirit (93)
// 7% magic damage per level
allocatePoint(state, 67, 20) // Teeth 20
allocatePoint(state, 68, 1) // Bone Armor prerequisite for Bone Wall
allocatePoint(state, 78, 20) // Bone Wall 20
let syn = evaluateSynergies(state, bs)
// 20 * 7% + 20 * 7% = 140% + 140% = 280%
expect(syn.totalPercent).toBe(280)
expect(syn.multiplier).toBe(3.8)
// Add Marrowwalk pseudo-charges (+33 Bone Prison charges via soft points)
state.specificSoftPoints[88] = 33 // Bone Prison is id 88
state.allSkillsSoftPoints = 10
syn = evaluateSynergies(state, bs)
// Synergy MUST remain exactly 280%, ignoring soft points on Bone Prison
expect(syn.totalPercent).toBe(280)
expect(syn.multiplier).toBe(3.8)
})
})
// ==========================================================================
// 5. Differential Oracles & Property Fuzzing
// ==========================================================================
describe('5. Differential Oracles & Property Fuzzing', () => {
it('fuzzes 1000 random inputs for 3-band duration against independent mathematical oracle', () => {
// Independent naive oracle
function oracle3Band(lvl: number, eLen: number, l1: number, l2: number, l3: number): number {
if (lvl <= 0 || (eLen === 0 && l1 === 0 && l2 === 0 && l3 === 0)) return 0
let res = eLen
for (let l = 2; l <= lvl; l++) {
if (l <= 8) res += l1
else if (l <= 16) res += l2
else res += l3
}
return res
}
// Pseudo-random LCG generator for reproducible fuzzing
let seed = 1337
function lcg(): number {
seed = (seed * 1664525 + 1013904223) % 4294967296
return seed / 4294967296
}
for (let i = 0; i < 1000; i++) {
const lvl = Math.floor(lcg() * 60) + 1 // 1..60
const eLen = Math.floor(lcg() * 200) // 0..199
const l1 = Math.floor(lcg() * 50)
const l2 = Math.floor(lcg() * 50)
const l3 = Math.floor(lcg() * 50)
const expected = oracle3Band(lvl, eLen, l1, l2, l3)
const actual = compute3BandDuration(lvl, eLen, l1, l2, l3)
expect(actual, `Mismatch at lvl=${lvl}, eLen=${eLen}, l1=${l1}, l2=${l2}, l3=${l3}`).toBe(expected)
}
})
it('validates mana cost floor constraint: calculateManaCost >= minmana across all catalog skills', () => {
for (const skill of SKILLS_CATALOG) {
for (let lvl = 1; lvl <= 30; lvl++) {
const cost = calculateManaCost(skill, lvl)
const minmana = skill.minmana ?? 0
if (skill.mana > 0 || skill.lvlmana > 0) {
expect(cost, `Skill ${skill.id} at lvl ${lvl} cost ${cost} < minmana ${minmana}`).toBeGreaterThanOrEqual(minmana)
}
}
}
})
it('verifies tooltip placement bounding box invariants: always inside canvas [8, 800] x [8, 552]', () => {
const mockFont = {
measureText: (str: string) => str.length * 7,
} as any
const dummyPositions = [
{ x: 30, y: 30, w: 48, h: 48 },
{ x: 400, y: 300, w: 48, h: 48 },
{ x: 750, y: 500, w: 48, h: 48 },
{ x: 10, y: 550, w: 48, h: 48 },
]
for (const pos of dummyPositions) {
// Test varying line counts from 5 to 25
for (let lineCount = 5; lineCount <= 25; lineCount++) {
const lines = Array.from({ length: lineCount }, (_, i) => ({
text: `Line ${i + 1}: Sample Tooltip Text Description`,
color: 'white' as const,
font: 'font8' as const,
}))
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, mockFont)
expect(placement.w).toBeGreaterThanOrEqual(240)
expect(placement.h).toBe(lineCount * 17 + 24)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(800)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
}
}
})
})
})