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