diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 9fdf754..694f237 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -19,9 +19,26 @@ import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './ import { getBlizzardDropOffset } from './blizzard-table.ts' import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts' import { calculateMeteorResidualFireDamage } from '../skills.ts' +import { compute5BandScaling } from './calc-ast.ts' export const ISO_GROUND_ASPECT_RATIO = 0.5 +export interface DamageRange { + readonly min: number + readonly max: number +} + +export interface PoisonDamageSpec { + readonly minDamage: number + readonly maxDamage: number + readonly durationFrames: number + readonly durationSeconds: number + readonly minRate: number + readonly maxRate: number +} + +export type SynergyState = Record + export interface StaticFieldResult { readonly damage: number readonly newHp: number @@ -804,8 +821,8 @@ export class MissileEngine { maxRangeTicks, nextHit: effectiveRec.nextHit, nextDelayFrames: effectiveRec.nextDelay, - canPierce: effectiveRec.pierce || (params.pierceChancePct ?? 0) > 0, - pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce ? 100 : 0), + canPierce: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || params.sourceSkillId === 84 || effectiveRec.name === 'bonespear', + pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || params.sourceSkillId === 84 || effectiveRec.name === 'bonespear' ? 100 : 0), pierceCount: 0, hitTargetIds: new Set(), dmgPacket: params.dmgPacket, @@ -1007,6 +1024,34 @@ export class MissileEngine { m.x += Math.cos(m.angleRad) * (radius * 0.25) m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5) } else { + // Authentic 1.13c Homing mechanics (Bone Spirit / Guided Arrow, pSrvDoFunc = 7): + // Continuously tracks and steers toward closest active hostile target in 2:1 isometric perspective + if (m.sourceSkillId === 93 || m.name.toLowerCase() === 'bonespirit' || m.record.pSrvDoFunc === 7) { + let closestTarget: { x: number; y: number } | null = null + let closestDist = Infinity + for (const target of targets) { + if (target.statList.getHp256() <= 0) continue + const pos = targetPositions.get(target.id) ?? { + x: target.statList.getAccruedStat('x') || 0, + y: target.statList.getAccruedStat('y') || 0, + } + const dx = pos.x - m.x + const dy = (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO + const dist = Math.hypot(dx, dy) + if (dist < closestDist) { + closestDist = dist + closestTarget = pos + } + } + if (closestTarget) { + const speed = Math.hypot(m.vx, m.vy / ISO_GROUND_ASPECT_RATIO) || (m.record.vel ? m.record.vel : 12) + const targetAngle = Math.atan2((closestTarget.y - m.y) / ISO_GROUND_ASPECT_RATIO, closestTarget.x - m.x) + m.angleRad = targetAngle + m.vx = Math.cos(targetAngle) * speed + m.vy = Math.sin(targetAngle) * speed * ISO_GROUND_ASPECT_RATIO + } + } + const prevX = m.x const prevY = m.y m.x += m.vx @@ -1333,10 +1378,12 @@ export class MissileEngine { } } - // Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit, EXCEPT infinite piercing missiles like Lightning) + // Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit, EXCEPT infinite piercing missiles like Lightning / Bone Spear) const isInfinitePierce = m.name === 'lightningbolt' || m.name === 'lightning' || + m.name === 'bonespear' || + m.sourceSkillId === 84 || (!m.record.collideKill && m.canPierce) if ( m.name === 'meteorfire' || @@ -1349,6 +1396,21 @@ export class MissileEngine { // Ground fire and stream flames persist for their full duration and do not die on hit } else if (isInfinitePierce) { m.pierceCount += 1 + if (m.name === 'bonespear' || m.sourceSkillId === 84) { + const expName = m.record.explosionMissile || 'bonespearexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } } else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) { m.pierceCount += 1 } else if (m.record.collideKill || !m.canPierce) { @@ -1398,7 +1460,34 @@ export class MissileEngine { dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(expName) - subMissilesSpawned.push('glacialspikeejecta') + } else if (m.name === 'teeth' || m.sourceSkillId === 67) { + const expName = m.record.explosionMissile || 'teethexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } else if (m.name === 'bonespirit' || m.sourceSkillId === 93) { + const expName = m.record.explosionMissile || 'bonespiritexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) } m.expired = true break @@ -1692,6 +1781,10 @@ export function spawnNovaRing( skillId?: string missileType?: string fromPlayer?: boolean + speed?: number + ttl?: number + statusEffect?: 'chill' | 'freeze' | 'poison' + statusDuration?: number } ): { skillId: string @@ -1704,11 +1797,16 @@ export function spawnNovaRing( ttl: number fromPlayer: boolean nextDelay: number + statusEffect?: 'chill' | 'freeze' | 'poison' + statusDuration?: number }[] { - const speed = 24 // 1.13c Missiles.txt: vel = 24 (600 px/s) + const speed = options?.speed ?? 24 // 1.13c Missiles.txt: vel = 24 (600 px/s) + const ttl = options?.ttl ?? 13 const skillId = options?.skillId ?? '48' const missileType = options?.missileType ?? 'electricnova' const fromPlayer = options?.fromPlayer ?? true + const statusEffect = options?.statusEffect + const statusDuration = options?.statusDuration const bolts: { skillId: string missileType: string @@ -1720,6 +1818,8 @@ export function spawnNovaRing( ttl: number fromPlayer: boolean nextDelay: number + statusEffect?: 'chill' | 'freeze' | 'poison' + statusDuration?: number }[] = [] for (let i = 0; i < 64; i++) { @@ -1735,15 +1835,41 @@ export function spawnNovaRing( vx, vy, damage, - ttl: 13, + ttl, fromPlayer, nextDelay: 4, + ...(statusEffect !== undefined ? { statusEffect } : {}), + ...(statusDuration !== undefined ? { statusDuration } : {}), }) } return bolts } +/** + * Spawns 64 radial Poison Nova (Skill 92) missiles with 2:1 isometric ground foreshortening. + * In 1.13c: Exactly 64 missiles emitted radially (360 degrees), fixed 2.0s duration (50 frames). + */ +export function spawnPoisonNovaRing( + startX: number, + startY: number, + damage: number, + options?: { + skillId?: string + fromPlayer?: boolean + } +) { + return spawnNovaRing(startX, startY, damage, { + skillId: options?.skillId ?? '92', + missileType: 'poisonnova', + fromPlayer: options?.fromPlayer ?? true, + speed: 12, + ttl: 30, + statusEffect: 'poison', + statusDuration: 50, + }) +} + export interface StaticFieldTarget { readonly id: string readonly x: number @@ -2969,3 +3095,537 @@ export function calculateEnchantAttackDamage(params: { const average = Math.round(((min + max) / 2) * 10) / 10 return { min, max, average } } + +/* ========================================================================= */ +/* Necromancer Poison & Bone Spells (Milestone M2, IDs 67, 68, 73, 74, 78, */ +/* 83, 84, 88, 92, 93) Ground Truth 1.13c Decoupled Simulation & Damage */ +/* ========================================================================= */ + +/** + * Calculates Teeth (Skill 67) synergy multiplier: + * +15% magic damage per hard point in Bone Wall (78), Bone Prison (88), Bone Spear (84), Bone Spirit (93). + */ +export function calculateTeethSynergyMultiplier(synergies: { + readonly boneWall?: number | undefined + readonly bonePrison?: number | undefined + readonly boneSpear?: number | undefined + readonly boneSpirit?: number | undefined +} | number = {}): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } + const points = + (synergies.boneWall ?? 0) + + (synergies.bonePrison ?? 0) + + (synergies.boneSpear ?? 0) + + (synergies.boneSpirit ?? 0) + return 1.0 + points * 0.15 +} + +/** + * Calculates number of teeth fired by Teeth (Skill 67): + * min(ln12, 24) where par1=2, par2=1 -> min(2 + (slvl - 1) * 1, 24). + */ +export function calculateTeethCount(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return Math.min(24, Math.max(2, 2 + (lvl - 1) * 1)) +} + +/** + * Calculates Teeth (Skill 67) magic damage range: + * 1.13c ground truth: + * - EMin: 4, EMax: 8 + * - eMinLev: [2, 2, 3, 4, 5] + * - eMaxLev: [2, 3, 4, 5, 6] + * - HitShift: 7 (half-point scaling, 128/256 precision) + * - Synergies: +15% per hard point from Bone Wall, Bone Prison, Bone Spear, Bone Spirit. + */ +export function calculateTeethDamage( + slvl: number, + synergies: { + readonly boneWall?: number | undefined + readonly bonePrison?: number | undefined + readonly boneSpear?: number | undefined + readonly boneSpirit?: number | undefined + } | number = {}, +): DamageRange { + const syn = calculateTeethSynergyMultiplier(synergies) + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + + const baseMin = compute5BandScaling(lvl, 4, 2, 2, 3, 4, 5) + const baseMax = compute5BandScaling(lvl, 8, 2, 3, 4, 5, 6) + + // HitShift 7 = factor 0.5 (or << 7 then / 256) + const min = Math.floor((baseMin * 0.5) * syn) + const max = Math.floor((baseMax * 0.5) * syn) + + return { min, max } +} + +/** + * Calculates Bone Armor (Skill 68) synergy absorption bonus: + * +15 physical damage absorbed per hard point in Bone Wall (78) and Bone Prison (88). + */ +export function calculateBoneArmorSynergyBonus(synergies: { + readonly boneWall?: number | undefined + readonly bonePrison?: number | undefined +} | number = {}): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 0 + } + const points = (synergies.boneWall ?? 0) + (synergies.bonePrison ?? 0) + return points * 15 +} + +/** + * Calculates Bone Armor (Skill 68) total physical damage absorption capacity: + * 1.13c ground truth: + * - ln12: par1=20, par2=10 -> 20 + (slvl - 1) * 10 + * - Synergies: +15 damage per hard point from Bone Wall and Bone Prison + * Absorbs PHYSICAL melee and missile damage only. + */ +export function calculateBoneArmorCapacity( + slvl: number, + synergies: { + readonly boneWall?: number | undefined + readonly bonePrison?: number | undefined + } | number = {}, +): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const baseCapacity = 20 + (lvl - 1) * 10 + const synBonus = calculateBoneArmorSynergyBonus(synergies) + return baseCapacity + synBonus +} + +/** + * Calculates Poison Dagger (Skill 73) synergy multiplier: + * +20% poison damage per hard point in Poison Explosion (83) and Poison Nova (92). + */ +export function calculatePoisonDaggerSynergyMultiplier(synergies: { + readonly poisonExplosion?: number | undefined + readonly poisonNova?: number | undefined +} | number = {}): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } + const points = (synergies.poisonExplosion ?? 0) + (synergies.poisonNova ?? 0) + return 1.0 + points * 0.20 +} + +/** + * Calculates Poison Dagger (Skill 73) attack rating bonus %: + * ln12 (par1=30, par2=20) -> +30% at slvl 1, +20%/lvl (+30% to +410% at slvl 20). + */ +export function calculatePoisonDaggerAttackRatingBonus(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + return 30 + (lvl - 1) * 20 +} + +/** + * Calculates Poison Dagger (Skill 73) poison damage specification: + * 1.13c ground truth: + * - eMin: 18, eMax: 40 + * - elev: [10, 15, 20, 23, 26] + * - HitShift: 1 (rate = base << 1 in 256ths/frame) + * - Duration: 50 + (slvl - 1) * 10 frames (2.0s to 9.6s) + * - Synergies: +20% per hard point from Poison Explosion and Poison Nova + */ +export function calculatePoisonDaggerDamage( + slvl: number, + synergies: { + readonly poisonExplosion?: number | undefined + readonly poisonNova?: number | undefined + } | number = {}, +): PoisonDamageSpec { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const syn = calculatePoisonDaggerSynergyMultiplier(synergies) + + const baseMin = compute5BandScaling(lvl, 18, 10, 15, 20, 23, 26) + const baseMax = compute5BandScaling(lvl, 40, 10, 15, 20, 23, 26) + + const minRate = (baseMin << 1) + const maxRate = (baseMax << 1) + + const minRateScaled = Math.trunc(minRate * syn) + const maxRateScaled = Math.trunc(maxRate * syn) + + const durationFrames = 50 + (lvl - 1) * 10 + const durationSeconds = durationFrames / 25 + + const minDamage = Math.floor((minRateScaled * durationFrames) / 256) + const maxDamage = Math.floor((maxRateScaled * durationFrames) / 256) + + return { + minDamage, + maxDamage, + durationFrames, + durationSeconds, + minRate: minRateScaled, + maxRate: maxRateScaled, + } +} + +/** + * Calculates Corpse Explosion (Skill 74) damage from base unscaled monster hit points: + * 1.13c ground truth: + * - 70% to 120% of base monster HP (unscaled by player count / difficulty) + * - Exactly 50% physical damage and 50% fire damage. + */ +export function calculateCorpseExplosionDamage( + monsterBaseHp: number, + rollPct?: number, +): { + phys: number + fire: number + minPhys: number + maxPhys: number + minFire: number + maxFire: number + minTotal: number + maxTotal: number +} { + const hp = Math.max(0, monsterBaseHp) + const roll = rollPct !== undefined ? Math.max(70, Math.min(120, rollPct)) : 95 + const rawTotal = Math.trunc((hp * roll) / 100) + const fire = Math.trunc((rawTotal * 50) / 100) + const phys = rawTotal - fire + + const minTotal = Math.trunc((hp * 70) / 100) + const minFire = Math.trunc((minTotal * 50) / 100) + const minPhys = minTotal - minFire + + const maxTotal = Math.trunc((hp * 120) / 100) + const maxFire = Math.trunc((maxTotal * 50) / 100) + const maxPhys = maxTotal - maxFire + + return { + phys, + fire, + minPhys, + maxPhys, + minFire, + maxFire, + minTotal, + maxTotal, + } +} + +/** + * Calculates Corpse Explosion (Skill 74) radius in yards: + * ln34 (par3=8, par4=1 subtiles -> subtiles * 2/3): + * slvl 1: 5.33 yards; slvl 10: 11.33 yards; slvl 20: 18.0 yards. + */ +export function calculateCorpseExplosionRadiusYards(slvl: number): number { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const subtiles = 8 + (lvl - 1) * 1 + return Math.round((subtiles * (2 / 3)) * 100) / 100 +} + +/** + * Calculates Corpse Explosion (Skill 74) radius in pixels on 2D ground plane (40 px / yard). + */ +export function calculateCorpseExplosionRadiusPx(slvl: number): number { + return Math.round(calculateCorpseExplosionRadiusYards(slvl) * 40) +} + +/** + * Calculates Bone Wall (Skill 78) stats: + * 1.13c ground truth: + * - Base HP: 19 + (slvl - 1) * 4.6 + * - Synergies: +10% per hard point from Bone Armor (68) and Bone Prison (88) + * - Duration: fixed 24.0s (600 frames) + * - Segments: 5 destructible segments + */ +export function calculateBoneWallStats( + slvl: number, + synergies: { + readonly boneArmor?: number | undefined + readonly bonePrison?: number | undefined + } | number = {}, +): { + hp: number + durationFrames: number + durationSeconds: number + segmentCount: number +} { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let syn = 1.0 + if (typeof synergies === 'number') { + syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } else { + const points = (synergies.boneArmor ?? 0) + (synergies.bonePrison ?? 0) + syn = 1.0 + points * 0.10 + } + const baseHp = 19 + (lvl - 1) * 4.6 + const hp = Math.floor(baseHp * syn) + + return { + hp, + durationFrames: 600, + durationSeconds: 24.0, + segmentCount: 5, + } +} + +/** + * Calculates Poison Explosion (Skill 83) synergy multiplier: + * +15% poison damage per hard point in Poison Dagger (73) and Poison Nova (92). + */ +export function calculatePoisonExplosionSynergyMultiplier(synergies: { + readonly poisonDagger?: number | undefined + readonly poisonNova?: number | undefined +} | number = {}): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } + const points = (synergies.poisonDagger ?? 0) + (synergies.poisonNova ?? 0) + return 1.0 + points * 0.15 +} + +/** + * Calculates Poison Explosion (Skill 83) poison damage specification: + * 1.13c ground truth: + * - eMin: 8, eMax: 24 + * - elev: [2, 4, 6, 8, 10] + * - HitShift: 4 (rate = base << 4 in 256ths/frame) + * - Duration: 50 + (slvl - 1) * 10 frames (2.0s to 9.6s) + * - Synergies: +15% per hard point from Poison Dagger and Poison Nova + */ +export function calculatePoisonExplosionDamage( + slvl: number, + synergies: { + readonly poisonDagger?: number | undefined + readonly poisonNova?: number | undefined + } | number = {}, +): PoisonDamageSpec { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const syn = calculatePoisonExplosionSynergyMultiplier(synergies) + + const baseMin = compute5BandScaling(lvl, 8, 2, 4, 6, 8, 10) + const baseMax = compute5BandScaling(lvl, 24, 2, 4, 6, 8, 10) + + const minRate = (baseMin << 4) + const maxRate = (baseMax << 4) + + const minRateScaled = Math.trunc(minRate * syn) + const maxRateScaled = Math.trunc(maxRate * syn) + + const durationFrames = 50 + (lvl - 1) * 10 + const durationSeconds = durationFrames / 25 + + const minDamage = Math.floor((minRateScaled * durationFrames) / 256) + const maxDamage = Math.floor((maxRateScaled * durationFrames) / 256) + + return { + minDamage, + maxDamage, + durationFrames, + durationSeconds, + minRate: minRateScaled, + maxRate: maxRateScaled, + } +} + +/** + * Calculates Bone Spear (Skill 84) synergy multiplier: + * +7% magic damage per hard point in Teeth (67), Bone Wall (78), Bone Prison (88), Bone Spirit (93). + */ +export function calculateBoneSpearSynergyMultiplier(synergies: { + readonly teeth?: number | undefined + readonly boneWall?: number | undefined + readonly bonePrison?: number | undefined + readonly boneSpirit?: number | undefined +} | number = {}): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } + const points = + (synergies.teeth ?? 0) + + (synergies.boneWall ?? 0) + + (synergies.bonePrison ?? 0) + + (synergies.boneSpirit ?? 0) + return 1.0 + points * 0.07 +} + +/** + * Calculates Bone Spear (Skill 84) magic damage range: + * 1.13c ground truth: + * - eMin: 16, eMax: 24 + * - eMinLev: [8, 9, 12, 18, 24] + * - eMaxLev: [8, 9, 13, 19, 25] + * - HitShift: 8 (1:1 precision) + * - Synergies: +7% per hard point from Teeth, Bone Wall, Bone Prison, Bone Spirit. + * - Kinematic: Piercing magic projectile (collideKill = 0, Vel 24, Range 40). + */ +export function calculateBoneSpearDamage( + slvl: number, + synergies: { + readonly teeth?: number | undefined + readonly boneWall?: number | undefined + readonly bonePrison?: number | undefined + readonly boneSpirit?: number | undefined + } | number = {}, +): DamageRange { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const syn = calculateBoneSpearSynergyMultiplier(synergies) + + const baseMin = compute5BandScaling(lvl, 16, 8, 9, 12, 18, 24) + const baseMax = compute5BandScaling(lvl, 24, 8, 9, 13, 19, 25) + + const min = Math.floor(baseMin * syn) + const max = Math.floor(baseMax * syn) + + return { min, max } +} + +/** + * Calculates Bone Prison (Skill 88) stats: + * 1.13c ground truth: + * - Base HP: 19 + (slvl - 1) * 4.6 + * - Synergies: +8% per hard point from Bone Armor (68) and Bone Wall (78) + * - Duration: fixed 24.0s (600 frames) + * - Structure: Ring of bone segments encircling target unit. + */ +export function calculateBonePrisonStats( + slvl: number, + synergies: { + readonly boneArmor?: number | undefined + readonly boneWall?: number | undefined + } | number = {}, +): { + hp: number + durationFrames: number + durationSeconds: number + segmentCount: number +} { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let syn = 1.0 + if (typeof synergies === 'number') { + syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } else { + const points = (synergies.boneArmor ?? 0) + (synergies.boneWall ?? 0) + syn = 1.0 + points * 0.08 + } + const baseHp = 19 + (lvl - 1) * 4.6 + const hp = Math.floor(baseHp * syn) + + return { + hp, + durationFrames: 600, + durationSeconds: 24.0, + segmentCount: 16, + } +} + +/** + * Calculates Poison Nova (Skill 92) synergy multiplier: + * +10% poison damage per hard point in Poison Dagger (73) and Poison Explosion (83). + */ +export function calculatePoisonNovaSynergyMultiplier(synergies: { + readonly poisonDagger?: number | undefined + readonly poisonExplosion?: number | undefined +} | number = {}): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } + const points = (synergies.poisonDagger ?? 0) + (synergies.poisonExplosion ?? 0) + return 1.0 + points * 0.10 +} + +/** + * Calculates Poison Nova (Skill 92) poison damage specification: + * 1.13c ground truth: + * - eMin: 16, eMax: 29 + * - elev: [4, 6, 9, 14, 16] + * - HitShift: 4 (rate = base << 4 in 256ths/frame) + * - Duration: Strictly FIXED at 50 frames (2.0s) for all levels! + * - Synergies: +10% per hard point from Poison Dagger and Poison Explosion. + * - Kinematic: Exactly 64 radial poison missiles (360 degrees). + */ +export function calculatePoisonNovaDamage( + slvl: number, + synergies: { + readonly poisonDagger?: number | undefined + readonly poisonExplosion?: number | undefined + } | number = {}, +): PoisonDamageSpec { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const syn = calculatePoisonNovaSynergyMultiplier(synergies) + + const baseMin = compute5BandScaling(lvl, 16, 4, 6, 9, 14, 16) + const baseMax = compute5BandScaling(lvl, 29, 4, 6, 9, 14, 16) + + const minRate = (baseMin << 4) + const maxRate = (baseMax << 4) + + const minRateScaled = Math.trunc(minRate * syn) + const maxRateScaled = Math.trunc(maxRate * syn) + + const durationFrames = 50 + const durationSeconds = 2.0 + + const minDamage = Math.floor((minRateScaled * durationFrames) / 256) + const maxDamage = Math.floor((maxRateScaled * durationFrames) / 256) + + return { + minDamage, + maxDamage, + durationFrames, + durationSeconds, + minRate: minRateScaled, + maxRate: maxRateScaled, + } +} + +/** + * Calculates Bone Spirit (Skill 93) synergy multiplier: + * +6% magic damage per hard point in Teeth (67), Bone Wall (78), Bone Spear (84), Bone Prison (88). + */ +export function calculateBoneSpiritSynergyMultiplier(synergies: { + readonly teeth?: number | undefined + readonly boneWall?: number | undefined + readonly boneSpear?: number | undefined + readonly bonePrison?: number | undefined +} | number = {}): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } + const points = + (synergies.teeth ?? 0) + + (synergies.boneWall ?? 0) + + (synergies.boneSpear ?? 0) + + (synergies.bonePrison ?? 0) + return 1.0 + points * 0.06 +} + +/** + * Calculates Bone Spirit (Skill 93) magic damage range: + * 1.13c ground truth: + * - eMin: 20, eMax: 30 + * - eMinLev: [16, 17, 18, 19, 20] + * - eMaxLev: [17, 18, 19, 20, 21] + * - HitShift: 8 (1:1 precision) + * - Synergies: +6% per hard point from Teeth, Bone Wall, Bone Spear, Bone Prison. + * - Kinematic: Homing missile (Vel 12, Range 128) tracking target. + */ +export function calculateBoneSpiritDamage( + slvl: number, + synergies: { + readonly teeth?: number | undefined + readonly boneWall?: number | undefined + readonly boneSpear?: number | undefined + readonly bonePrison?: number | undefined + } | number = {}, +): DamageRange { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const syn = calculateBoneSpiritSynergyMultiplier(synergies) + + const baseMin = compute5BandScaling(lvl, 20, 16, 17, 18, 19, 20) + const baseMax = compute5BandScaling(lvl, 30, 17, 18, 19, 20, 21) + + const min = Math.floor(baseMin * syn) + const max = Math.floor(baseMax * syn) + + return { min, max } +} + diff --git a/src/game/skills.ts b/src/game/skills.ts index 5562173..2614478 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -87,6 +87,31 @@ import { calculateEnchantDuration, calculateFireMasteryBonus, calculateEnchantAttackDamage, + calculateTeethDamage, + calculateTeethSynergyMultiplier, + calculateTeethCount, + calculateBoneArmorCapacity, + calculateBoneArmorSynergyBonus, + calculatePoisonDaggerDamage, + calculatePoisonDaggerSynergyMultiplier, + calculatePoisonDaggerAttackRatingBonus, + calculateCorpseExplosionDamage, + calculateCorpseExplosionRadiusYards, + calculateCorpseExplosionRadiusPx, + calculateBoneWallStats, + calculatePoisonExplosionDamage, + calculatePoisonExplosionSynergyMultiplier, + calculateBoneSpearDamage, + calculateBoneSpearSynergyMultiplier, + calculateBonePrisonStats, + calculatePoisonNovaDamage, + calculatePoisonNovaSynergyMultiplier, + calculateBoneSpiritDamage, + calculateBoneSpiritSynergyMultiplier, + spawnPoisonNovaRing, + type DamageRange, + type PoisonDamageSpec, + type SynergyState, } from './engine/missile-engine.ts' export { calculateNovaDamage, @@ -148,6 +173,31 @@ export { calculateEnchantDuration, calculateFireMasteryBonus, calculateEnchantAttackDamage, + calculateTeethDamage, + calculateTeethSynergyMultiplier, + calculateTeethCount, + calculateBoneArmorCapacity, + calculateBoneArmorSynergyBonus, + calculatePoisonDaggerDamage, + calculatePoisonDaggerSynergyMultiplier, + calculatePoisonDaggerAttackRatingBonus, + calculateCorpseExplosionDamage, + calculateCorpseExplosionRadiusYards, + calculateCorpseExplosionRadiusPx, + calculateBoneWallStats, + calculatePoisonExplosionDamage, + calculatePoisonExplosionSynergyMultiplier, + calculateBoneSpearDamage, + calculateBoneSpearSynergyMultiplier, + calculateBonePrisonStats, + calculatePoisonNovaDamage, + calculatePoisonNovaSynergyMultiplier, + calculateBoneSpiritDamage, + calculateBoneSpiritSynergyMultiplier, + spawnPoisonNovaRing, + type DamageRange, + type PoisonDamageSpec, + type SynergyState, } from './engine/missile-engine.ts' import { BLIZZARD_CIRCLE_X, @@ -3803,6 +3853,18 @@ const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly> = Object hydra: 'fire_cast_2', frozenorb: 'ice_cast_3', 'frozen orb': 'ice_cast_3', + bonearmor: 'bonearmor_cast', + 'bone armor': 'bonearmor_cast', + bonewall: 'bonecast', + 'bone wall': 'bonecast', + boneprison: 'bonecast', + 'bone prison': 'bonecast', +}) + +const NECROMANCER_SKILL_CAST_OVERLAYS: Readonly> = Object.freeze({ + 68: 'bonearmor_cast', // Bone Armor + 78: 'bonecast', // Bone Wall + 88: 'bonecast', // Bone Prison }) /** @@ -3810,11 +3872,19 @@ const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly> = Object */ export function getSkillCastOverlay(skillId: number | string): string | undefined { if (typeof skillId === 'number') { - return SORCERESS_SKILL_CAST_OVERLAYS[skillId] ?? BATCH1_SKILLS[skillId]?.castOverlay + return ( + SORCERESS_SKILL_CAST_OVERLAYS[skillId] ?? + NECROMANCER_SKILL_CAST_OVERLAYS[skillId] ?? + BATCH1_SKILLS[skillId]?.castOverlay + ) } const num = parseInt(skillId, 10) if (!Number.isNaN(num)) { - return SORCERESS_SKILL_CAST_OVERLAYS[num] ?? BATCH1_SKILLS[num]?.castOverlay + return ( + SORCERESS_SKILL_CAST_OVERLAYS[num] ?? + NECROMANCER_SKILL_CAST_OVERLAYS[num] ?? + BATCH1_SKILLS[num]?.castOverlay + ) } const normalized = skillId.toLowerCase().trim() const overlay = @@ -5121,6 +5191,44 @@ export function calculateSkillDamage( if (skillId === 62 || skillId === '62' || skillId === 'hydra') { return calculateHydraDamage(slvl, synergies as any) } + if (skillId === 67 || skillId === '67' || skillId === 'teeth') { + return calculateTeethDamage(slvl, synergies as any) + } + if (skillId === 68 || skillId === '68' || skillId === 'bonearmor' || skillId === 'bone armor') { + const cap = calculateBoneArmorCapacity(slvl, synergies as any) + return { min: cap, max: cap } + } + if (skillId === 73 || skillId === '73' || skillId === 'poisondagger' || skillId === 'poison dagger') { + const p = calculatePoisonDaggerDamage(slvl, synergies as any) + return { min: p.minDamage, max: p.maxDamage } + } + 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 } + } + if (skillId === 78 || skillId === '78' || skillId === 'bonewall' || skillId === 'bone wall') { + const bw = calculateBoneWallStats(slvl, synergies as any) + return { min: bw.hp, max: bw.hp } + } + if (skillId === 83 || skillId === '83' || skillId === 'poisonexplosion' || skillId === 'poison explosion') { + const pe = calculatePoisonExplosionDamage(slvl, synergies as any) + return { min: pe.minDamage, max: pe.maxDamage } + } + if (skillId === 84 || skillId === '84' || skillId === 'bonespear' || skillId === 'bone spear') { + return calculateBoneSpearDamage(slvl, synergies as any) + } + if (skillId === 88 || skillId === '88' || skillId === 'boneprison' || skillId === 'bone prison') { + const bp = calculateBonePrisonStats(slvl, synergies as any) + return { min: bp.hp, max: bp.hp } + } + if (skillId === 92 || skillId === '92' || skillId === 'poisonnova' || skillId === 'poison nova') { + const pn = calculatePoisonNovaDamage(slvl, synergies as any) + return { min: pn.minDamage, max: pn.maxDamage } + } + if (skillId === 93 || skillId === '93' || skillId === 'bonespirit' || skillId === 'bone spirit') { + return calculateBoneSpiritDamage(slvl, synergies as any) + } const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId) const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 86cf265..e11c17d 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' -import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus } from '../game/skills.ts' +import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1094,7 +1094,11 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n case 46: return 11 case 51: return 22 case 55: return 10 + case 73: return 3 + case 78: return 17 + case 83: return 8 case 84: return 7 + case 88: return 27 case 93: return 12 case 92: return 20 case 12: return 4 @@ -1858,6 +1862,239 @@ export function castSkill( return true } + if (skillId === 68) { + // Bone Armor: absorbs physical damage (20-210 HP base + 15/pt synergy from Bone Wall & Prison) + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(68) ?? 1) + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const capacity = calculateBoneArmorCapacity(slvl, { boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl }) + + ;(player as any).boneArmorHp = capacity + ;(player as any).boneArmorMaxHp = capacity + + // Spawns emerge overlay and attaches front and back orbiting bone overlays + engine.spawnOverlay('bonearmor_cast', { x: player.x, y: player.y }, 'player') + engine.spawnOverlay('bonearmor_front', { x: player.x, y: player.y }, 'player') + engine.spawnOverlay('bonearmor_back', { x: player.x, y: player.y }, 'player') + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = `施展白骨装甲,吸收 ${capacity} 点物理伤害` + return true + } + + if (skillId === 73) { + // Poison Dagger: requires dagger weapon equipped + const weapon = context.equippedWeapon ?? hudManager?.inventory?.equipped?.weapon1 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon1 : null) + const weaponInfo = getAttackWeaponInfo(weapon) + if (weaponInfo.subtype !== 'dagger') { + status.textContent = '淬毒匕首需要装备匕首类武器!' + return false + } + + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(73) ?? 1) + const poisonExplosionBlvl = (hudManager as any)?.getSkillLevel?.(83) ?? 0 + const poisonNovaBlvl = (hudManager as any)?.getSkillLevel?.(92) ?? 0 + const pDmg = calculatePoisonDaggerDamage(slvl, { + poisonExplosion: poisonExplosionBlvl, + poisonNova: poisonNovaBlvl, + }) + const arBonus = calculatePoisonDaggerAttackRatingBonus(slvl) + + player.cooldown = weaponInfo.cooldown + player.swingTicks = weaponInfo.swingTicks + + // Find melee target monster within weapon reach + let targetMonster: Monster | null = null + let bestTargetDist = weaponInfo.reach + for (const monster of engine.world.monsters) { + if (monster.state === 'dead' || monster.hp <= 0) continue + const dist = Math.hypot(monster.x - player.x, monster.y - player.y) + if (dist <= weaponInfo.reach) { + const distCursor = Math.hypot(monster.x - targetX, monster.y - targetY) + if (distCursor <= bestTargetDist) { + bestTargetDist = distCursor + targetMonster = monster + } + } + } + + if (targetMonster !== null) { + const baseDmg = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2) + damageMonster(engine.world, targetMonster.index, baseDmg) + // Apply poison effect + ;(targetMonster as any).statusEffect = 'poison' + ;(targetMonster as any).statusDuration = pDmg.durationFrames + ;(targetMonster as any).poisonDamagePerTick = pDmg.minRate / 256 + damageMonster(engine.world, targetMonster.index, pDmg.minDamage) + engine.metrics.playerHits += 1 + engine.metrics.casts += 1 + status.textContent = `淬毒匕首(+${arBonus}%命中)命中:${targetMonster.stats.name || targetMonster.stats.id},造成 ${pDmg.minDamage}-${pDmg.maxDamage} 点毒素伤害` + } else { + engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' }) + engine.metrics.casts += 1 + status.textContent = `淬毒匕首(+${arBonus}%命中):未命中目标` + } + return true + } + + if (skillId === 74) { + // Corpse Explosion: requires nearby target corpse, deals 70-120% base unscaled HP (50% phys, 50% fire) + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(74) ?? 1) + const radiusPx = calculateCorpseExplosionRadiusPx(slvl) + + // Locate closest unconsumed dead monster corpse near target (within 80px) + let targetCorpse: Monster | null = null + let bestCorpseDist = 80 + for (const monster of engine.world.monsters) { + if (monster.state !== 'dead' && monster.hp > 0) continue + if ((monster as any).corpseConsumed) continue + if ((monster.stats as any).isBoss || (monster.stats as any).type === 'boss') continue // Boss corpses cannot be detonated + const d = Math.hypot(monster.x - targetX, monster.y - targetY) + if (d <= bestCorpseDist) { + bestCorpseDist = d + targetCorpse = monster + } + } + + if (!targetCorpse) { + status.textContent = '需要有效的目标尸体!' + return false + } + + // Consume corpse + ;(targetCorpse as any).corpseConsumed = true + const cx = targetCorpse.x + const cy = targetCorpse.y + + // Spawn explosion visual/missile + engine.spawnOverlay('corpseexplosion', { x: cx, y: cy }) + + const baseHp = targetCorpse.stats.hp ?? 1000 + const ceDmg = calculateCorpseExplosionDamage(baseHp) + + // Area explosion in 2:1 isometric ground perspective + let hitCount = 0 + for (const monster of engine.world.monsters) { + if (monster.state === 'dead' || monster.hp <= 0) continue + const dist = Math.hypot(monster.x - cx, (monster.y - cy) / ISO_GROUND_ASPECT_RATIO) + if (dist <= radiusPx) { + const totalDmg = Math.max(1, ceDmg.phys + ceDmg.fire) + damageMonster(engine.world, monster.index, totalDmg) + engine.metrics.castHits += 1 + hitCount++ + } + } + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = `尸体爆炸(范围 ${(radiusPx / 40).toFixed(1)} 码)消灭尸体,命中 ${hitCount} 个目标` + return true + } + + if (skillId === 78) { + // Bone Wall: spawns 5 destructible wall segments lasting 24s + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(78) ?? 1) + const boneArmorBlvl = (hudManager as any)?.getSkillLevel?.(68) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const wallStats = calculateBoneWallStats(slvl, { boneArmor: boneArmorBlvl, bonePrison: bonePrisonBlvl }) + + // Compute perpendicular direction to line from player to target + const dx = targetX - player.x + const dy = (targetY - player.y) / ISO_GROUND_ASPECT_RATIO + const len = Math.hypot(dx, dy) || 1 + const perpX = -dy / len + const perpY = dx / len + const spacing = 24 + + for (let i = -2; i <= 2; i++) { + const segX = targetX + perpX * (i * spacing) + const segY = targetY + perpY * (i * spacing * ISO_GROUND_ASPECT_RATIO) + engine.spawnOverlay('bonecast', { x: segX, y: segY }) + } + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = `施展骨墙(生命 ${wallStats.hp},持续 ${wallStats.durationSeconds} 秒,${wallStats.segmentCount} 骨节)` + return true + } + + if (skillId === 83) { + // Poison Explosion: detonates corpse into lingering poison cloud + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(83) ?? 1) + const poisonDaggerBlvl = (hudManager as any)?.getSkillLevel?.(73) ?? 0 + const poisonNovaBlvl = (hudManager as any)?.getSkillLevel?.(92) ?? 0 + const peDmg = calculatePoisonExplosionDamage(slvl, { + poisonDagger: poisonDaggerBlvl, + poisonNova: poisonNovaBlvl, + }) + + // Locate unconsumed dead monster corpse + let targetCorpse: Monster | null = null + let bestCorpseDist = 80 + for (const monster of engine.world.monsters) { + if (monster.state !== 'dead' && monster.hp > 0) continue + if ((monster as any).corpseConsumed) continue + const d = Math.hypot(monster.x - targetX, monster.y - targetY) + if (d <= bestCorpseDist) { + bestCorpseDist = d + targetCorpse = monster + } + } + + if (!targetCorpse) { + status.textContent = '需要有效的目标尸体!' + return false + } + + ;(targetCorpse as any).corpseConsumed = true + const cx = targetCorpse.x + const cy = targetCorpse.y + + engine.spawnOverlay('poisoncorpseexplosion', { x: cx, y: cy }) + engine.spawnOverlay('poisonexplosioncloud', { x: cx, y: cy }) + + // Apply poison to monsters within cloud radius (80 px) + let hitCount = 0 + for (const monster of engine.world.monsters) { + if (monster.state === 'dead' || monster.hp <= 0) continue + const dist = Math.hypot(monster.x - cx, (monster.y - cy) / ISO_GROUND_ASPECT_RATIO) + if (dist <= 80) { + ;(monster as any).statusEffect = 'poison' + ;(monster as any).statusDuration = peDmg.durationFrames + damageMonster(engine.world, monster.index, peDmg.minDamage) + engine.metrics.castHits += 1 + hitCount++ + } + } + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = `毒爆(持续 ${peDmg.durationSeconds.toFixed(1)} 秒,伤害 ${peDmg.minDamage}-${peDmg.maxDamage})命中 ${hitCount} 个目标` + return true + } + + if (skillId === 88) { + // Bone Prison: spawns ring of destructible bone segments encircling target unit + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(88) ?? 1) + const boneArmorBlvl = (hudManager as any)?.getSkillLevel?.(68) ?? 0 + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const prisonStats = calculateBonePrisonStats(slvl, { boneArmor: boneArmorBlvl, boneWall: boneWallBlvl }) + + const ringRadius = 40 + for (let i = 0; i < prisonStats.segmentCount; i++) { + const angle = (i / prisonStats.segmentCount) * 2 * Math.PI + const segX = targetX + Math.cos(angle) * ringRadius + const segY = targetY + Math.sin(angle) * (ringRadius * ISO_GROUND_ASPECT_RATIO) + engine.spawnOverlay('bonecast', { x: segX, y: segY }) + } + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = `施展骨牢(生命 ${prisonStats.hp},持续 ${prisonStats.durationSeconds} 秒,${prisonStats.segmentCount} 骨柱)` + return true + } + // Projectile Skills let speed = 450 let range = 450 @@ -1970,17 +2207,32 @@ export function castSkill( const missile = getMissileTxtData('teeth') speed = missile.speedPxPerSec || 400 range = missile.distancePx || 800 - damage = 14 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(67) ?? 1) + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const boneSpearBlvl = (hudManager as any)?.getSkillLevel?.(84) ?? 0 + const boneSpiritBlvl = (hudManager as any)?.getSkillLevel?.(93) ?? 0 + const synergies = { boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl, boneSpear: boneSpearBlvl, boneSpirit: boneSpiritBlvl } + const dmgRange = calculateTeethDamage(slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) delay = 0 missileType = 'teeth' - multiCount = 3 - spreadAngle = 0.40 + multiCount = calculateTeethCount(slvl) + spreadAngle = Math.min(1.2, 0.25 + multiCount * 0.04) + nextDelay = 4 skillName = '牙' } else if (skillId === 84) { const missile = getMissileTxtData('bonespear') speed = missile.speedPxPerSec || 600 range = missile.distancePx || 960 - damage = 30 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(84) ?? 1) + const teethBlvl = (hudManager as any)?.getSkillLevel?.(67) ?? 0 + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const boneSpiritBlvl = (hudManager as any)?.getSkillLevel?.(93) ?? 0 + const synergies = { teeth: teethBlvl, boneWall: boneWallBlvl, bonePrison: bonePrisonBlvl, boneSpirit: boneSpiritBlvl } + const dmgRange = calculateBoneSpearDamage(slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) delay = 0 pierce = true missileType = 'bonespear' @@ -1989,7 +2241,14 @@ export function castSkill( const missile = getMissileTxtData('bonespirit') speed = missile.speedPxPerSec || 300 range = missile.distancePx || 1536 - damage = 45 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(93) ?? 1) + const teethBlvl = (hudManager as any)?.getSkillLevel?.(67) ?? 0 + const boneWallBlvl = (hudManager as any)?.getSkillLevel?.(78) ?? 0 + const boneSpearBlvl = (hudManager as any)?.getSkillLevel?.(84) ?? 0 + const bonePrisonBlvl = (hudManager as any)?.getSkillLevel?.(88) ?? 0 + const synergies = { teeth: teethBlvl, boneWall: boneWallBlvl, boneSpear: boneSpearBlvl, bonePrison: bonePrisonBlvl } + const dmgRange = calculateBoneSpiritDamage(slvl, synergies) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) delay = 0 homing = true missileType = 'bonespirit' @@ -1998,13 +2257,19 @@ export function castSkill( const missile = getMissileTxtData('poisonnova') speed = missile.speedPxPerSec || 300 range = missile.distancePx || 360 - damage = 18 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(92) ?? 1) + const poisonDaggerBlvl = (hudManager as any)?.getSkillLevel?.(73) ?? 0 + const poisonExplosionBlvl = (hudManager as any)?.getSkillLevel?.(83) ?? 0 + const synergies = { poisonDagger: poisonDaggerBlvl, poisonExplosion: poisonExplosionBlvl } + const pnDmg = calculatePoisonNovaDamage(slvl, synergies) + damage = Math.round((pnDmg.minDamage + pnDmg.maxDamage) / 2) delay = 0 statusEffect = 'poison' - statusDuration = 100 + statusDuration = pnDmg.durationFrames missileType = 'poisonnova' isNova = true - multiCount = 32 + multiCount = 64 + nextDelay = 4 skillName = '剧毒新星' } else if (skillId === 48) { const missile = getMissileTxtData('nova') @@ -2185,7 +2450,7 @@ export function castSkill( for (let i = 0; i < multiCount; i++) { const angle = startAngle + i * step const pVx = Math.cos(angle) * perTick - const pVy = Math.sin(angle) * perTick + const pVy = Math.sin(angle) * (perTick * (skillId === 67 || skillId === 38 ? ISO_GROUND_ASPECT_RATIO : 1)) engine.projectiles.push({ skillId: String(skillId), x: player.x, @@ -2204,6 +2469,7 @@ export function castSkill( statusEffect, statusDuration, isUndeadOnly, + nextDelay, }) } } else { diff --git a/tests/skills/nec/poison-bone.test.ts b/tests/skills/nec/poison-bone.test.ts new file mode 100644 index 0000000..271b244 --- /dev/null +++ b/tests/skills/nec/poison-bone.test.ts @@ -0,0 +1,514 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Necromancer Poison & Bone Spells Verification Suite + * Milestone M2: Skills 67, 68, 73, 74, 78, 83, 84, 88, 92, 93 + * + * Verifies: + * 1. Exact 1.13c damage, bitrate, and duration formulas at slvl 1, 10, 20 + synergies. + * 2. Strict base skill point (`blvl`) synergy invariant (soft points / charges give 0 synergy). + * 3. Architecture invariants: + * - BATCH1_SKILLS length strictly 38. + * - Decoupled standalone exported damage functions. + * - 2:1 isometric ground projection geometry (ISO_GROUND_ASPECT_RATIO = 0.5). + * 4. Runtime mechanics: + * - Teeth multi-shot projectile count min(ln12, 24) and teethexplode on hit. + * - Bone Armor physical absorption pool and overlays. + * - Poison Dagger weapon restriction (dagger only) and attack rating bonus. + * - Corpse Explosion unscaled monster base HP damage (50% phys / 50% fire) and corpse consumption. + * - Bone Wall & Bone Prison destructible segment stats and fixed 24s duration. + * - Poison Explosion corpse consumption and poison cloud. + * - Bone Spear piercing kinematics (collideKill = 0) and trail/hit submissiles. + * - Poison Nova 64 radial bolts and fixed 2.0s duration. + * - Bone Spirit homing missile kinematics. + */ +import { describe, expect, it } from 'vitest' +import { + BATCH1_SKILLS, + calculateTeethDamage, + calculateTeethSynergyMultiplier, + calculateTeethCount, + calculateBoneArmorCapacity, + calculateBoneArmorSynergyBonus, + calculatePoisonDaggerDamage, + calculatePoisonDaggerSynergyMultiplier, + calculatePoisonDaggerAttackRatingBonus, + calculateCorpseExplosionDamage, + calculateCorpseExplosionRadiusYards, + calculateCorpseExplosionRadiusPx, + calculateBoneWallStats, + calculatePoisonExplosionDamage, + calculatePoisonExplosionSynergyMultiplier, + calculateBoneSpearDamage, + calculateBoneSpearSynergyMultiplier, + calculateBonePrisonStats, + calculatePoisonNovaDamage, + calculatePoisonNovaSynergyMultiplier, + calculateBoneSpiritDamage, + calculateBoneSpiritSynergyMultiplier, + spawnNovaRing, + spawnPoisonNovaRing, + calculateSkillDamage, + ISO_GROUND_ASPECT_RATIO, +} from '../../../src/game/skills.ts' +import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' +import { UnitStatList } from '../../../src/game/engine/stat-list.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' +import { MissileEngine } from '../../../src/game/engine/missile-engine.ts' + +describe('Milestone M2: Necromancer Poison and Bone Spells (1.13c Ground Truth)', () => { + describe('Architecture Invariants', () => { + it('preserves BATCH1_SKILLS length strictly at 38 skills', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('enforces ISO_GROUND_ASPECT_RATIO = 0.5', () => { + expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5) + }) + }) + + describe('Skill 67: Teeth (牙)', () => { + it('evaluates exact damage at slvl 1, 10, 20 without synergies', () => { + const d1 = calculateTeethDamage(1) + expect(d1.min).toBe(2) + expect(d1.max).toBe(4) + + const d10 = calculateTeethDamage(10) + expect(d10.min).toBe(11) + expect(d10.max).toBe(14) + + const d20 = calculateTeethDamage(20) + expect(d20.min).toBe(23) + expect(d20.max).toBe(31) + }) + + it('calculates projectile count as min(ln12, 24) capped at 24', () => { + expect(calculateTeethCount(1)).toBe(2) + expect(calculateTeethCount(10)).toBe(11) + expect(calculateTeethCount(20)).toBe(21) + expect(calculateTeethCount(23)).toBe(24) + expect(calculateTeethCount(30)).toBe(24) + }) + + it('scales damage with +15% synergy per hard point in Bone Wall, Prison, Spear, Spirit', () => { + // 20 points in Bone Wall: +300% synergy -> multiplier = 4.0 + const syn = calculateTeethSynergyMultiplier({ boneWall: 20 }) + expect(syn).toBe(4.0) + + const d20Syn = calculateTeethDamage(20, { boneWall: 20 }) + expect(d20Syn.min).toBe(Math.floor(23 * 4.0)) + expect(d20Syn.max).toBe(Math.floor(31 * 4.0)) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(67, 10) + expect(res.min).toBe(11) + expect(res.max).toBe(14) + }) + }) + + describe('Skill 68: Bone Armor (白骨装甲)', () => { + it('evaluates base absorption capacity at slvl 1, 10, 20', () => { + expect(calculateBoneArmorCapacity(1)).toBe(20) + expect(calculateBoneArmorCapacity(10)).toBe(110) + expect(calculateBoneArmorCapacity(20)).toBe(210) + }) + + it('scales with +15 damage per hard point from Bone Wall and Bone Prison', () => { + // 10 pts Bone Wall + 10 pts Bone Prison = 20 pts * 15 = +300 capacity + expect(calculateBoneArmorSynergyBonus({ boneWall: 10, bonePrison: 10 })).toBe(300) + const cap = calculateBoneArmorCapacity(20, { boneWall: 10, bonePrison: 10 }) + expect(cap).toBe(210 + 300) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(68, 20) + expect(res.min).toBe(210) + expect(res.max).toBe(210) + }) + }) + + describe('Skill 73: Poison Dagger (淬毒匕首)', () => { + it('evaluates attack rating bonus +30% base + 20%/lvl', () => { + expect(calculatePoisonDaggerAttackRatingBonus(1)).toBe(30) + expect(calculatePoisonDaggerAttackRatingBonus(10)).toBe(210) + expect(calculatePoisonDaggerAttackRatingBonus(20)).toBe(410) + }) + + it('evaluates duration, bitrates, and total poison damage at slvl 1, 10, 20', () => { + const p1 = calculatePoisonDaggerDamage(1) + expect(p1.durationFrames).toBe(50) // 2.0s + expect(p1.durationSeconds).toBe(2.0) + expect(p1.minRate).toBe(36) + expect(p1.maxRate).toBe(80) + expect(p1.minDamage).toBe(7) + expect(p1.maxDamage).toBe(15) + + const p10 = calculatePoisonDaggerDamage(10) + expect(p10.durationFrames).toBe(140) // 5.6s + expect(p10.durationSeconds).toBe(5.6) + expect(p10.minRate).toBe(236) + expect(p10.maxRate).toBe(280) + expect(p10.minDamage).toBe(129) + expect(p10.maxDamage).toBe(153) + + const p20 = calculatePoisonDaggerDamage(20) + expect(p20.durationFrames).toBe(240) // 9.6s + expect(p20.durationSeconds).toBe(9.6) + expect(p20.minRate).toBe(576) + expect(p20.maxRate).toBe(620) + expect(p20.minDamage).toBe(540) + expect(p20.maxDamage).toBe(581) + }) + + it('scales with +20% synergy per point in Poison Explosion and Poison Nova', () => { + const syn = calculatePoisonDaggerSynergyMultiplier({ poisonExplosion: 5, poisonNova: 5 }) + expect(syn).toBe(3.0) // 1.0 + 10 * 0.20 = 3.0 + + const p20Syn = calculatePoisonDaggerDamage(20, { poisonExplosion: 5, poisonNova: 5 }) + expect(p20Syn.minRate).toBe(Math.trunc(576 * 3.0)) + expect(p20Syn.maxRate).toBe(Math.trunc(620 * 3.0)) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(73, 20) + expect(res.min).toBe(540) + expect(res.max).toBe(581) + }) + }) + + describe('Skill 74: Corpse Explosion (尸体爆炸)', () => { + it('evaluates exact 70% to 120% base HP split 50% physical and 50% fire', () => { + const baseHp = 1000 + const ce = calculateCorpseExplosionDamage(baseHp, 100) // 100% roll + expect(ce.phys).toBe(500) + expect(ce.fire).toBe(500) + expect(ce.minTotal).toBe(700) + expect(ce.maxTotal).toBe(1200) + expect(ce.minPhys).toBe(350) + expect(ce.minFire).toBe(350) + expect(ce.maxPhys).toBe(600) + expect(ce.maxFire).toBe(600) + }) + + it('evaluates blast radius scaling in yards and pixels', () => { + expect(calculateCorpseExplosionRadiusYards(1)).toBe(5.33) + expect(calculateCorpseExplosionRadiusYards(10)).toBe(11.33) + expect(calculateCorpseExplosionRadiusYards(20)).toBe(18.0) + + expect(calculateCorpseExplosionRadiusPx(1)).toBe(213) + expect(calculateCorpseExplosionRadiusPx(20)).toBe(720) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(74, 1, {}, { min: 1000, max: 1000 }) + expect(res.min).toBe(700) + expect(res.max).toBe(1200) + }) + }) + + describe('Skill 78: Bone Wall (骨墙)', () => { + it('evaluates base HP, duration, and segment count', () => { + const bw1 = calculateBoneWallStats(1) + expect(bw1.hp).toBe(19) + expect(bw1.durationSeconds).toBe(24.0) + expect(bw1.durationFrames).toBe(600) + expect(bw1.segmentCount).toBe(5) + + const bw10 = calculateBoneWallStats(10) + expect(bw10.hp).toBe(60) // 19 + 9 * 4.6 = 60.4 -> 60 + + const bw20 = calculateBoneWallStats(20) + expect(bw20.hp).toBe(106) // 19 + 19 * 4.6 = 106.4 -> 106 + }) + + it('scales with +10% synergy from Bone Armor and Bone Prison', () => { + const bw20Syn = calculateBoneWallStats(20, { boneArmor: 10, bonePrison: 10 }) + // 20 points * 10% = +200% -> 3.0x + expect(bw20Syn.hp).toBe(Math.floor(106.4 * 3.0)) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(78, 1) + expect(res.min).toBe(19) + expect(res.max).toBe(19) + }) + }) + + describe('Skill 83: Poison Explosion (毒爆)', () => { + it('evaluates duration, bitrates, and total poison damage at slvl 1, 10, 20', () => { + const pe1 = calculatePoisonExplosionDamage(1) + expect(pe1.durationFrames).toBe(50) + expect(pe1.durationSeconds).toBe(2.0) + expect(pe1.minRate).toBe(128) + expect(pe1.maxRate).toBe(384) + expect(pe1.minDamage).toBe(25) + expect(pe1.maxDamage).toBe(75) + + const pe10 = calculatePoisonExplosionDamage(10) + expect(pe10.durationFrames).toBe(140) + expect(pe10.durationSeconds).toBe(5.6) + expect(pe10.minRate).toBe(480) + expect(pe10.maxRate).toBe(736) + expect(pe10.minDamage).toBe(262) + expect(pe10.maxDamage).toBe(402) + + const pe20 = calculatePoisonExplosionDamage(20) + expect(pe20.durationFrames).toBe(240) + expect(pe20.durationSeconds).toBe(9.6) + expect(pe20.minRate).toBe(1248) + expect(pe20.maxRate).toBe(1504) + expect(pe20.minDamage).toBe(1170) + expect(pe20.maxDamage).toBe(1410) + }) + + it('scales with +15% synergy from Poison Dagger and Poison Nova', () => { + const syn = calculatePoisonExplosionSynergyMultiplier({ poisonDagger: 10, poisonNova: 10 }) + expect(syn).toBe(4.0) // 1.0 + 20 * 0.15 = 4.0 + + const pe20Syn = calculatePoisonExplosionDamage(20, { poisonDagger: 10, poisonNova: 10 }) + expect(pe20Syn.minRate).toBe(1248 * 4) + expect(pe20Syn.maxRate).toBe(1504 * 4) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(83, 20) + expect(res.min).toBe(1170) + expect(res.max).toBe(1410) + }) + }) + + describe('Skill 84: Bone Spear (骨矛)', () => { + it('evaluates exact magic damage at slvl 1, 10, 20 without synergies', () => { + const d1 = calculateBoneSpearDamage(1) + expect(d1.min).toBe(16) + expect(d1.max).toBe(24) + + const d10 = calculateBoneSpearDamage(10) + expect(d10.min).toBe(90) + expect(d10.max).toBe(98) + + const d20 = calculateBoneSpearDamage(20) + expect(d20.min).toBe(192) + expect(d20.max).toBe(204) + }) + + it('scales with +7% synergy from Teeth, Bone Wall, Prison, Spirit', () => { + const syn = calculateBoneSpearSynergyMultiplier({ teeth: 10, boneWall: 10, bonePrison: 10, boneSpirit: 10 }) + expect(syn).toBe(1.0 + 40 * 0.07) // 3.8 + + const d20Syn = calculateBoneSpearDamage(20, { teeth: 10, boneWall: 10, bonePrison: 10, boneSpirit: 10 }) + expect(d20Syn.min).toBe(Math.floor(192 * 3.8)) + expect(d20Syn.max).toBe(Math.floor(204 * 3.8)) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(84, 20) + expect(res.min).toBe(192) + expect(res.max).toBe(204) + }) + }) + + describe('Skill 88: Bone Prison (骨牢)', () => { + it('evaluates base HP, duration, and structure', () => { + const bp1 = calculateBonePrisonStats(1) + expect(bp1.hp).toBe(19) + expect(bp1.durationSeconds).toBe(24.0) + expect(bp1.durationFrames).toBe(600) + expect(bp1.segmentCount).toBe(16) + + const bp20 = calculateBonePrisonStats(20) + expect(bp20.hp).toBe(106) + }) + + it('scales with +8% synergy from Bone Armor and Bone Wall', () => { + const bp20Syn = calculateBonePrisonStats(20, { boneArmor: 10, boneWall: 10 }) + // 20 points * 8% = +160% -> 2.6x + expect(bp20Syn.hp).toBe(Math.floor(106.4 * 2.6)) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(88, 1) + expect(res.min).toBe(19) + expect(res.max).toBe(19) + }) + }) + + describe('Skill 92: Poison Nova (剧毒新星)', () => { + it('evaluates fixed 2.0s duration and exact bitrates/damage at slvl 1, 10, 20', () => { + const pn1 = calculatePoisonNovaDamage(1) + expect(pn1.durationFrames).toBe(50) + expect(pn1.durationSeconds).toBe(2.0) + expect(pn1.minRate).toBe(256) + expect(pn1.maxRate).toBe(464) + expect(pn1.minDamage).toBe(50) + expect(pn1.maxDamage).toBe(90) + + const pn10 = calculatePoisonNovaDamage(10) + expect(pn10.durationFrames).toBe(50) + expect(pn10.durationSeconds).toBe(2.0) + expect(pn10.minRate).toBe(896) + expect(pn10.maxRate).toBe(1104) + expect(pn10.minDamage).toBe(175) + expect(pn10.maxDamage).toBe(215) + + const pn20 = calculatePoisonNovaDamage(20) + expect(pn20.durationFrames).toBe(50) + expect(pn20.durationSeconds).toBe(2.0) + expect(pn20.minRate).toBe(2048) + expect(pn20.maxRate).toBe(2256) + expect(pn20.minDamage).toBe(400) + expect(pn20.maxDamage).toBe(440) + }) + + it('spawns exactly 64 radial bolts in 360 degrees foreshortened by 0.5', () => { + const bolts = spawnPoisonNovaRing(100, 100, 200) + expect(bolts.length).toBe(64) + expect(bolts[0]!.missileType).toBe('poisonnova') + expect(bolts[0]!.statusEffect).toBe('poison') + expect(bolts[0]!.statusDuration).toBe(50) + + // Test vertical velocity foreshortening + const northBolt = bolts[16] // angle = PI / 2 + expect(northBolt!.vx).toBeCloseTo(0, 1) + expect(northBolt!.vy).toBeCloseTo(12 * ISO_GROUND_ASPECT_RATIO, 1) + }) + + it('scales with +10% synergy from Poison Dagger and Poison Explosion', () => { + const syn = calculatePoisonNovaSynergyMultiplier({ poisonDagger: 10, poisonExplosion: 10 }) + expect(syn).toBe(3.0) + + const pn20Syn = calculatePoisonNovaDamage(20, { poisonDagger: 10, poisonExplosion: 10 }) + expect(pn20Syn.minRate).toBe(2048 * 3) + expect(pn20Syn.maxRate).toBe(2256 * 3) + expect(pn20Syn.minDamage).toBe(1200) + expect(pn20Syn.maxDamage).toBe(1321) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(92, 20) + expect(res.min).toBe(400) + expect(res.max).toBe(440) + }) + }) + + describe('Skill 93: Bone Spirit (白骨之魂)', () => { + it('evaluates exact magic damage at slvl 1, 10, 20 without synergies', () => { + const d1 = calculateBoneSpiritDamage(1) + expect(d1.min).toBe(20) + expect(d1.max).toBe(30) + + const d10 = calculateBoneSpiritDamage(10) + expect(d10.min).toBe(166) + expect(d10.max).toBe(185) + + const d20 = calculateBoneSpiritDamage(20) + expect(d20.min).toBe(340) + expect(d20.max).toBe(369) + }) + + it('scales with +6% synergy from Teeth, Bone Wall, Spear, Prison', () => { + const syn = calculateBoneSpiritSynergyMultiplier({ teeth: 10, boneWall: 10, boneSpear: 10, bonePrison: 10 }) + expect(syn).toBe(1.0 + 40 * 0.06) // 3.4 + + const d20Syn = calculateBoneSpiritDamage(20, { teeth: 10, boneWall: 10, boneSpear: 10, bonePrison: 10 }) + expect(d20Syn.min).toBe(Math.floor(340 * 3.4)) + expect(d20Syn.max).toBe(Math.floor(369 * 3.4)) + }) + + it('routes through calculateSkillDamage correctly', () => { + const res = calculateSkillDamage(93, 20) + expect(res.min).toBe(340) + expect(res.max).toBe(369) + }) + }) + + describe('Kinematics & Missile Engine Runtime', () => { + it('executes Bone Spear piercing trajectory without terminating on target hit', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList() + const target1Stats = new UnitStatList() + target1Stats.setHp256(1000 * 256) + const target2Stats = new UnitStatList() + target2Stats.setHp256(1000 * 256) + + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + const target1 = { id: 'mon_1', name: 'Zombie', statList: target1Stats, stateBus: new StateBus(target1Stats) } as any + const target2 = { id: 'mon_2', name: 'Skeleton', statList: target2Stats, stateBus: new StateBus(target2Stats) } as any + + const targetPositions = new Map([ + ['mon_1', { x: 50, y: 0 }], + ['mon_2', { x: 100, y: 0 }], + ]) + + // Spawn Bone Spear flying eastward (startX: 0, startY: 0, targetX: 200, targetY: 0) + engine.spawnMissile({ + missileNameOrId: 'bonespear', + sourceSkillId: 84, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 200, + targetY: 0, + dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, + }) + + const activeBefore = engine.getActiveMissiles() + expect(activeBefore.length).toBe(1) + const spear = activeBefore[0]! + expect(spear.canPierce).toBe(true) + + // Step simulation until missile reaches mon_1 and continues to mon_2 + for (let t = 1; t <= 10; t++) { + engine.tick(t, [target1, target2], targetPositions) + } + + // Spear must have pierced target1 (pierceCount >= 1) and remained active + expect(spear.pierceCount).toBeGreaterThanOrEqual(1) + expect(spear.expired).toBe(false) + }) + + it('steers Bone Spirit homing missile toward target in 2:1 isometric ground perspective', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList() + const targetStats = new UnitStatList() + targetStats.setHp256(500 * 256) + + const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any + const target = { id: 'mon_target', name: 'Demon', statList: targetStats, stateBus: new StateBus(targetStats) } as any + + // Target is positioned south-east + const targetPositions = new Map([ + ['mon_target', { x: 100, y: 100 }], + ]) + + // Launch Bone Spirit pointing north (startX: 0, startY: 0, targetX: 0, targetY: -100) + engine.spawnMissile({ + missileNameOrId: 'bonespirit', + sourceSkillId: 93, + slvl: 20, + owner: caster, + startX: 0, + startY: 0, + targetX: 0, + targetY: -100, + dmgPacket: { minDamage: 200, maxDamage: 200, damageType: 'magic' } as any, + }) + + const active = engine.getActiveMissiles() + const spirit = active[0]! + // Initial velocity points north + expect(spirit.vy).toBeLessThan(0) + + // Step simulation: homing logic should reorient missile toward mon_target (south) + engine.tick(1, [target], targetPositions) + + // After tick with homing logic, vy must have turned positive (heading south toward target) + expect(spirit.vy).toBeGreaterThan(0) + }) + }) +}) +