409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
||
import {
|
||
createInitialState,
|
||
getEffectiveLevel,
|
||
getTotalPointsSpent,
|
||
getTabPointsSpent,
|
||
getRequiredCharacterLevel,
|
||
canAllocate,
|
||
allocatePoint,
|
||
canDeallocate,
|
||
deallocatePoint,
|
||
respec,
|
||
evaluateSynergies,
|
||
evaluateMasteries,
|
||
calculateManaCost,
|
||
calculateDetailedDamage,
|
||
buildTooltipViewModel,
|
||
serializeBuildToHash,
|
||
deserializeBuildFromHash,
|
||
type SkillCalcState,
|
||
} from '../src/game/skill-calc-engine.ts'
|
||
import { SKILLS_BY_ID, SKILLS_BY_CLASS } from '../src/data/skills-catalog.ts'
|
||
|
||
describe('Diablo II Skill Calculator Engine (Issue #26)', () => {
|
||
let state: SkillCalcState
|
||
|
||
beforeEach(() => {
|
||
state = createInitialState('sor')
|
||
})
|
||
|
||
describe('Point Allocation & Deallocation State Machine (Left Click / Right Click)', () => {
|
||
it('allocates points to level 1 skill with 0 prerequisites', () => {
|
||
// Fire Bolt (id 36): reqlevel 1, no prereqs
|
||
expect(canAllocate(state, 36)).toBe(true)
|
||
expect(allocatePoint(state, 36)).toBe(true)
|
||
expect(state.hardPoints[36]).toBe(1)
|
||
expect(getTotalPointsSpent(state)).toBe(1)
|
||
expect(getTabPointsSpent(state, 0)).toBe(1) // Fire Spells is tab 0
|
||
})
|
||
|
||
it('rejects allocation when direct prerequisites are not met', () => {
|
||
// Fire Ball (id 47): requires Fire Bolt (36)
|
||
expect(canAllocate(state, 47)).toBe(false)
|
||
expect(allocatePoint(state, 47)).toBe(false)
|
||
expect(state.hardPoints[47]).toBeUndefined()
|
||
|
||
// Now allocate Fire Bolt
|
||
allocatePoint(state, 36)
|
||
expect(canAllocate(state, 47)).toBe(true)
|
||
expect(allocatePoint(state, 47)).toBe(true)
|
||
expect(state.hardPoints[47]).toBe(1)
|
||
})
|
||
|
||
it('caps hard points at exactly 20 per skill', () => {
|
||
allocatePoint(state, 36, 25)
|
||
expect(state.hardPoints[36]).toBe(20)
|
||
expect(canAllocate(state, 36)).toBe(false)
|
||
})
|
||
|
||
it('deallocates points correctly (right click)', () => {
|
||
allocatePoint(state, 36, 5)
|
||
expect(state.hardPoints[36]).toBe(5)
|
||
|
||
expect(deallocatePoint(state, 36)).toBe(true)
|
||
expect(state.hardPoints[36]).toBe(4)
|
||
|
||
// Deallocate all remaining
|
||
expect(deallocatePoint(state, 36, 4)).toBe(true)
|
||
expect(state.hardPoints[36]).toBeUndefined()
|
||
expect(getTotalPointsSpent(state)).toBe(0)
|
||
})
|
||
|
||
it('prevents deallocation when active dependent skills exist (orphan protection)', () => {
|
||
// Fire Bolt (36) -> Fire Ball (47) -> Meteor (56)
|
||
allocatePoint(state, 36, 1)
|
||
allocatePoint(state, 47, 1)
|
||
|
||
// Reducing Fire Bolt above 1 is allowed
|
||
allocatePoint(state, 36, 4)
|
||
expect(canDeallocate(state, 36)).toBe(true) // 5 -> 4
|
||
deallocatePoint(state, 36, 4)
|
||
expect(state.hardPoints[36]).toBe(1)
|
||
|
||
// Reducing Fire Bolt from 1 to 0 while Fire Ball has 1 point MUST be rejected
|
||
expect(canDeallocate(state, 36)).toBe(false)
|
||
expect(deallocatePoint(state, 36)).toBe(false)
|
||
expect(state.hardPoints[36]).toBe(1)
|
||
|
||
// Deallocate Fire Ball first, then Fire Bolt can be removed
|
||
expect(deallocatePoint(state, 47)).toBe(true)
|
||
expect(canDeallocate(state, 36)).toBe(true)
|
||
expect(deallocatePoint(state, 36)).toBe(true)
|
||
expect(state.hardPoints[36]).toBeUndefined()
|
||
})
|
||
|
||
it('respec refunds all allocated points cleanly', () => {
|
||
allocatePoint(state, 36, 20)
|
||
allocatePoint(state, 47, 20)
|
||
expect(getTotalPointsSpent(state)).toBe(40)
|
||
|
||
const refunded = respec(state)
|
||
expect(refunded).toBe(40)
|
||
expect(getTotalPointsSpent(state)).toBe(0)
|
||
expect(Object.keys(state.hardPoints)).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
describe('Character Level Requirement Calculation', () => {
|
||
it('calculates minimum level based on highest reqlevel and hard points', () => {
|
||
// Level 1 skill with 1 point: min level = 1
|
||
allocatePoint(state, 36, 1)
|
||
expect(getRequiredCharacterLevel(state)).toBe(1)
|
||
|
||
// Level 1 skill with 20 points: requires at least character level 20
|
||
allocatePoint(state, 36, 19)
|
||
expect(getRequiredCharacterLevel(state)).toBe(20)
|
||
|
||
// Meteor (56): reqlevel 24. With 1 point: min level 24
|
||
// Prereqs: Fire Bolt (36) -> Fire Ball (47) -> Meteor (56) and Inferno (41) -> Blaze (46) -> Fire Wall (51) -> Meteor
|
||
allocatePoint(state, 47, 1)
|
||
allocatePoint(state, 41, 1)
|
||
allocatePoint(state, 46, 1)
|
||
allocatePoint(state, 51, 1)
|
||
allocatePoint(state, 56, 1)
|
||
expect(getRequiredCharacterLevel(state)).toBe(24)
|
||
|
||
// Meteor with 10 hard points: requires 24 + 9 = 33
|
||
allocatePoint(state, 56, 9)
|
||
expect(getRequiredCharacterLevel(state)).toBe(33)
|
||
})
|
||
})
|
||
|
||
describe('1.13c Synergy Engine (blvl Invariant & Scaling)', () => {
|
||
it('evaluates Fire Ball synergies strictly from hard points of Fire Bolt and Meteor', () => {
|
||
const fb = SKILLS_BY_ID[47]! // Fire Ball
|
||
expect(fb).toBeDefined()
|
||
|
||
// Initial: 0 points in Fire Bolt and Meteor => synergy bonus = 0%
|
||
let syn = evaluateSynergies(state, fb)
|
||
expect(syn.totalPercent).toBe(0)
|
||
expect(syn.multiplier).toBe(1.0)
|
||
|
||
// Invest 10 points in Fire Bolt (14% per point)
|
||
allocatePoint(state, 36, 10)
|
||
syn = evaluateSynergies(state, fb)
|
||
expect(syn.totalPercent).toBe(140)
|
||
expect(syn.multiplier).toBe(2.4)
|
||
|
||
// Invest 20 points in Meteor (14% per point)
|
||
// Meteor requires Fire Ball (47) and Fire Wall (51)
|
||
allocatePoint(state, 47, 1)
|
||
allocatePoint(state, 41, 1)
|
||
allocatePoint(state, 46, 1)
|
||
allocatePoint(state, 51, 1)
|
||
allocatePoint(state, 56, 20)
|
||
syn = evaluateSynergies(state, fb)
|
||
// 10 * 14 + 20 * 14 = 140 + 280 = 420%
|
||
expect(syn.totalPercent).toBe(420)
|
||
expect(syn.multiplier).toBe(5.2)
|
||
|
||
// Soft points (+skills) MUST NOT increase synergy bonus!
|
||
state.allSkillsSoftPoints = 10
|
||
state.tabSoftPoints[0] = 5
|
||
syn = evaluateSynergies(state, fb)
|
||
expect(syn.totalPercent).toBe(420) // Unchanged!
|
||
expect(syn.multiplier).toBe(5.2)
|
||
})
|
||
})
|
||
|
||
describe('Mastery Calculation (Sorceress, Paladin, Assassin)', () => {
|
||
it('calculates Sorceress Fire Mastery amplification', () => {
|
||
const fb = SKILLS_BY_ID[47]!
|
||
// Fire Mastery (61): level 1 = 30%, level 20 = 30 + 7*19 = 163%
|
||
allocatePoint(state, 61, 20)
|
||
const mastery = evaluateMasteries(state, fb)
|
||
expect(mastery).not.toBeNull()
|
||
expect(mastery?.active).toBe(true)
|
||
expect(mastery?.bonusPercent).toBe(163)
|
||
expect(mastery?.multiplier).toBe(2.63)
|
||
|
||
// With +2 all skills: effective level becomes 22 => 30 + 7*21 = 177%
|
||
state.allSkillsSoftPoints = 2
|
||
const masteryWithSoft = evaluateMasteries(state, fb)
|
||
expect(masteryWithSoft?.effectiveLevel).toBe(22)
|
||
expect(masteryWithSoft?.bonusPercent).toBe(177)
|
||
})
|
||
|
||
it('calculates Paladin Concentration bonus to Blessed Hammer (at 50% effectiveness)', () => {
|
||
const palState = createInitialState('pal')
|
||
const hammer = SKILLS_BY_ID[112]!
|
||
|
||
// Concentration (113): level 20 grants +60% + 15%*19 = 345%.
|
||
// Blessed Hammer receives half: Math.floor(345 / 2) = 172%
|
||
palState.hardPoints[113] = 20
|
||
const mastery = evaluateMasteries(palState, hammer)
|
||
expect(mastery).not.toBeNull()
|
||
expect(mastery?.active).toBe(true)
|
||
expect(mastery?.bonusPercent).toBe(172)
|
||
expect(mastery?.multiplier).toBe(2.72)
|
||
})
|
||
})
|
||
|
||
describe('Mana Cost Formula Evaluation', () => {
|
||
it('evaluates Teleport mana cost clamping at minmana = 1', () => {
|
||
const tp = SKILLS_BY_ID[54]! // Teleport: mana 24, lvlmana -1, minmana 1
|
||
expect(calculateManaCost(tp, 1)).toBe(24)
|
||
expect(calculateManaCost(tp, 10)).toBe(15)
|
||
expect(calculateManaCost(tp, 24)).toBe(1)
|
||
expect(calculateManaCost(tp, 30)).toBe(1) // Clamped at minmana 1
|
||
})
|
||
|
||
it('evaluates Magic Arrow mana cost scaling to 0', () => {
|
||
const amaState = createInitialState('ama')
|
||
const ma = SKILLS_BY_ID[6]! // Magic Arrow: mana 12, lvlmana -1, manashift 5 (shift factor 0.125)
|
||
// Level 1: 12 * 0.125 = 1.5
|
||
expect(calculateManaCost(ma, 1)).toBe(1.5)
|
||
// Level 13: (12 - 12) * 0.125 = 0
|
||
expect(calculateManaCost(ma, 13)).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('Detailed Damage Calculation', () => {
|
||
it('calculates Fire Ball damage with base, synergy, and mastery multipliers', () => {
|
||
const fb = SKILLS_BY_ID[47]!
|
||
|
||
// Level 1 Fire Ball with 0 synergies, 0 mastery
|
||
// Base: EMin=12, EMax=28, HitShift=7 (shift factor 0.5)
|
||
// baseMin = 12 * 0.5 = 6, baseMax = 28 * 0.5 = 14
|
||
let dmg = calculateDetailedDamage(state, fb, 1)
|
||
expect(dmg.hasDamage).toBe(true)
|
||
expect(dmg.type).toBe('fire')
|
||
expect(dmg.baseMin).toBe(6)
|
||
expect(dmg.baseMax).toBe(14)
|
||
expect(dmg.finalMin).toBe(6)
|
||
expect(dmg.finalMax).toBe(14)
|
||
|
||
// Add 20 points to Fire Bolt (synergy: +280%)
|
||
allocatePoint(state, 36, 20)
|
||
dmg = calculateDetailedDamage(state, fb, 1)
|
||
// Multiplier = 1.0 + 2.80 = 3.80
|
||
// withSynergyMin = floor(6 * 3.80) = 22
|
||
// withSynergyMax = floor(14 * 3.80) = 53
|
||
expect(dmg.synergyBonusPct).toBe(280)
|
||
expect(dmg.withSynergyMin).toBe(22)
|
||
expect(dmg.withSynergyMax).toBe(53)
|
||
|
||
// Add 20 points to Fire Mastery (+163%)
|
||
allocatePoint(state, 61, 20)
|
||
dmg = calculateDetailedDamage(state, fb, 1)
|
||
// Mastery multiplier = 2.63
|
||
// finalMin = floor(22 * 2.63) = 57
|
||
// finalMax = floor(53 * 2.63) = 139
|
||
expect(dmg.masteryBonusPct).toBe(163)
|
||
expect(dmg.finalMin).toBe(57)
|
||
expect(dmg.finalMax).toBe(139)
|
||
})
|
||
})
|
||
|
||
describe('Tooltip View Model Builder', () => {
|
||
it('builds complete tooltip model with localized names, requirements, mana, and synergy status', () => {
|
||
const vm = buildTooltipViewModel(state, 47, 99)
|
||
expect(vm).not.toBeNull()
|
||
expect(vm?.skill.name).toBe('Fire Ball')
|
||
expect(vm?.skill.nameZh).toBe('火球')
|
||
expect(vm?.reqlevel).toBe(12)
|
||
expect(vm?.levelMet).toBe(true)
|
||
expect(vm?.prereqs).toHaveLength(1) // Requires Fire Bolt
|
||
expect(vm?.prereqs[0].name).toBe('Fire Bolt')
|
||
expect(vm?.prereqs[0].met).toBe(false)
|
||
expect(vm?.current.damage.hasDamage).toBe(true)
|
||
expect(vm?.nextLevel).not.toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('Authentic Diablo II Tooltip Display Rules (Issue #26 Fidelity)', () => {
|
||
it('strictly hides direct attack damage for non-damage skills', () => {
|
||
// Warmth (37), Amplify Damage (66), Raise Skeleton (70), Fanaticism (122), Battle Orders (149), Valkyrie (32)
|
||
const nonDamageSkillIds = [37, 66, 70, 122, 149, 32, 75, 87, 138, 247]
|
||
for (const id of nonDamageSkillIds) {
|
||
const skill = SKILLS_BY_ID[id]!
|
||
expect(skill.descdam).toBe('')
|
||
const dmg = calculateDetailedDamage(state, skill, 20)
|
||
expect(dmg.hasDamage).toBe(false)
|
||
expect(dmg.baseMin).toBe(0)
|
||
expect(dmg.baseMax).toBe(0)
|
||
expect(dmg.finalMin).toBe(0)
|
||
expect(dmg.finalMax).toBe(0)
|
||
}
|
||
})
|
||
|
||
it('evaluates Warmth mana recovery rate effect without damage', () => {
|
||
const warmth = SKILLS_BY_ID[37]!
|
||
const sorState = createInitialState('sor')
|
||
sorState.hardPoints[37] = 20
|
||
const vm = buildTooltipViewModel(sorState, 37, 99)
|
||
expect(vm).not.toBeNull()
|
||
expect(vm?.current.damage.hasDamage).toBe(false)
|
||
expect(vm?.current.effects.length).toBeGreaterThan(0)
|
||
const effect = vm?.current.effects[0]
|
||
expect(effect?.textZh).toBe('法力回復速度:+258%')
|
||
expect(effect?.textEn).toBe('Mana Recovery Rate: +258%')
|
||
})
|
||
|
||
it('evaluates Raise Skeleton minion stats (count, life, defense, attack, damage)', () => {
|
||
const necState = createInitialState('nec')
|
||
necState.hardPoints[70] = 20 // Raise Skeleton 20
|
||
necState.hardPoints[69] = 20 // Skeleton Mastery 20
|
||
const vm = buildTooltipViewModel(necState, 70, 99)
|
||
expect(vm).not.toBeNull()
|
||
expect(vm?.current.damage.hasDamage).toBe(false)
|
||
expect(vm?.current.effects.length).toBeGreaterThanOrEqual(5)
|
||
|
||
// Find total skeletons line
|
||
const totalSkeletons = vm?.current.effects.find(e => e.lineType === 36)
|
||
expect(totalSkeletons).toBeDefined()
|
||
expect(totalSkeletons?.value).toBe(8) // (2 + 20/3) = 8 skeletons
|
||
|
||
// Find skeleton life line (minHP 21 + Mastery bonus + Raise bonus)
|
||
const lifeLine = vm?.current.effects.find(e => e.lineType === 13)
|
||
expect(lifeLine).toBeDefined()
|
||
expect(lifeLine?.textZh).toMatch(/生命:\d+/)
|
||
|
||
// Find skeleton damage line (lineType 34)
|
||
const dmgLine = vm?.current.effects.find(e => e.lineType === 34)
|
||
expect(dmgLine).toBeDefined()
|
||
expect(dmgLine?.textZh).toMatch(/傷害:\d+ - \d+/)
|
||
})
|
||
|
||
it('evaluates Battle Orders buff effects (max mana, life, stamina % and duration)', () => {
|
||
const barState = createInitialState('bar')
|
||
barState.hardPoints[149] = 20
|
||
const vm = buildTooltipViewModel(barState, 149, 99)
|
||
expect(vm).not.toBeNull()
|
||
expect(vm?.current.damage.hasDamage).toBe(false)
|
||
|
||
// ln34 = par3 + (lvl - 1) * par4 = 35 + 19 * 3 = 92%
|
||
const manaLine = vm?.current.effects.find(e => e.textZh.includes('法力最大值'))
|
||
const lifeLine = vm?.current.effects.find(e => e.textZh.includes('生命最大值'))
|
||
const staminaLine = vm?.current.effects.find(e => e.textZh.includes('耐力最大值'))
|
||
const durationLine = vm?.current.effects.find(e => e.lineType === 12)
|
||
|
||
expect(manaLine?.textZh).toBe('法力最大值:+92%')
|
||
expect(lifeLine?.textZh).toBe('生命最大值:+92%')
|
||
expect(staminaLine?.textZh).toBe('耐力最大值:+92%')
|
||
expect(durationLine?.textZh).toBe('持續:220 秒')
|
||
})
|
||
|
||
it('evaluates Fanaticism aura effects (attack speed, attack rating, your damage, party damage, radius)', () => {
|
||
const palState = createInitialState('pal')
|
||
palState.hardPoints[122] = 20
|
||
const vm = buildTooltipViewModel(palState, 122, 99)
|
||
expect(vm).not.toBeNull()
|
||
expect(vm?.current.damage.hasDamage).toBe(false)
|
||
|
||
const arLine = vm?.current.effects.find(e => e.textZh.includes('準確率:'))
|
||
const asLine = vm?.current.effects.find(e => e.textZh.includes('攻擊速度:'))
|
||
const yourDmgLine = vm?.current.effects.find(e => e.textZh.includes('你的傷害:'))
|
||
const partyDmgLine = vm?.current.effects.find(e => e.textZh.includes('團隊傷害:'))
|
||
const radiusLine = vm?.current.effects.find(e => e.lineType === 19)
|
||
|
||
expect(arLine?.textZh).toBe('準確率:+135%')
|
||
expect(asLine?.textZh).toBe('攻擊速度:+35%')
|
||
expect(yourDmgLine?.textZh).toBe('你的傷害:+373%')
|
||
expect(partyDmgLine?.textZh).toBe('團隊傷害:+186%')
|
||
expect(radiusLine?.textZh).toBe('半徑:20.0 碼')
|
||
})
|
||
|
||
it('evaluates Valkyrie minion stats (defense bonus, attack rating, damage %, life)', () => {
|
||
const amaState = createInitialState('ama')
|
||
amaState.hardPoints[32] = 20
|
||
const vm = buildTooltipViewModel(amaState, 32, 99)
|
||
expect(vm).not.toBeNull()
|
||
expect(vm?.current.damage.hasDamage).toBe(false)
|
||
|
||
const defLine = vm?.current.effects.find(e => e.textZh.includes('防禦加成:'))
|
||
const atkLine = vm?.current.effects.find(e => e.textZh.includes('準確率:'))
|
||
const dmgLine = vm?.current.effects.find(e => e.textZh.includes('傷害'))
|
||
const lifeLine = vm?.current.effects.find(e => e.lineType === 13)
|
||
|
||
expect(defLine).toBeDefined()
|
||
expect(atkLine).toBeDefined()
|
||
expect(dmgLine).toBeDefined()
|
||
expect(lifeLine?.textZh).toMatch(/生命:\d+/)
|
||
})
|
||
})
|
||
|
||
describe('URL Hash Build Serialization & Sharing', () => {
|
||
it('serializes and deserializes a build accurately', () => {
|
||
allocatePoint(state, 36, 20) // Fire Bolt
|
||
allocatePoint(state, 47, 20) // Fire Ball
|
||
state.allSkillsSoftPoints = 3
|
||
state.tabSoftPoints[0] = 2
|
||
|
||
const hash = serializeBuildToHash(state)
|
||
expect(hash).toContain('c=sor')
|
||
expect(hash).toContain('36:20')
|
||
expect(hash).toContain('47:20')
|
||
|
||
const restored = deserializeBuildFromHash(hash)
|
||
expect(restored).not.toBeNull()
|
||
expect(restored?.classCode).toBe('sor')
|
||
expect(restored?.hardPoints[36]).toBe(20)
|
||
expect(restored?.hardPoints[47]).toBe(20)
|
||
expect(restored?.allSkillsSoftPoints).toBe(3)
|
||
expect(restored?.tabSoftPoints[0]).toBe(2)
|
||
})
|
||
})
|
||
})
|