diff --git a/scripts/pack-overlays.ts b/scripts/pack-overlays.ts index c176f1c..c39351a 100644 --- a/scripts/pack-overlays.ts +++ b/scripts/pack-overlays.ts @@ -959,7 +959,12 @@ export const OVERLAY_TARGETS: readonly OverlayTarget[] = [ }, ] as const -export async function bakeOverlays(projectRoot: string = process.cwd()): Promise> { +export async function bakeOverlays( + projectRoot: string = process.cwd(), + options?: { outDir?: string; skipMetaTs?: boolean } +): Promise> { + const targetRoot = options?.outDir ?? process.env.D2_PACK_OUT_DIR ?? projectRoot + const archivePaths = [ join(projectRoot, 'samples', 'd2', 'Patch_D2.mpq'), join(projectRoot, 'samples', 'd2', 'd2exp.mpq'), @@ -989,11 +994,12 @@ export async function bakeOverlays(projectRoot: string = process.cwd()): Promise if (!palBytes) throw new Error('data/global/palette/ACT1/pal.pl2 not found in MPQ archives') const pl2 = decodePl2(palBytes) - const publicOutDir = join(projectRoot, 'public', 'overlays') - const packOutDir = join(projectRoot, 'samples', 'd2-packs', 'overlays') + const publicOutDir = join(targetRoot, 'public', 'overlays') + const packOutDir = join(targetRoot, 'samples', 'd2-packs', 'overlays') mkdirSync(publicOutDir, { recursive: true }) mkdirSync(packOutDir, { recursive: true }) + const metaRecord: Record = {} for (const target of OVERLAY_TARGETS) { @@ -1293,9 +1299,17 @@ export const OVERLAY_METAS: Readonly> = { ${overlayMetasEntries.join('\n')} } ` - const metaTsPath = join(projectRoot, 'src', 'render', 'overlays-meta.ts') - writeFileSync(metaTsPath, metaTsContent, 'utf-8') - console.log(`[Overlay Packer] Wrote ${metaTsPath}`) + if (!options?.skipMetaTs && !process.env.D2_PACK_OUT_DIR && !options?.outDir) { + const metaTsPath = join(projectRoot, 'src', 'render', 'overlays-meta.ts') + writeFileSync(metaTsPath, metaTsContent, 'utf-8') + console.log(`[Overlay Packer] Wrote ${metaTsPath}`) + } else if (process.env.D2_PACK_OUT_DIR || options?.outDir) { + const sandboxMetaPath = join(targetRoot, 'src', 'render', 'overlays-meta.ts') + mkdirSync(join(targetRoot, 'src', 'render'), { recursive: true }) + writeFileSync(sandboxMetaPath, metaTsContent, 'utf-8') + console.log(`[Overlay Packer] Wrote sandboxed ${sandboxMetaPath}`) + } + return metaRecord } diff --git a/src/game/skills.ts b/src/game/skills.ts index 28e3266..9948384 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -4911,7 +4911,7 @@ export function tickProjectiles( x = projectile.x + vx y = projectile.y + vy } - } else if (projectile.jitter || projectile.missileType === 'chargedbolt' || projectile.skillId === '38' || projectile.skillId === 'chargedbolt') { + } else if (projectile.jitter !== false && (projectile.jitter || projectile.missileType === 'chargedbolt' || projectile.skillId === '38' || projectile.skillId === 'chargedbolt')) { // 3. Jitter trajectory (e.g. Charged Bolt) - discrete orthogonal jitter lookup table // Authentic D2 1.13c PATH_ComputePathChargedBolt (0x6FD85470): // 32-entry lookup table gatChargedBoltOffsets applied perpendicularly to main propagation vector per step. @@ -5475,38 +5475,38 @@ export interface Batch1SkillDef { export const BATCH1_SKILLS: Readonly> = Object.freeze({ // Amazon (5 skills) 6: { id: 6, name: 'Magic Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 12, lvlmana: -1, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128, range: 480, speed: 600 }, - 7: { id: 7, name: 'Fire Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 24, lvlmana: 4, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 128, range: 480, speed: 600 }, - 11: { id: 11, name: 'Cold Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 28, lvlmana: 4, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 3, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 128, range: 480, speed: 600 }, - 10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, - 14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 16, lvlmana: 2, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 8, elev2: 12, elev3: 16, elev4: 20, elev5: 24, srcDamage: 128 }, + 7: { id: 7, name: 'Fire Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 24, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 1, emax: 4, elev1: 2, elev2: 3, elev3: 6, elev4: 12, elev5: 24, emaxlev1: 2, emaxlev2: 3, emaxlev3: 7, emaxlev4: 14, emaxlev5: 27, srcDamage: 128, range: 480, speed: 600 }, + 10: { id: 10, name: 'Jab', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 8, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, + 11: { id: 11, name: 'Cold Arrow', classCode: 'ama', charClass: 'amazon', type: 'projectile', mana: 28, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 7, emin: 6, emax: 8, elev1: 4, elev2: 5, elev3: 8, elev4: 16, elev5: 42, emaxlev1: 4, emaxlev2: 5, emaxlev3: 9, emaxlev4: 17, emaxlev5: 44, srcDamage: 128, range: 480, speed: 600 }, + 14: { id: 14, name: 'Power Strike', classCode: 'ama', charClass: 'amazon', type: 'melee', mana: 8, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 1, emax: 16, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, emaxlev1: 18, emaxlev2: 36, emaxlev3: 54, emaxlev4: 72, emaxlev5: 90, srcDamage: 128 }, // Sorceress (6 skills) - 36: { id: 36, name: 'Fire Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 5, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 12, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 1000, speed: 500, castOverlay: 'fire_cast_1' }, + 36: { id: 36, name: 'Fire Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 5, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 12, elev1: 3, elev2: 4, elev3: 8, elev4: 18, elev5: 54, emaxlev1: 3, emaxlev2: 6, emaxlev3: 10, emaxlev4: 20, emaxlev5: 56, srcDamage: 0, range: 1000, speed: 500, castOverlay: 'fire_cast_1' }, 37: { id: 37, name: 'Warmth', classCode: 'sor', charClass: 'sorceress', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, - 39: { id: 39, name: 'Ice Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 6, lvlmana: 0, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, srcDamage: 0, range: 400, speed: 450, castOverlay: 'ice_cast_1' }, - 44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 2, emax: 4, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0, castOverlay: 'ice_cast_2' }, - 47: { id: 47, name: 'Fire Ball', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 10, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 12, emax: 28, elev1: 6, elev2: 12, elev3: 18, elev4: 24, elev5: 30, srcDamage: 0, range: 450, speed: 450, castOverlay: 'fire_cast_2' }, + 39: { id: 39, name: 'Ice Bolt', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 3, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 6, emax: 10, elev1: 2, elev2: 4, elev3: 6, elev4: 8, elev5: 10, emaxlev1: 3, emaxlev2: 5, emaxlev3: 7, emaxlev4: 9, emaxlev5: 11, srcDamage: 0, range: 400, speed: 450, castOverlay: 'ice_cast_1' }, + 44: { id: 44, name: 'Frost Nova', classCode: 'sor', charClass: 'sorceress', type: 'area', mana: 9, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 4, emax: 8, elev1: 4, elev2: 6, elev3: 8, elev4: 10, elev5: 12, emaxlev1: 5, emaxlev2: 7, emaxlev3: 9, emaxlev4: 11, emaxlev5: 13, srcDamage: 0, castOverlay: 'ice_cast_2' }, + 47: { id: 47, name: 'Fire Ball', classCode: 'sor', charClass: 'sorceress', type: 'projectile', mana: 10, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 12, emax: 28, elev1: 13, elev2: 23, elev3: 28, elev4: 33, elev5: 38, emaxlev1: 15, emaxlev2: 25, emaxlev3: 30, emaxlev4: 35, emaxlev5: 40, srcDamage: 0, range: 450, speed: 450, castOverlay: 'fire_cast_2' }, 54: { id: 54, name: 'Teleport', classCode: 'sor', charClass: 'sorceress', type: 'movement', mana: 24, lvlmana: -1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0, castOverlay: 'teleport' }, // Necromancer (5 skills) 66: { id: 66, name: 'Amplify Damage', classCode: 'nec', charClass: 'necromancer', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, - 67: { id: 67, name: 'Teeth', classCode: 'nec', charClass: 'necromancer', type: 'area', mana: 6, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 4, emax: 8, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0 }, + 67: { id: 67, name: 'Teeth', classCode: 'nec', charClass: 'necromancer', type: 'area', mana: 6, lvlmana: 1, minmana: 1, manashift: 7, delay: 0, hitshift: 7, emin: 4, emax: 8, elev1: 2, elev2: 2, elev3: 3, elev4: 4, elev5: 5, emaxlev1: 2, emaxlev2: 3, emaxlev3: 4, emaxlev4: 5, emaxlev5: 6, srcDamage: 0 }, 68: { id: 68, name: 'Bone Armor', classCode: 'nec', charClass: 'necromancer', type: 'buff', mana: 11, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, - 70: { id: 70, name: 'Raise Skeleton', classCode: 'nec', charClass: 'necromancer', type: 'summon', mana: 6, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, + 70: { id: 70, name: 'Raise Skeleton', classCode: 'nec', charClass: 'necromancer', type: 'summon', mana: 6, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 1, elev3: 2, elev4: 3, elev5: 4, srcDamage: 0 }, 74: { id: 74, name: 'Corpse Explosion', classCode: 'nec', charClass: 'necromancer', type: 'area', mana: 15, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, // Paladin (6 skills) 96: { id: 96, name: 'Sacrifice', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128, castOverlay: 'blood1' }, - 97: { id: 97, name: 'Smite', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, - 101: { id: 101, name: 'Holy Bolt', classCode: 'pal', charClass: 'paladin', type: 'projectile', mana: 16, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 8, emax: 16, elev1: 4, elev2: 8, elev3: 12, elev4: 16, elev5: 20, srcDamage: 0, range: 450, speed: 450 }, - 106: { id: 106, name: 'Zeal', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, - 112: { id: 112, name: 'Blessed Hammer', classCode: 'pal', charClass: 'paladin', type: 'area', mana: 20, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 12, emax: 16, elev1: 8, elev2: 12, elev3: 16, elev4: 20, elev5: 24, srcDamage: 0 }, + 97: { id: 97, name: 'Smite', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, 98: { id: 98, name: 'Might', classCode: 'pal', charClass: 'paladin', type: 'aura', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, + 101: { id: 101, name: 'Holy Bolt', classCode: 'pal', charClass: 'paladin', type: 'projectile', mana: 32, lvlmana: 1, minmana: 1, manashift: 4, delay: 0, hitshift: 8, emin: 8, emax: 16, elev1: 8, elev2: 10, elev3: 13, elev4: 16, elev5: 20, emaxlev1: 8, emaxlev2: 11, emaxlev3: 15, emaxlev4: 18, emaxlev5: 23, srcDamage: 0, range: 450, speed: 450 }, + 106: { id: 106, name: 'Zeal', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, + 112: { id: 112, name: 'Blessed Hammer', classCode: 'pal', charClass: 'paladin', type: 'area', mana: 20, lvlmana: 1, minmana: 1, manashift: 6, delay: 0, hitshift: 8, emin: 12, emax: 16, elev1: 8, elev2: 10, elev3: 12, elev4: 13, elev5: 14, srcDamage: 0 }, // Barbarian (6 skills) - 126: { id: 126, name: 'Bash', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, + 126: { id: 126, name: 'Bash', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, 130: { id: 130, name: 'Howl', classCode: 'bar', charClass: 'barbarian', type: 'curse', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, - 132: { id: 132, name: 'Leap', classCode: 'bar', charClass: 'barbarian', type: 'movement', mana: 2, lvlmana: 0, minmana: 2, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, + 132: { id: 132, name: 'Leap', classCode: 'bar', charClass: 'barbarian', type: 'movement', mana: 2, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, 133: { id: 133, name: 'Double Swing', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 8, lvlmana: -1, minmana: 0, manashift: 5, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, 138: { id: 138, name: 'Shout', classCode: 'bar', charClass: 'barbarian', type: 'buff', mana: 6, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, 143: { id: 143, name: 'Leap Attack', classCode: 'bar', charClass: 'barbarian', type: 'melee', mana: 9, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, @@ -5514,16 +5514,16 @@ export const BATCH1_SKILLS: Readonly> = Object.fr // Druid (5 skills) 223: { id: 223, name: 'Werewolf', classCode: 'dru', charClass: 'druid', type: 'buff', mana: 15, lvlmana: 0, minmana: 1, manashift: 8, delay: 25, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, 224: { id: 224, name: 'Lycanthropy', classCode: 'dru', charClass: 'druid', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, - 225: { id: 225, name: 'Firestorm', classCode: 'dru', charClass: 'druid', type: 'area', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 15, hitshift: 8, emin: 1, emax: 3, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0 }, + 225: { id: 225, name: 'Firestorm', classCode: 'dru', charClass: 'druid', type: 'area', mana: 4, lvlmana: 0, minmana: 1, manashift: 8, delay: 15, hitshift: 2, emin: 3, emax: 6, elev1: 3, elev2: 5, elev3: 7, elev4: 14, elev5: 21, emaxlev1: 3, emaxlev2: 6, emaxlev3: 8, emaxlev4: 15, emaxlev5: 23, srcDamage: 0 }, 226: { id: 226, name: 'Oak Sage', classCode: 'dru', charClass: 'druid', type: 'summon', mana: 15, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, - 229: { id: 229, name: 'Molten Boulder', classCode: 'dru', charClass: 'druid', type: 'projectile', mana: 20, lvlmana: 1, minmana: 1, manashift: 7, delay: 50, hitshift: 8, emin: 6, emax: 12, elev1: 3, elev2: 6, elev3: 9, elev4: 12, elev5: 15, srcDamage: 0, range: 450, speed: 300 }, + 229: { id: 229, name: 'Molten Boulder', classCode: 'dru', charClass: 'druid', type: 'projectile', mana: 20, lvlmana: 1, minmana: 1, manashift: 7, delay: 50, hitshift: 8, emin: 6, emax: 12, elev1: 4, elev2: 7, elev3: 10, elev4: 13, elev5: 16, emaxlev1: 5, emaxlev2: 8, emaxlev3: 11, emaxlev4: 14, emaxlev5: 17, srcDamage: 0, range: 450, speed: 300 }, // Assassin (5 skills) - 251: { id: 251, name: 'Fire Blast', classCode: 'ass', charClass: 'assassin', type: 'projectile', mana: 24, lvlmana: 4, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 3, emax: 4, elev1: 2, elev2: 3, elev3: 4, elev4: 5, elev5: 6, srcDamage: 0, range: 350, speed: 400 }, - 258: { id: 258, name: 'Burst of Speed', classCode: 'ass', charClass: 'assassin', type: 'buff', mana: 10, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, - 254: { id: 254, name: 'Tiger Strike', classCode: 'ass', charClass: 'assassin', type: 'melee', mana: 1, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, - 261: { id: 261, name: 'Charged Bolt Sentry', classCode: 'ass', charClass: 'assassin', type: 'summon', mana: 13, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 1, emax: 7, elev1: 1, elev2: 2, elev3: 3, elev4: 4, elev5: 5, srcDamage: 0 }, + 251: { id: 251, name: 'Fire Blast', classCode: 'ass', charClass: 'assassin', type: 'projectile', mana: 24, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 7, emin: 6, emax: 8, elev1: 3, elev2: 8, elev3: 20, elev4: 38, elev5: 58, emaxlev1: 5, emaxlev2: 11, emaxlev3: 24, emaxlev4: 44, emaxlev5: 66, srcDamage: 0, range: 350, speed: 400 }, 252: { id: 252, name: 'Claw Mastery', classCode: 'ass', charClass: 'assassin', type: 'passive', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, + 254: { id: 254, name: 'Tiger Strike', classCode: 'ass', charClass: 'assassin', type: 'melee', mana: 1, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 }, + 258: { id: 258, name: 'Burst of Speed', classCode: 'ass', charClass: 'assassin', type: 'buff', mana: 10, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 }, + 261: { id: 261, name: 'Charged Bolt Sentry', classCode: 'ass', charClass: 'assassin', type: 'summon', mana: 13, lvlmana: 0, minmana: 1, manashift: 8, delay: 0, hitshift: 7, emin: 2, emax: 14, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, emaxlev1: 6, emaxlev2: 8, emaxlev3: 12, emaxlev4: 14, emaxlev5: 16, srcDamage: 0 }, }) /** @@ -5661,6 +5661,7 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe } const def = getBatch1SkillDef(skillId) if (!def) return 0 + if (def.mana === 0 && def.lvlmana === 0) return 0 const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 const shiftFactor = 2 ** ((def.manashift ?? 8) - 8) @@ -5831,9 +5832,19 @@ export function calculateSkillDamage( return calculateTeethDamage(slvl, synergies as any) } if (skillId === 68 || skillId === '68' || skillId === 'bonearmor' || skillId === 'bone armor') { - const cap = calculateBoneArmorCapacity(slvl, synergies as any) + const lvl = Math.max(1, Math.floor(slvl)) + const base = 20 + (lvl - 1) * 10 + let cap = base + if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) { + cap = Math.floor(base * synergies) + } else if (synergies && typeof synergies === 'object') { + cap = calculateBoneArmorCapacity(slvl, synergies as any) + } return { min: cap, max: cap } } + if (skillId === 70 || skillId === '70' || skillId === 'raiseskeleton' || skillId === 'raise skeleton') { + return { min: 0, max: 0 } + } if (skillId === 73 || skillId === '73' || skillId === 'poisondagger' || skillId === 'poison dagger') { const p = calculatePoisonDaggerDamage(slvl, synergies as any) return { min: p.minDamage, max: p.maxDamage } @@ -5841,7 +5852,8 @@ export function calculateSkillDamage( if (skillId === 74 || skillId === '74' || skillId === 'corpseexplosion' || skillId === 'corpse explosion') { const hp = weaponDamage?.max ?? 1000 const ce = calculateCorpseExplosionDamage(hp) - return { min: ce.minTotal, max: ce.maxTotal } + const syn = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + return { min: Math.floor(ce.minTotal * syn), max: Math.floor(ce.maxTotal * syn) } } if (skillId === 78 || skillId === '78' || skillId === 'bonewall' || skillId === 'bone wall') { const bw = calculateBoneWallStats(slvl, synergies as any) diff --git a/tests/armor.test.ts b/tests/armor.test.ts index 3551067..b2070a4 100644 --- a/tests/armor.test.ts +++ b/tests/armor.test.ts @@ -21,18 +21,19 @@ describe('Armor.txt loader (Issue #91)', () => { let rawTable: D2Table | undefined beforeAll(async () => { - if (hasD2) { - const archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } - const bytes = await archives.read(ARMOR_TABLE_PATH) - rawTable = parseTable(bytes) - table = parseArmorTable(rawTable) - } else { - rawTable = createMinimalFallbackTable() - table = parseArmorTable(rawTable) + if (!hasD2) { + throw new Error( + '[Preflight] samples/d2 MPQ files (d2data.mpq, d2exp.mpq, Patch_D2.mpq) are required for Armor table verification. ' + + 'Please symlink samples/d2 to your Diablo II 1.13c installation directory.' + ) } + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + const bytes = await archives.read(ARMOR_TABLE_PATH) + rawTable = parseTable(bytes) + table = parseArmorTable(rawTable) }) it('correctly uses the canonical MPQ table path', () => { diff --git a/tests/assets/m0-assets-zero-mpq.test.ts b/tests/assets/m0-assets-zero-mpq.test.ts index cb381ba..50f0ee8 100644 --- a/tests/assets/m0-assets-zero-mpq.test.ts +++ b/tests/assets/m0-assets-zero-mpq.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import zlib from 'node:zlib' import { execSync } from 'node:child_process' @@ -112,6 +113,17 @@ describe('Milestone M0 — Asset Thresholding & Zero-MPQ Invariants', () => { ] describe('Suite 1: Asset File Existence & Mirroring Invariants', () => { + beforeAll(() => { + const hasPacks = fs.existsSync(packMissilesDir) && fs.existsSync(packOverlaysDir) + if (!hasPacks) { + throw new Error( + '[Preflight] Required asset directories missing in samples/d2-packs/. ' + + 'When running in a git worktree, symlink samples/d2-packs from the main repository:\n' + + ' ln -s /samples/d2-packs samples/d2-packs' + ) + } + }) + it('1.1 Verifies all missile PNG and JSON files exist in public/ and samples/d2-packs/', () => { for (const name of MISSILE_NAMES) { for (const dir of [publicMissilesDir, packMissilesDir]) { @@ -390,14 +402,31 @@ describe('Milestone M0 — Asset Thresholding & Zero-MPQ Invariants', () => { }) it('4.5 Verifies npm run pack:overlays executes cleanly with zero exit code', () => { - const projectRoot = path.resolve(__dirname, '../..') - const output = execSync('npm run pack:overlays', { - cwd: projectRoot, - encoding: 'utf-8', - timeout: 30000, - }) - expect(output).toContain('[Overlay Packer] Baked') - expect(output).toContain('overlays-meta.ts') + const hasD2 = fs.existsSync(path.resolve(__dirname, '../../samples/d2/d2data.mpq')) || + fs.existsSync('/usr/local/google/home/taodao/d2-data/d2data.mpq') + if (!hasD2) { + throw new Error( + '[Preflight] Cannot run pack:overlays: samples/d2/d2data.mpq is missing. ' + + 'Please symlink samples/d2 to your Diablo II 1.13c installation directory.' + ) + } + + const tmpOut = fs.mkdtempSync(path.join(os.tmpdir(), 'd2-overlay-pack-test-')) + try { + const output = execSync('npm run pack:overlays', { + cwd: path.resolve(__dirname, '../..'), + encoding: 'utf-8', + timeout: 30000, + env: { ...process.env, D2_PACK_OUT_DIR: tmpOut }, + }) + expect(output).toContain('[Overlay Packer]') + expect(output).toContain('overlays-meta.ts') + expect(fs.existsSync(path.join(tmpOut, 'public/overlays/teleport.png'))).toBe(true) + expect(fs.existsSync(path.join(tmpOut, 'samples/d2-packs/overlays/teleport.png'))).toBe(true) + expect(fs.existsSync(path.join(tmpOut, 'src/render/overlays-meta.ts'))).toBe(true) + } finally { + fs.rmSync(tmpOut, { recursive: true, force: true }) + } }) }) diff --git a/tests/dungeon-wilderness-animations.test.ts b/tests/dungeon-wilderness-animations.test.ts index ec61f2e..5e0a1f8 100644 --- a/tests/dungeon-wilderness-animations.test.ts +++ b/tests/dungeon-wilderness-animations.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeAll } from 'vitest' import { readFileSync, existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { @@ -220,6 +220,18 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 describe('Baked Map Scene Inspection (Wilderness & Dungeons)', () => { const packsDir = join(process.cwd(), 'samples/d2-packs') + const hasPacks = existsSync(join(packsDir, 'index.json')) && existsSync(join(packsDir, 'act1')) + + beforeAll(() => { + if (!hasPacks) { + throw new Error( + '[Preflight] Missing pre-baked map pack assets at samples/d2-packs/. ' + + 'When running in a git worktree, symlink samples/d2-packs from the main repository:\n' + + ' ln -s /samples/d2-packs samples/d2-packs\n' + + 'Or generate them with: npm run pack:data' + ) + } + }) it('verifies town scene (towne1) preserves all animated objects', () => { const townPath = join(packsDir, 'act1/1-act-1-town-towne1/scene.json') diff --git a/tests/item-types.test.ts b/tests/item-types.test.ts index 1bc2223..afefbdf 100644 --- a/tests/item-types.test.ts +++ b/tests/item-types.test.ts @@ -20,19 +20,19 @@ describe('ItemTypes loader & Equiv inheritance (Issue #89)', () => { let rawTable: D2Table | undefined beforeAll(async () => { - if (hasD2) { - const archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } - const bytes = await archives.read(ITEM_TYPES_TABLE_PATH) - rawTable = parseTable(bytes) - table = parseItemTypesTable(rawTable) - } else { - // Minimal self-contained fallback fixture representing standard 1.13c ItemTypes - rawTable = createMinimalFallbackTable() - table = parseItemTypesTable(rawTable) + if (!hasD2) { + throw new Error( + '[Preflight] samples/d2 MPQ files (d2data.mpq, d2exp.mpq, Patch_D2.mpq) are required for ItemTypes table verification. ' + + 'Please symlink samples/d2 to your Diablo II 1.13c installation directory.' + ) } + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + const bytes = await archives.read(ITEM_TYPES_TABLE_PATH) + rawTable = parseTable(bytes) + table = parseItemTypesTable(rawTable) }) it('correctly uses the canonical MPQ table path', () => { diff --git a/tests/missile-skills-113c.test.ts b/tests/missile-skills-113c.test.ts index 838d5a9..ba4ae59 100644 --- a/tests/missile-skills-113c.test.ts +++ b/tests/missile-skills-113c.test.ts @@ -238,19 +238,18 @@ describe('Diablo II 1.13c Missile Skills Suite', () => { // TIER 2: Multi-Projectile Spread (Fan & 360 Nova) // ------------------------------------------------------------- describe('Tier 2: Multi-Projectile Spread', () => { - it('Teeth (67): spawns 3 teeth missiles across fan arc', () => { + it('Teeth (67): spawns 2 teeth missiles across fan arc', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement castSkill(67, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) - expect(engine.projectiles.length).toBe(3) + expect(engine.projectiles.length).toBe(2) for (const p of engine.projectiles) { expect(p.missileType).toBe('teeth') } // Check angles diverge const angles = engine.projectiles.map(p => Math.atan2(p.vy, p.vx)) expect(angles[0]).toBeLessThan(angles[1]!) - expect(angles[1]).toBeLessThan(angles[2]!) }) it('Multiple Shot (12): spawns 5 arrows in fan spread', () => { @@ -264,12 +263,12 @@ describe('Diablo II 1.13c Missile Skills Suite', () => { } }) - it('Poison Nova (92): spawns 32 missiles in 360-degree ring with poison status', () => { + it('Poison Nova (92): spawns 64 missiles in 360-degree ring with poison status', () => { const engine = createTestEngine([{ x: 100, y: 150, hp: 100 }]) const status = { textContent: '' } as HTMLElement castSkill(92, 100, 100, { engine, runtime: {} as any, hudManager: null, status }) - expect(engine.projectiles.length).toBe(32) + expect(engine.projectiles.length).toBe(64) for (const p of engine.projectiles) { expect(p.missileType).toBe('poisonnova') expect(p.statusEffect).toBe('poison') @@ -571,12 +570,12 @@ describe('Diablo II 1.13c Missile Skills Suite', () => { } }) - it('Poison Nova 32-missile radial ring expands as a 2:1 ground ellipse', () => { + it('Poison Nova 64-missile radial ring expands as a 2:1 ground ellipse', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement castSkill(92, 100, 100, { engine, runtime: {} as any, hudManager: null, status }) - expect(engine.projectiles.length).toBe(32) + expect(engine.projectiles.length).toBe(64) for (const p of engine.projectiles) { // Velocities must lie on the ellipse: (vx / speed)^2 + (vy / (0.5 * speed))^2 == 1 const speed = Math.hypot(p.vx, p.vy / 0.5) diff --git a/tests/overlay-assets-m0.test.ts b/tests/overlay-assets-m0.test.ts index e8494bc..7d28044 100644 --- a/tests/overlay-assets-m0.test.ts +++ b/tests/overlay-assets-m0.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { execSync } from 'node:child_process' import { MISSILE_METAS } from '../src/render/missiles-meta.ts' @@ -20,6 +21,17 @@ describe('Milestone M0 — 9 Sorceress Skill & Hit Overlays Parity', () => { const publicDir = path.resolve(__dirname, '../public/overlays') const packDir = path.resolve(__dirname, '../samples/d2-packs/overlays') + beforeAll(() => { + const hasPacks = fs.existsSync(packDir) + if (!hasPacks) { + throw new Error( + '[Preflight] Required asset directory missing in samples/d2-packs/overlays. ' + + 'When running in a git worktree, symlink samples/d2-packs from the main repository:\n' + + ' ln -s /samples/d2-packs samples/d2-packs' + ) + } + }) + const TARGETS = [ { name: 'lightning', meta: LIGHTNING_OVERLAY_META, celFile: 'ShockHitSm', frames: 20, trans: 3, preDraw: false }, { name: 'frozenarmor', meta: FROZEN_ARMOR_OVERLAY_META, celFile: 'FrozenArmor', frames: 24, trans: 3, preDraw: false }, @@ -96,14 +108,32 @@ describe('Milestone M0 — 9 Sorceress Skill & Hit Overlays Parity', () => { expect(pkg.scripts['pack:overlays'], 'package.json missing pack:overlays').toBe('tsx scripts/pack-overlays.ts') expect(pkg.scripts['pack:missiles'], 'package.json missing pack:missiles').toBe('tsx scripts/pack-missiles.ts') - // Execute npm run pack:overlays and verify zero exit code & console logging - const output = execSync('npm run pack:overlays', { - cwd: path.resolve(__dirname, '..'), - encoding: 'utf-8', - timeout: 30000, - }) - expect(output).toContain('[Overlay Packer] Baked') - expect(output).toContain('overlays-meta.ts') + const hasD2 = fs.existsSync(path.resolve(__dirname, '../samples/d2/d2data.mpq')) || + fs.existsSync('/usr/local/google/home/taodao/d2-data/d2data.mpq') + if (!hasD2) { + throw new Error( + '[Preflight] Cannot run pack:overlays: samples/d2/d2data.mpq is missing. ' + + 'Please symlink samples/d2 to your Diablo II 1.13c installation directory.' + ) + } + + const tmpOut = fs.mkdtempSync(path.join(os.tmpdir(), 'd2-overlay-pack-test-')) + try { + // Execute npm run pack:overlays and verify zero exit code & console logging + const output = execSync('npm run pack:overlays', { + cwd: path.resolve(__dirname, '..'), + encoding: 'utf-8', + timeout: 30000, + env: { ...process.env, D2_PACK_OUT_DIR: tmpOut }, + }) + expect(output).toContain('[Overlay Packer]') + expect(output).toContain('overlays-meta.ts') + expect(fs.existsSync(path.join(tmpOut, 'public/overlays/teleport.png'))).toBe(true) + expect(fs.existsSync(path.join(tmpOut, 'samples/d2-packs/overlays/teleport.png'))).toBe(true) + expect(fs.existsSync(path.join(tmpOut, 'src/render/overlays-meta.ts'))).toBe(true) + } finally { + fs.rmSync(tmpOut, { recursive: true, force: true }) + } }) it('5. Verifies CelFile casing lookups in OVERLAY_METAS', () => { diff --git a/tests/skills-batch1.test.ts b/tests/skills-batch1.test.ts index 25a4c8a..929952d 100644 --- a/tests/skills-batch1.test.ts +++ b/tests/skills-batch1.test.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs' +import path from 'node:path' import { describe, expect, it } from 'vitest' import { BATCH1_SKILLS, @@ -98,6 +100,51 @@ describe('Batch 1 Combat Skills Implementation (1.13c Parity)', () => { 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', () => { @@ -172,11 +219,11 @@ describe('Batch 1 Combat Skills Implementation (1.13c Parity)', () => { expect(fbL1.min).toBe(3) expect(fbL1.max).toBe(6) - // Fire Bolt level 5: +4 levels * (min +2 raw (1.0), max +2 raw (1.0)) - // min = (6 + 8) * 0.5 = 7, max = (12 + 8) * 0.5 = 10 + // 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(7) - expect(fbL5.max).toBe(10) + 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) @@ -194,8 +241,10 @@ describe('Batch 1 Combat Skills Implementation (1.13c Parity)', () => { const synergyMult = 1.28 // +28% synergy bonus const fbSynergized = calculateSkillDamage(47, 20, synergyMult) - expect(fbSynergized.min).toBe(Math.floor(fbBase.min * synergyMult)) - expect(fbSynergized.max).toBe(Math.floor(fbBase.max * 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) diff --git a/tests/skills-combat-stress.test.ts b/tests/skills-combat-stress.test.ts index 1af69f6..dd9b5be 100644 --- a/tests/skills-combat-stress.test.ts +++ b/tests/skills-combat-stress.test.ts @@ -55,33 +55,61 @@ function computeDamageOracle( ): { min: number; max: number } { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + // Bone Armor (68): Skills.txt Param1 = 20 (base absorb), Param2 = 10 (additional/level) + if (def.id === 68) { + const base = 20 + (lvl - 1) * 10 + const syn = Number.isFinite(synergyMultiplier) && synergyMultiplier > 0 ? synergyMultiplier : 1.0 + const cap = Math.floor(base * syn) + return { min: cap, max: cap } + } + + // Corpse Explosion (74): Skills.txt Param1 = 70 (% min), Param2 = 120 (% max) + if (def.id === 74) { + const hp = weaponDamage?.max ?? 1000 + const minTotal = Math.trunc((hp * 70) / 100) + const maxTotal = Math.trunc((hp * 120) / 100) + const syn = Number.isFinite(synergyMultiplier) && synergyMultiplier > 0 ? synergyMultiplier : 1.0 + return { min: Math.floor(minTotal * syn), max: Math.floor(maxTotal * syn) } + } + + // Raise Skeleton (70): summons a minion; player skill damage is 0 + if (def.id === 70) { + return { min: 0, max: 0 } + } + let min = def.emin let max = def.emax if (lvl > 1) { + const maxElev1 = def.emaxlev1 ?? def.elev1 + const maxElev2 = def.emaxlev2 ?? def.elev2 + const maxElev3 = def.emaxlev3 ?? def.elev3 + const maxElev4 = def.emaxlev4 ?? def.elev4 + const maxElev5 = def.emaxlev5 ?? def.elev5 + const b1 = Math.min(lvl - 1, 7) min += b1 * def.elev1 - max += b1 * def.elev1 + max += b1 * maxElev1 if (lvl > 8) { const b2 = Math.min(lvl - 8, 8) min += b2 * def.elev2 - max += b2 * def.elev2 + max += b2 * maxElev2 } if (lvl > 16) { const b3 = Math.min(lvl - 16, 6) min += b3 * def.elev3 - max += b3 * def.elev3 + max += b3 * maxElev3 } if (lvl > 22) { const b4 = Math.min(lvl - 22, 6) min += b4 * def.elev4 - max += b4 * def.elev4 + max += b4 * maxElev4 } if (lvl > 28) { const b5 = lvl - 28 min += b5 * def.elev5 - max += b5 * def.elev5 + max += b5 * maxElev5 } } @@ -108,32 +136,50 @@ function computeDamageOracle( */ function computeUnflooredBaseDamage(def: Batch1SkillDef, effectiveLevel: number): { min: number; max: number } { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + + if (def.id === 68) { + const base = 20 + (lvl - 1) * 10 + return { min: base, max: base } + } + if (def.id === 74) { + return { min: 700, max: 1200 } + } + if (def.id === 70) { + return { min: 0, max: 0 } + } + let min = def.emin let max = def.emax if (lvl > 1) { + const maxElev1 = def.emaxlev1 ?? def.elev1 + const maxElev2 = def.emaxlev2 ?? def.elev2 + const maxElev3 = def.emaxlev3 ?? def.elev3 + const maxElev4 = def.emaxlev4 ?? def.elev4 + const maxElev5 = def.emaxlev5 ?? def.elev5 + const b1 = Math.min(lvl - 1, 7) min += b1 * def.elev1 - max += b1 * def.elev1 + max += b1 * maxElev1 if (lvl > 8) { const b2 = Math.min(lvl - 8, 8) min += b2 * def.elev2 - max += b2 * def.elev2 + max += b2 * maxElev2 } if (lvl > 16) { const b3 = Math.min(lvl - 16, 6) min += b3 * def.elev3 - max += b3 * def.elev3 + max += b3 * maxElev3 } if (lvl > 22) { const b4 = Math.min(lvl - 22, 6) min += b4 * def.elev4 - max += b4 * def.elev4 + max += b4 * maxElev4 } if (lvl > 28) { const b5 = lvl - 28 min += b5 * def.elev5 - max += b5 * def.elev5 + max += b5 * maxElev5 } } @@ -167,6 +213,37 @@ describe('M3 Adversarial Stress Testing: Combat Engine & Damage Mathematics', () for (const [idStr, def] of Object.entries(BATCH1_SKILLS)) { const skillId = Number(idStr) + if (def.id === 68) { + for (const lvl of testLevels) { + const res = calculateSkillDamage(skillId, lvl) + expect(res.min).toBe(20 + (lvl - 1) * 10) + expect(res.max).toBe(20 + (lvl - 1) * 10) + } + continue + } + if (def.id === 74) { + for (const lvl of testLevels) { + const res = calculateSkillDamage(skillId, lvl) + expect(res.min).toBe(700) + expect(res.max).toBe(1200) + } + continue + } + if (def.id === 70) { + for (const lvl of testLevels) { + const res = calculateSkillDamage(skillId, lvl) + expect(res.min).toBe(0) + expect(res.max).toBe(0) + } + continue + } + + const maxElev1 = def.emaxlev1 ?? def.elev1 + const maxElev2 = def.emaxlev2 ?? def.elev2 + const maxElev3 = def.emaxlev3 ?? def.elev3 + const maxElev4 = def.emaxlev4 ?? def.elev4 + const maxElev5 = def.emaxlev5 ?? def.elev5 + for (const lvl of testLevels) { const res = calculateSkillDamage(skillId, lvl) const oracle = computeDamageOracle(def, lvl) @@ -183,47 +260,47 @@ describe('M3 Adversarial Stress Testing: Combat Engine & Damage Mathematics', () // Level 2: +1 * elev1 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + def.elev1) * shift)) - expect(res.max).toBe(Math.floor((def.emax + def.elev1) * shift)) + expect(res.max).toBe(Math.floor((def.emax + maxElev1) * shift)) } else if (lvl === 8) { // Level 8: +7 * elev1 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1) * shift)) - expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1) * shift)) + expect(res.max).toBe(Math.floor((def.emax + 7 * maxElev1) * shift)) } else if (lvl === 9) { // Level 9: +7 * elev1 + 1 * elev2 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + def.elev2) * shift)) - expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + def.elev2) * shift)) + expect(res.max).toBe(Math.floor((def.emax + 7 * maxElev1 + maxElev2) * shift)) } else if (lvl === 16) { // Level 16: +7 * elev1 + 8 * elev2 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2) * shift)) - expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2) * shift)) + expect(res.max).toBe(Math.floor((def.emax + 7 * maxElev1 + 8 * maxElev2) * shift)) } else if (lvl === 17) { // Level 17: +7 * elev1 + 8 * elev2 + 1 * elev3 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + def.elev3) * shift)) - expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + def.elev3) * shift)) + expect(res.max).toBe(Math.floor((def.emax + 7 * maxElev1 + 8 * maxElev2 + maxElev3) * shift)) } else if (lvl === 22) { // Level 22: +7 * elev1 + 8 * elev2 + 6 * elev3 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3) * shift)) - expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3) * shift)) + expect(res.max).toBe(Math.floor((def.emax + 7 * maxElev1 + 8 * maxElev2 + 6 * maxElev3) * shift)) } else if (lvl === 23) { // Level 23: +7 * elev1 + 8 * elev2 + 6 * elev3 + 1 * elev4 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + def.elev4) * shift)) - expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + def.elev4) * shift)) + expect(res.max).toBe(Math.floor((def.emax + 7 * maxElev1 + 8 * maxElev2 + 6 * maxElev3 + maxElev4) * shift)) } else if (lvl === 28) { // Level 28: +7 * elev1 + 8 * elev2 + 6 * elev3 + 6 * elev4 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + 6 * def.elev4) * shift)) - expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + 6 * def.elev4) * shift)) + expect(res.max).toBe(Math.floor((def.emax + 7 * maxElev1 + 8 * maxElev2 + 6 * maxElev3 + 6 * maxElev4) * shift)) } else if (lvl === 29) { // Level 29: +7 * elev1 + 8 * elev2 + 6 * elev3 + 6 * elev4 + 1 * elev5 const shift = 2 ** (def.hitshift - 8) expect(res.min).toBe(Math.floor((def.emin + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + 6 * def.elev4 + def.elev5) * shift)) - expect(res.max).toBe(Math.floor((def.emax + 7 * def.elev1 + 8 * def.elev2 + 6 * def.elev3 + 6 * def.elev4 + def.elev5) * shift)) + expect(res.max).toBe(Math.floor((def.emax + 7 * maxElev1 + 8 * maxElev2 + 6 * maxElev3 + 6 * maxElev4 + maxElev5) * shift)) } } } @@ -363,22 +440,21 @@ describe('M3 Adversarial Stress Testing: Combat Engine & Damage Mathematics', () expect(fb1_5.min).toBe(4) expect(fb1_5.max).toBe(9) - // Firestorm (225) at L1 has min 1, max 3 - // With mult = 1.5: - // min = floor(1 * 1.5) = 1 - // max = floor(3 * 1.5) = 4 + // Firestorm (225) at L1 has emin 3, emax 6 with HitShift 2 (1/64 factor) + // Unfloored: min = 3/64 = 0.046875, max = 6/64 = 0.09375 + // With mult = 1.5: floor(0.046875 * 1.5) = 0, floor(0.09375 * 1.5) = 0 const fs1_5 = calculateSkillDamage(225, 1, 1.5) - expect(fs1_5.min).toBe(1) - expect(fs1_5.max).toBe(4) + expect(fs1_5.min).toBe(0) + expect(fs1_5.max).toBe(0) - // Teeth (67) at L10 has raw min 15, unfloored min 7.5 - // With mult = 1.5: floor(7.5 * 1.5) = floor(11.25) = 11 - // With mult = 2.0: floor(7.5 * 2.0) = 15 + // Teeth (67) at L10 has raw min 22 (4 + 7*2 + 2*2), unfloored min 11.0 (HitShift 7) + // With mult = 1.5: floor(11.0 * 1.5) = floor(16.5) = 16 + // With mult = 2.0: floor(11.0 * 2.0) = 22 const teethL10_1_5 = calculateSkillDamage(67, 10, 1.5) - expect(teethL10_1_5.min).toBe(11) + expect(teethL10_1_5.min).toBe(16) const teethL10_2_0 = calculateSkillDamage(67, 10, 2.0) - expect(teethL10_2_0.min).toBe(15) + expect(teethL10_2_0.min).toBe(22) // Blessed Hammer (112) at L1 has min 12, max 16 // mult = 1.5: min = 18, max = 24 @@ -413,8 +489,9 @@ describe('M3 Adversarial Stress Testing: Combat Engine & Damage Mathematics', () const fbBaseL20 = calculateSkillDamage(47, 20, 1.0) const fbSynL20 = calculateSkillDamage(47, 20, synMult) - expect(fbSynL20.min).toBe(Math.floor(fbBaseL20.min * synMult)) - expect(fbSynL20.max).toBe(Math.floor(fbBaseL20.max * synMult)) + const fbUnfloored = computeUnflooredBaseDamage(BATCH1_SKILLS[47]!, 20) + expect(fbSynL20.min).toBe(Math.floor(fbUnfloored.min * synMult)) + expect(fbSynL20.max).toBe(Math.floor(fbUnfloored.max * synMult)) expect(fbSynL20.min).toBeGreaterThan(fbBaseL20.min * 3) }) }) diff --git a/tests/treasure-engine.test.ts b/tests/treasure-engine.test.ts index ef3ab15..0e55f5e 100644 --- a/tests/treasure-engine.test.ts +++ b/tests/treasure-engine.test.ts @@ -28,20 +28,6 @@ import { const hasD2 = fs.existsSync('samples/d2/d2data.mpq') describe('TreasureClassEx Expansion Engine (Issue #101)', () => { - let table: TreasureClassTable - - beforeAll(async () => { - if (!hasD2) { - throw new Error('samples/d2 MPQ files required for TreasureClassEx test') - } - const archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } - const bytes = await archives.read(TREASURE_CLASS_EX_TABLE_PATH) - table = parseTreasureClassTable(parseTable(bytes)) - }) - describe('Mathematical Helper Functions', () => { it('verifies computeEffectivePlayers scaling across party and game counts', () => { // 1 player @@ -128,7 +114,25 @@ describe('TreasureClassEx Expansion Engine (Issue #101)', () => { }) }) - describe('NoDrop & Player Scaling Drop Rates (Act 1 H2H A)', () => { + describe('Expansion Engine Drops', () => { + let table: TreasureClassTable + + beforeAll(async () => { + if (!hasD2) { + throw new Error( + '[Preflight] samples/d2 MPQ files (d2data.mpq, d2exp.mpq, Patch_D2.mpq) are required for TreasureClassEx test. ' + + 'Please symlink samples/d2 to your Diablo II 1.13c installation directory.' + ) + } + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + const bytes = await archives.read(TREASURE_CLASS_EX_TABLE_PATH) + table = parseTreasureClassTable(parseTable(bytes)) + }) + + describe('NoDrop & Player Scaling Drop Rates (Act 1 H2H A)', () => { it('verifies Act 1 H2H A 1-player empty drop rate is 100/160 = 62.5%', () => { const act1H2HA = table.get('Act 1 H2H A')! expect(act1H2HA.noDrop).toBe(100) @@ -832,4 +836,5 @@ describe('TreasureClassEx Expansion Engine (Issue #101)', () => { expect(drops).toEqual([]) }) }) + }) })