489 lines
21 KiB
TypeScript
489 lines
21 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
})
|