import fs from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getSkillCooldownTicks, calculateSkillDamage, executeSkill, type SkillExecutionContext, type Batch1SkillDef, } from '../src/game/skills.ts' import type { CombatPlayer } from '../src/game/combat.ts' function makeTestPlayer(overrides?: Partial): CombatPlayer { return { x: 100, y: 100, facing: 0, hp: 100, maxHp: 100, mana: 100, maxMana: 100, level: 30, xp: 0, cooldown: 0, alive: true, respawnIn: 0, swingTicks: 0, ...overrides, } } describe('Batch 1 Combat Skills Implementation (1.13c Parity)', () => { describe('Skill Catalog & Coverage Across All 7 Classes', () => { const expectedSkills: readonly { readonly id: number; readonly name: string; readonly charClass: string }[] = [ // Amazon (5) { id: 6, name: 'Magic Arrow', charClass: 'amazon' }, { id: 7, name: 'Fire Arrow', charClass: 'amazon' }, { id: 10, name: 'Jab', charClass: 'amazon' }, { id: 11, name: 'Cold Arrow', charClass: 'amazon' }, { id: 14, name: 'Power Strike', charClass: 'amazon' }, // Sorceress (6) { id: 36, name: 'Fire Bolt', charClass: 'sorceress' }, { id: 37, name: 'Warmth', charClass: 'sorceress' }, { id: 39, name: 'Ice Bolt', charClass: 'sorceress' }, { id: 44, name: 'Frost Nova', charClass: 'sorceress' }, { id: 47, name: 'Fire Ball', charClass: 'sorceress' }, { id: 54, name: 'Teleport', charClass: 'sorceress' }, // Necromancer (5) { id: 66, name: 'Amplify Damage', charClass: 'necromancer' }, { id: 67, name: 'Teeth', charClass: 'necromancer' }, { id: 68, name: 'Bone Armor', charClass: 'necromancer' }, { id: 70, name: 'Raise Skeleton', charClass: 'necromancer' }, { id: 74, name: 'Corpse Explosion', charClass: 'necromancer' }, // Paladin (6) { id: 96, name: 'Sacrifice', charClass: 'paladin' }, { id: 97, name: 'Smite', charClass: 'paladin' }, { id: 98, name: 'Might', charClass: 'paladin' }, { id: 101, name: 'Holy Bolt', charClass: 'paladin' }, { id: 106, name: 'Zeal', charClass: 'paladin' }, { id: 112, name: 'Blessed Hammer', charClass: 'paladin' }, // Barbarian (6) { id: 126, name: 'Bash', charClass: 'barbarian' }, { id: 130, name: 'Howl', charClass: 'barbarian' }, { id: 132, name: 'Leap', charClass: 'barbarian' }, { id: 133, name: 'Double Swing', charClass: 'barbarian' }, { id: 138, name: 'Shout', charClass: 'barbarian' }, { id: 143, name: 'Leap Attack', charClass: 'barbarian' }, // Druid (5) { id: 223, name: 'Werewolf', charClass: 'druid' }, { id: 224, name: 'Lycanthropy', charClass: 'druid' }, { id: 225, name: 'Firestorm', charClass: 'druid' }, { id: 226, name: 'Oak Sage', charClass: 'druid' }, { id: 229, name: 'Molten Boulder', charClass: 'druid' }, // Assassin (5) { id: 251, name: 'Fire Blast', charClass: 'assassin' }, { id: 252, name: 'Claw Mastery', charClass: 'assassin' }, { id: 254, name: 'Tiger Strike', charClass: 'assassin' }, { id: 258, name: 'Burst of Speed', charClass: 'assassin' }, { id: 261, name: 'Charged Bolt Sentry', charClass: 'assassin' }, ] it('contains exactly 38 canonical skills across all 7 classes', () => { expect(Object.keys(BATCH1_SKILLS).length).toBe(38) for (const item of expectedSkills) { expect(isBatch1Skill(item.id)).toBe(true) const def = getBatch1SkillDef(item.id) expect(def).toBeDefined() expect(def!.name).toBe(item.name) expect(def!.charClass).toBe(item.charClass) } }) it('returns undefined and false for non-batch1 skill IDs', () => { expect(isBatch1Skill(0)).toBe(false) expect(isBatch1Skill(999)).toBe(false) expect(isBatch1Skill(-1)).toBe(false) expect(getBatch1SkillDef(999)).toBeUndefined() }) it('BATCH1_SKILLS parity with raw Skills.txt', () => { const skillsTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Skills.txt') const content = fs.readFileSync(skillsTxtPath, 'utf8') const lines = content.split('\n') const header = lines[0]!.split('\t') const col = (name: string) => header.indexOf(name) const num = (v: string | undefined) => (!v || v.trim() === '' ? 0 : parseInt(v, 10)) const rowsById = new Map() for (let i = 1; i < lines.length; i++) { const row = lines[i]!.split('\t') const idStr = row[col('Id')] if (idStr !== undefined && idStr.trim() !== '') { rowsById.set(parseInt(idStr, 10), row) } } for (const [idStr, def] of Object.entries(BATCH1_SKILLS)) { const id = Number(idStr) const row = rowsById.get(id) expect(row, `Missing Skills.txt row for skill ${id} (${def.name})`).toBeDefined() if (!row) continue expect(def.mana, `${def.name} (id ${id}) mana`).toBe(num(row[col('mana')])) const expectedMinmana = (def.mana === 0 && def.lvlmana === 0) ? 0 : num(row[col('minmana')]) expect(def.minmana, `${def.name} (id ${id}) minmana`).toBe(expectedMinmana) expect(def.manashift, `${def.name} (id ${id}) manashift`).toBe(num(row[col('manashift')])) expect(def.delay, `${def.name} (id ${id}) delay`).toBe(num(row[col('delay')])) expect(def.hitshift, `${def.name} (id ${id}) hitshift`).toBe(num(row[col('HitShift')])) expect(def.emin, `${def.name} (id ${id}) emin`).toBe(num(row[col('EMin')])) expect(def.emax, `${def.name} (id ${id}) emax`).toBe(num(row[col('EMax')])) expect(def.elev1, `${def.name} (id ${id}) elev1`).toBe(num(row[col('EMinLev1')])) expect(def.elev2, `${def.name} (id ${id}) elev2`).toBe(num(row[col('EMinLev2')])) expect(def.elev3, `${def.name} (id ${id}) elev3`).toBe(num(row[col('EMinLev3')])) expect(def.elev4, `${def.name} (id ${id}) elev4`).toBe(num(row[col('EMinLev4')])) expect(def.elev5, `${def.name} (id ${id}) elev5`).toBe(num(row[col('EMinLev5')])) expect(def.emaxlev1 ?? def.elev1, `${def.name} (id ${id}) emaxlev1`).toBe(num(row[col('EMaxLev1')]) || num(row[col('EMinLev1')])) expect(def.emaxlev2 ?? def.elev2, `${def.name} (id ${id}) emaxlev2`).toBe(num(row[col('EMaxLev2')]) || num(row[col('EMinLev2')])) expect(def.emaxlev3 ?? def.elev3, `${def.name} (id ${id}) emaxlev3`).toBe(num(row[col('EMaxLev3')]) || num(row[col('EMinLev3')])) expect(def.emaxlev4 ?? def.elev4, `${def.name} (id ${id}) emaxlev4`).toBe(num(row[col('EMaxLev4')]) || num(row[col('EMinLev4')])) expect(def.emaxlev5 ?? def.elev5, `${def.name} (id ${id}) emaxlev5`).toBe(num(row[col('EMaxLev5')]) || num(row[col('EMinLev5')])) expect(def.srcDamage, `${def.name} (id ${id}) srcDamage`).toBe(num(row[col('SrcDam')])) } }) }) describe('Mana Cost Scaling & 24.8 Fixed-Point Clamping', () => { it('scales standard spell mana costs accurately', () => { // Fire Bolt (36): base 2.5 (shift 640), lvlmana 0 -> constant 2.5 mana across levels expect(getSkillManaCost(36, 1)).toBe(2.5) expect(getSkillManaCost(36, 5)).toBe(2.5) expect(getSkillManaCost(36, 9)).toBe(2.5) // Teeth (67): base 3.0 (shift 768), lvlmana 1 (shift 128 = 0.5 per level) expect(getSkillManaCost(67, 1)).toBe(3.0) expect(getSkillManaCost(67, 5)).toBe(5.0) expect(getSkillManaCost(67, 9)).toBe(7.0) }) it('handles decreasing mana costs reaching exactly 0.0 mana', () => { // Magic Arrow (6): base 1.5, manaadd -0.125, minmana = 0 expect(getSkillManaCost(6, 1)).toBe(1.5) expect(getSkillManaCost(6, 5)).toBe(1.0) expect(getSkillManaCost(6, 9)).toBe(0.5) expect(getSkillManaCost(6, 13)).toBe(0.0) expect(getSkillManaCost(6, 20)).toBe(0.0) // Double Swing (133): base 1.0, manaadd -0.125, minmana = 0 expect(getSkillManaCost(133, 1)).toBe(1.0) expect(getSkillManaCost(133, 5)).toBe(0.5) expect(getSkillManaCost(133, 9)).toBe(0.0) expect(getSkillManaCost(133, 20)).toBe(0.0) }) it('clamps decreasing mana cost at minmana = 1 for Teleport', () => { // Teleport (54): base 24, manaadd -1, minmana = 1 expect(getSkillManaCost(54, 1)).toBe(24) expect(getSkillManaCost(54, 10)).toBe(15) expect(getSkillManaCost(54, 23)).toBe(2) expect(getSkillManaCost(54, 24)).toBe(1) expect(getSkillManaCost(54, 30)).toBe(1) expect(getSkillManaCost(54, 50)).toBe(1) }) it('returns 0 mana for passives and zero-cost auras', () => { expect(getSkillManaCost(37, 1)).toBe(0) // Warmth expect(getSkillManaCost(98, 1)).toBe(0) // Might expect(getSkillManaCost(224, 1)).toBe(0) // Lycanthropy expect(getSkillManaCost(252, 1)).toBe(0) // Claw Mastery }) }) describe('Casting Delay & Cooldown Mechanics', () => { it('returns exact 1.13c frame delays for skills with casting delays', () => { // Werewolf: 25 frames (1.0 sec) expect(getSkillCooldownTicks(223)).toBe(25) // Firestorm: 15 frames (0.6 sec) expect(getSkillCooldownTicks(225)).toBe(15) // Molten Boulder: 50 frames (2.0 sec) expect(getSkillCooldownTicks(229)).toBe(50) }) it('returns 0 frames for skills without casting delays', () => { expect(getSkillCooldownTicks(36)).toBe(0) // Fire Bolt expect(getSkillCooldownTicks(47)).toBe(0) // Fire Ball expect(getSkillCooldownTicks(54)).toBe(0) // Teleport expect(getSkillCooldownTicks(106)).toBe(0) // Zeal expect(getSkillCooldownTicks(112)).toBe(0) // Blessed Hammer }) }) describe('Damage Calculation & Synergies', () => { it('calculates spell damage across level bands with HitShift parity', () => { // Fire Bolt (36): HitShift 7 (0.5x), base raw min 6 (3.0), max 12 (6.0) const fbL1 = calculateSkillDamage(36, 1) expect(fbL1.min).toBe(3) expect(fbL1.max).toBe(6) // Fire Bolt level 5: +4 levels * (min +3 raw (1.5), max +3 raw (1.5)) // min = (6 + 12) * 0.5 = 9, max = (12 + 12) * 0.5 = 12 const fbL5 = calculateSkillDamage(36, 5) expect(fbL5.min).toBe(9) expect(fbL5.max).toBe(12) // Ice Bolt (39): HitShift 7, base raw min 6 (3.0), max 10 (5.0) const ibL1 = calculateSkillDamage(39, 1) expect(ibL1.min).toBe(3) expect(ibL1.max).toBe(5) // Teeth (67): HitShift 7, base raw min 4 (2.0), max 8 (4.0) const teethL1 = calculateSkillDamage(67, 1) expect(teethL1.min).toBe(2) expect(teethL1.max).toBe(4) }) it('multiplies base damage by synergyMultiplier accurately', () => { const fbBase = calculateSkillDamage(47, 20) // Fire Ball L20 const synergyMult = 1.28 // +28% synergy bonus const fbSynergized = calculateSkillDamage(47, 20, synergyMult) // Fire Ball L20: raw min 399 * 0.5 = 199.5, raw max 453 * 0.5 = 226.5. In 1.13c, synergy scales unfloored base: // floor(199.5 * 1.28) = 255, floor(226.5 * 1.28) = 289 expect(fbSynergized.min).toBe(Math.floor(199.5 * synergyMult)) expect(fbSynergized.max).toBe(Math.floor(226.5 * synergyMult)) // Blessed Hammer L20 with 2.10 synergy (Blessed Aim + Vigor) const bhBase = calculateSkillDamage(112, 20) const bhSynergized = calculateSkillDamage(112, 20, 2.10) expect(bhSynergized.min).toBe(Math.floor(bhBase.min * 2.10)) expect(bhSynergized.max).toBe(Math.floor(bhBase.max * 2.10)) }) it('scales weapon damage for weapon-dependent skills', () => { const weapon = { min: 10, max: 20 } // Jab (10): srcDamage 128 (100% weapon damage) const jab = calculateSkillDamage(10, 1, 1.0, weapon) expect(jab.min).toBe(10) expect(jab.max).toBe(20) // Bash (126): srcDamage 128 const bash = calculateSkillDamage(126, 1, 1.0, weapon) expect(bash.min).toBe(10) expect(bash.max).toBe(20) // Sacrifice (96): srcDamage 128 const sac = calculateSkillDamage(96, 1, 1.0, weapon) expect(sac.min).toBe(10) expect(sac.max).toBe(20) }) }) describe('Skill Execution Mechanics Across Archetypes', () => { it('blocks execution when cooldown casting delay is active', () => { const player = makeTestPlayer({ cooldown: 15 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 5, hardPoints: 5, synergyMultiplier: 1.0, } // Firestorm (225) has a 15-frame delay const res = executeSkill(225, ctx) expect(res.kind).toBe('cooldown') if (res.kind === 'cooldown') { expect(res.ticksLeft).toBe(15) } }) it('blocks execution when player mana is insufficient', () => { const player = makeTestPlayer({ mana: 5 }) // Teleport needs 24 mana at L1 const ctx: SkillExecutionContext = { caster: player, skillLevel: 1, hardPoints: 1, synergyMultiplier: 1.0, } const res = executeSkill(54, ctx) expect(res.kind).toBe('mana') if (res.kind === 'mana') { expect(res.cost).toBe(24) expect(res.currentMana).toBe(5) } // Mana was not deducted expect(player.mana).toBe(5) }) it('executes projectile skill and spawns valid projectile entity', () => { const player = makeTestPlayer({ mana: 50, x: 100, y: 100, facing: 0 }) const target = { x: 200, y: 100 } // Directly to the right const ctx: SkillExecutionContext = { caster: player, target, skillLevel: 5, hardPoints: 5, synergyMultiplier: 1.0, } const res = executeSkill(36, ctx) // Fire Bolt expect(res.kind).toBe('projectile') if (res.kind === 'projectile') { expect(res.projectile).toBeDefined() expect(res.projectile.x).toBe(100) expect(res.projectile.y).toBe(100) expect(res.projectile.vx).toBeGreaterThan(0) expect(res.projectile.vy).toBeCloseTo(0, 4) expect(res.projectile.fromPlayer).toBe(true) expect(res.manaCost).toBe(2.5) } expect(player.mana).toBe(47.5) // 50 - 2.5 }) it('executes multi-hit melee skills (Jab, Double Swing, Zeal)', () => { const player = makeTestPlayer({ mana: 50 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } // Jab (10): always 3 hits const jab = executeSkill(10, ctx) expect(jab.kind).toBe('melee') if (jab.kind === 'melee') expect(jab.hits).toBe(3) // Double Swing (133): always 2 hits const ds = executeSkill(133, ctx) expect(ds.kind).toBe('melee') if (ds.kind === 'melee') expect(ds.hits).toBe(2) // Zeal (106): level 1 -> 2 hits, level 3 -> 4 hits, level 4+ -> 5 hits cap const zeal1 = executeSkill(106, { caster: player, skillLevel: 1 }) if (zeal1.kind === 'melee') expect(zeal1.hits).toBe(2) const zeal3 = executeSkill(106, { caster: player, skillLevel: 3 }) if (zeal3.kind === 'melee') expect(zeal3.hits).toBe(4) const zeal4 = executeSkill(106, { caster: player, skillLevel: 4 }) if (zeal4.kind === 'melee') expect(zeal4.hits).toBe(5) const zeal20 = executeSkill(106, { caster: player, skillLevel: 20 }) if (zeal20.kind === 'melee') expect(zeal20.hits).toBe(5) }) it('executes special melee mechanics (Knockback, Auto-Hit, Self-Damage)', () => { const player = makeTestPlayer({ mana: 50 }) // Bash (126): knockback const bash = executeSkill(126, { caster: player, skillLevel: 1 }) if (bash.kind === 'melee') expect(bash.knockback).toBe(true) // Smite (97): autoHit and knockback const smite = executeSkill(97, { caster: player, skillLevel: 1 }) if (smite.kind === 'melee') { expect(smite.autoHit).toBe(true) expect(smite.knockback).toBe(true) } // Sacrifice (96): 8% self-damage const sac = executeSkill(96, { caster: player, skillLevel: 1 }) if (sac.kind === 'melee') expect(sac.selfDamagePct).toBe(8) }) it('executes area effect skills with appropriate radii', () => { const player = makeTestPlayer({ mana: 50 }) // Frost Nova (44): radius 240 const fn = executeSkill(44, { caster: player, skillLevel: 1 }) expect(fn.kind).toBe('area') if (fn.kind === 'area') expect(fn.radius).toBe(240) // Corpse Explosion (74): radius 80 + 20*(lvl-1) const ce1 = executeSkill(74, { caster: player, skillLevel: 1 }) if (ce1.kind === 'area') expect(ce1.radius).toBe(80) const ce5 = executeSkill(74, { caster: player, skillLevel: 5 }) if (ce5.kind === 'area') expect(ce5.radius).toBe(160) }) it('executes buff skills with duration and stat effects', () => { const player = makeTestPlayer({ mana: 50 }) // Bone Armor (68) const ba = executeSkill(68, { caster: player, skillLevel: 3 }) expect(ba.kind).toBe('buff') if (ba.kind === 'buff') { expect(ba.buffEffect.damageAbsorption).toBe(40) // 20 + 10*(3-1) } // Shout (138) const shout = executeSkill(138, { caster: player, skillLevel: 2 }) expect(shout.kind).toBe('buff') if (shout.kind === 'buff') { expect(shout.buffEffect.defensePct).toBe(110) // 100 + 10*(2-1) } // Burst of Speed (258) const bos = executeSkill(258, { caster: player, skillLevel: 1 }) expect(bos.kind).toBe('buff') if (bos.kind === 'buff') { expect(bos.buffEffect.attackSpeedPct).toBe(20) expect(bos.buffEffect.walkRunSpeedPct).toBe(20) } }) it('executes curse debuff skills (Amplify Damage, Howl)', () => { const player = makeTestPlayer({ mana: 50 }) // Amplify Damage (66): physicalResistPenaltyPct = 100 const amp = executeSkill(66, { caster: player, skillLevel: 1 }) expect(amp.kind).toBe('curse') if (amp.kind === 'curse') { expect(amp.debuffEffect.physicalResistPenaltyPct).toBe(100) expect(amp.radius).toBe(80) } // Howl (130): flee = 1 const howl = executeSkill(130, { caster: player, skillLevel: 1 }) expect(howl.kind).toBe('curse') if (howl.kind === 'curse') { expect(howl.debuffEffect.flee).toBe(1) } }) it('executes movement skills with proper destination coordinates', () => { const player = makeTestPlayer({ mana: 50, x: 200, y: 300 }) const target = { x: 450, y: 600 } const tp = executeSkill(54, { caster: player, target, skillLevel: 1 }) expect(tp.kind).toBe('movement') if (tp.kind === 'movement') { expect(tp.destination).toEqual({ x: 450, y: 600 }) } }) it('executes summon skills with proper minion counts', () => { const player = makeTestPlayer({ mana: 50 }) // Raise Skeleton (70): 1 at L1..2, 2 at L3..5, 3 at L6..8, etc. const rs1 = executeSkill(70, { caster: player, skillLevel: 1 }) expect(rs1.kind).toBe('summon') if (rs1.kind === 'summon') { expect(rs1.summonType).toBe('skeleton') expect(rs1.count).toBe(1) } const rs6 = executeSkill(70, { caster: player, skillLevel: 6 }) if (rs6.kind === 'summon') { expect(rs6.count).toBe(3) // 1 + floor(6/3) } // Oak Sage (226) const oak = executeSkill(226, { caster: player, skillLevel: 1 }) if (oak.kind === 'summon') { expect(oak.summonType).toBe('oak_sage') expect(oak.count).toBe(1) } }) it('executes aura and passive skills', () => { const player = makeTestPlayer({ mana: 50 }) // Might (98): aura const might = executeSkill(98, { caster: player, skillLevel: 1 }) expect(might.kind).toBe('aura') if (might.kind === 'aura') { expect(might.auraBonusPct).toBe(40) } // Warmth (37): passive const warmth = executeSkill(37, { caster: player, skillLevel: 1 }) expect(warmth.kind).toBe('passive') if (warmth.kind === 'passive') { expect(warmth.passiveBonus.manaRegenPct).toBe(30) } // Claw Mastery (252): passive const cm = executeSkill(252, { caster: player, skillLevel: 1 }) expect(cm.kind).toBe('passive') if (cm.kind === 'passive') { expect(cm.passiveBonus.clawDamagePct).toBe(35) } }) it('respects deductResources: false option', () => { const player = makeTestPlayer({ mana: 100, cooldown: 0 }) const ctx: SkillExecutionContext = { caster: player, skillLevel: 1 } // Werewolf (223) costs 15 mana and has 25f cooldown const res = executeSkill(223, ctx, { deductResources: false }) expect(res.kind).toBe('buff') expect(player.mana).toBe(100) // Unchanged expect(player.cooldown).toBe(0) // Unchanged }) }) describe('Adversarial & Input Hardening Protections', () => { it('gracefully handles NaN and non-finite skill levels without throwing', () => { expect(getSkillManaCost(36, NaN)).toBe(2.5) expect(getSkillManaCost(36, -5)).toBe(2.5) expect(getSkillManaCost(36, Infinity)).toBeDefined() const dmg = calculateSkillDamage(36, NaN) expect(Number.isFinite(dmg.min)).toBe(true) expect(Number.isFinite(dmg.max)).toBe(true) }) it('gracefully handles NaN synergy multiplier', () => { const dmg = calculateSkillDamage(47, 20, NaN) expect(Number.isFinite(dmg.min)).toBe(true) expect(Number.isFinite(dmg.max)).toBe(true) expect(dmg.min).toBeGreaterThan(0) }) it('handles zero-distance target vector without generating NaN', () => { const player = makeTestPlayer({ x: 100, y: 100, facing: 0 }) const res = executeSkill(36, { caster: player, target: { x: 100, y: 100 }, // Same as caster skillLevel: 1, }) if (res.kind === 'projectile') { expect(Number.isFinite(res.projectile.vx)).toBe(true) expect(Number.isFinite(res.projectile.vy)).toBe(true) } }) }) })