feat(nec): implement 10 Necromancer Poison and Bone Spells (M2)

- Implement 10 Poison and Bone skills (Teeth 67, Bone Armor 68, Poison Dagger 73,
  Corpse Explosion 74, Bone Wall 78, Poison Explosion 83, Bone Spear 84, Bone Prison 88,
  Poison Nova 92, Bone Spirit 93) per 1.13c ground truth.
- Decouple damage formulas into standalone exported functions: calculateTeethDamage,
  calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculateCorpseExplosionDamage,
  calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage,
  calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage.
- Preserve BATCH1_SKILLS length strictly at 38 skills.
- Enforce 2:1 isometric ground projection geometry (ISO_GROUND_ASPECT_RATIO = 0.5)
  for collision, radial Poison Nova bursts (64 bolts), and trajectory kinematics.
- Implement Bone Spirit homing steering and Bone Spear piercing (collideKill = 0).
- Integrate casting lifecycle in act-scene: mana deduction, overlays, corpse consumption,
  and dagger weapon requirement for Poison Dagger.
- Add comprehensive 37-test suite in tests/skills/nec/poison-bone.test.ts.
This commit is contained in:
troytt 2026-09-24 07:49:02 +00:00
parent f424a92a1d
commit e070ed404d
4 changed files with 1566 additions and 18 deletions

View File

@ -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<string, number | undefined>
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<string>(),
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 }
}

View File

@ -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<Record<string, string>> = 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<Record<number, string>> = 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<Record<string, string>> = 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

View File

@ -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 {

View File

@ -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<string, { x: number; y: number }>([
['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<string, { x: number; y: number }>([
['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)
})
})
})