/** * 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) } } }) }) })