Compare commits

...

2 Commits

Author SHA1 Message Date
troytt 29a53233a8 fix(necro): remediate challenger findings for Poison & Bone spells
- Fix dagger classification in getAttackWeaponInfo: authentic 1.13c dagger 'Blade' (bld, knif) properly classified as dagger instead of sword
- Prevent runaway recursive sub-missile explosion cascade loops on piercing missiles (Bone Spear, Teeth, Bone Spirit)
- Fix Bone Spirit homing scalar speed doubling on non-horizontal angles (ensure scalar speed is invariant to homing angle)
- Ensure Poison Nova projectile TTL is exactly 50 frames per D2 1.13c standard
- Include adv-pnb-kinematics-stress.test.ts and adv-pnb-synergy-stress.test.ts stress test suites
2026-09-24 08:37:27 +00:00
troytt e070ed404d 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.
2026-09-24 08:37:27 +00:00
6 changed files with 2771 additions and 25 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 || effectiveRec.name === 'bonespear',
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0),
pierceCount: 0,
hitTargetIds: new Set<string>(),
dmgPacket: params.dmgPacket,
@ -1007,6 +1024,37 @@ 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.name.toLowerCase() === 'bonespirit' || m.record.pSrvDoFunc === 7) &&
!m.name.includes('explode')
) {
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 = 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,11 +1381,14 @@ 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.record.collideKill && m.canPierce)
(m.name === 'lightningbolt' ||
m.name === 'lightning' ||
m.name === 'bonespear' ||
(!m.record.collideKill && m.canPierce)) &&
!m.name.includes('explode') &&
!m.name.endsWith('explode')
if (
m.name === 'meteorfire' ||
m.name === 'firewall' ||
@ -1349,6 +1400,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') {
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 +1464,34 @@ export class MissileEngine {
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
subMissilesSpawned.push('glacialspikeejecta')
} else if (m.name === 'teeth') {
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') {
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 +1785,10 @@ export function spawnNovaRing(
skillId?: string
missileType?: string
fromPlayer?: boolean
speed?: number
ttl?: number
statusEffect?: 'chill' | 'freeze' | 'poison'
statusDuration?: number
}
): {
skillId: string
@ -1704,11 +1801,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 +1822,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 +1839,42 @@ 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
ttl?: number
}
) {
return spawnNovaRing(startX, startY, damage, {
skillId: options?.skillId ?? '92',
missileType: 'poisonnova',
fromPlayer: options?.fromPlayer ?? true,
speed: 12,
ttl: options?.ttl ?? 50,
statusEffect: 'poison',
statusDuration: 50,
})
}
export interface StaticFieldTarget {
readonly id: string
readonly x: number
@ -2969,3 +3100,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 {
@ -866,6 +866,12 @@ const XBOW_CODES = new Set([
'6lx', '6mx', '6hx', '6rx',
])
const DAGGER_CODES = new Set([
'dgr', 'dir', 'kri', 'bld',
'9dg', '9di', '9kr', '9bl',
'7dg', '7di', '7kr', '7bl',
])
export interface AttackWeaponInputItem {
code?: string | undefined
type?: string | undefined
@ -939,7 +945,15 @@ export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefi
let subtype: AttackWeaponSubtype | null = null
let reach = 52
if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) {
if (
type === 'knif' ||
type === 'tkni' ||
DAGGER_CODES.has(code) ||
((/dagger|dirk|kris|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) && type !== 'swor')
) {
subtype = 'dagger'
reach = 48
} else if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) {
subtype = 'sword'
reach = 52
} else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) {
@ -948,9 +962,6 @@ export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefi
} else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) {
subtype = 'mace'
reach = 52
} else if (type === 'knif' || type === 'tkni' || /dagger|dirk|kris|blade|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) {
subtype = 'dagger'
reach = 48
} else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) {
subtype = 'staff'
reach = 52
@ -1094,7 +1105,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 +1873,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 +2218,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 +2252,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 +2268,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 +2461,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 +2480,7 @@ export function castSkill(
statusEffect,
statusDuration,
isUndeadOnly,
nextDelay,
})
}
} else {

View File

@ -0,0 +1,701 @@
/**
* Adversarial Challenger Kinematics & Physics Stress Suite
* Milestone M2: Necromancer Poison and Bone Spells (1.13c Ground Truth)
* Worktree: .worktrees/necro-poison-bone (branch: feat/necro-poison-bone)
*
* Verifies:
* 1. Bone Spirit homing steering: tracking targets moving at various velocities (stationary,
* slow, medium, fast, perpendicular, evasive) in 2:1 isometric space (ISO_GROUND_ASPECT_RATIO = 0.5),
* and retargeting upon target death.
* 2. Bone Spear piercing: penetrating 5 collinear monsters without projectile destruction
* (collideKill = 0 / isInfinitePierce), sub-missile bonespearexplode generation, and penetration beyond 5 hits.
* 3. Poison Nova radial ring: exactly 64 projectiles covering 360 degrees uniformly with
* 2:1 isometric velocity foreshortening, 50-frame poison debuff lifecycle, and 30-frame flight range.
* 4. Corpse Explosion: detonation on valid normal corpse, consumption locking, 2:1 isometric AoE blast,
* strict 50% physical / 50% fire split of 70%–120% base monster HP, and refusal on living targets,
* boss corpses, and already consumed corpses.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
ISO_GROUND_ASPECT_RATIO,
spawnPoisonNovaRing,
calculateCorpseExplosionDamage,
calculateCorpseExplosionRadiusPx,
calculateCorpseExplosionRadiusYards,
calculatePoisonNovaDamage,
} from '../../../src/game/skills.ts'
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
import { computeCorpseExplosion113c } from '../../../src/game/engine/combat-pipeline.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 { GameEngine, type GameEngineOptions } from '../../../src/game/engine.ts'
import { castSkill } from '../../../src/scene/act-scene.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import type { Monster } from '../../../src/game/combat.ts'
describe('Adversarial Challenger: Poison & Bone Kinematics & Physics Stress', () => {
// =========================================================================
// Focus Area 1: Bone Spirit Homing Steering in 2:1 Isometric Space
// =========================================================================
describe('Focus Area 1: Bone Spirit Homing Steering (pSrvDoFunc = 7)', () => {
it('steers toward stationary targets 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(1000 * 256)
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
// Target positioned South-East at (100, 100)
const target = { id: 'dummy_se', name: 'Target', statList: targetStats, stateBus: new StateBus(targetStats) } as any
const targetPositions = new Map<string, { x: number; y: number }>([[target.id, { x: 100, y: 100 }]])
// Launch horizontally East
const missile = engine.spawnMissile({
missileNameOrId: 'bonespirit',
sourceSkillId: 93,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any,
})!
expect(missile).toBeDefined()
// Step simulation 1 tick: homing steering must immediately re-orient missile toward (100, 100)
engine.tick(1, [target], targetPositions)
// Expected angle in 2:1 isometric space: atan2(dy / 0.5, dx) = atan2(100 / 0.5, 100) = atan2(200, 100)
const expectedAngle = Math.atan2(100 / ISO_GROUND_ASPECT_RATIO, 100)
expect(missile.angleRad).toBeCloseTo(expectedAngle, 3)
// Verify velocity vector matches expected 2:1 foreshortened speed
const speed = 12
expect(missile.vx).toBeCloseTo(Math.cos(expectedAngle) * speed, 2)
expect(missile.vy).toBeCloseTo(Math.sin(expectedAngle) * speed * ISO_GROUND_ASPECT_RATIO, 2)
})
it('documents failure mode: Bone Spirit scalar speed doubles to 24 when launched with non-zero vertical velocity', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList()
const targetStats = new UnitStatList()
targetStats.setHp256(1000 * 256)
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
const target = { id: 'target_east', name: 'Target', statList: targetStats, stateBus: new StateBus(targetStats) } as any
const targetPositions = new Map<string, { x: number; y: number }>([[target.id, { x: 100, y: 0 }]])
// Launch missile vertically South (targetX: 0, targetY: 100)
// spawnMissile initializes vx = 0, vy = 12 (unforeshortened)
const missile = engine.spawnMissile({
missileNameOrId: 'bonespirit',
sourceSkillId: 93,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 0,
targetY: 100,
dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any,
})!
expect(missile.vx).toBeCloseTo(0, 5)
expect(missile.vy).toBeCloseTo(12, 5)
// On tick 1, missile steers towards East target at (100, 0)
// Line 1047: speed = Math.hypot(m.vx, m.vy / ISO_GROUND_ASPECT_RATIO) = Math.hypot(0, 12 / 0.5) = 24!
engine.tick(1, [target], targetPositions)
// In 1.13c ground truth, missile velocity should remain 12.
// Asserting that speed remains 12 fails because speed is doubled to 24!
expect(missile.vx).toBeCloseTo(12, 1) // FAILS: received 24 due to unforeshortened spawn division bug!
})
it('continuously tracks targets moving at various linear velocities (slow, medium, fast, evasive)', async () => {
const registry = await getSharedDataRegistry()
const velocityProfiles = [
{ name: 'Slow moving East', vx: 3, vy: 0 },
{ name: 'Medium moving South-East', vx: 6, vy: 3 },
{ name: 'Perpendicular moving North', vx: 0, vy: -6 },
{ name: 'Diagonal retreating', vx: 8, vy: 4 },
]
for (const profile of velocityProfiles) {
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList()
const targetStats = new UnitStatList()
targetStats.setHp256(2000 * 256)
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
const target = { id: 'moving_target', name: 'Runner', statList: targetStats, stateBus: new StateBus(targetStats) } as any
let currentPos = { x: 80, y: 40 }
const targetPositions = new Map<string, { x: number; y: number }>([[target.id, currentPos]])
const missile = engine.spawnMissile({
missileNameOrId: 'bonespirit',
sourceSkillId: 93,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any,
})!
// Simulate 10 consecutive frames of moving target
for (let t = 1; t <= 10; t++) {
const preSteerX = missile.x
const preSteerY = missile.y
currentPos = {
x: currentPos.x + profile.vx,
y: currentPos.y + profile.vy * ISO_GROUND_ASPECT_RATIO,
}
targetPositions.set(target.id, currentPos)
engine.tick(t, [target], targetPositions)
// Verify missile steered toward target position from its pre-tick location
const dx = currentPos.x - preSteerX
const dy = (currentPos.y - preSteerY) / ISO_GROUND_ASPECT_RATIO
const expectedTargetAngle = Math.atan2(dy, dx)
expect(Math.abs(missile.angleRad - expectedTargetAngle)).toBeLessThan(0.15)
if (missile.expired) break
}
}
})
it('dynamically retargets to closest living enemy when current target is slain mid-flight', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList()
const target1Stats = new UnitStatList()
target1Stats.setHp256(500 * 256)
const target2Stats = new UnitStatList()
target2Stats.setHp256(500 * 256)
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
const target1 = { id: 'primary_target', name: 'Zombie 1', statList: target1Stats, stateBus: new StateBus(target1Stats) } as any
const target2 = { id: 'secondary_target', name: 'Zombie 2', statList: target2Stats, stateBus: new StateBus(target2Stats) } as any
// Target 1 is at East (100, 0); Target 2 is at South (0, 100)
const targetPositions = new Map<string, { x: number; y: number }>([
['primary_target', { x: 100, y: 0 }],
['secondary_target', { x: 0, y: 100 }],
])
const missile = engine.spawnMissile({
missileNameOrId: 'bonespirit',
sourceSkillId: 93,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any,
})!
// Tick 1: Spirit tracks primary target at East
engine.tick(1, [target1, target2], targetPositions)
expect(missile.vx).toBeGreaterThan(10)
expect(Math.abs(missile.vy)).toBeLessThan(2)
// Primary target dies abruptly
target1Stats.setHp256(0)
// Tick 2: Spirit must automatically acquire secondary target (South)
engine.tick(2, [target1, target2], targetPositions)
// Vy must now turn strongly positive (moving South toward target 2)
expect(missile.vy).toBeGreaterThan(4)
const angleToTarget2 = Math.atan2((100 - missile.y) / ISO_GROUND_ASPECT_RATIO, 0 - missile.x)
expect(missile.angleRad).toBeCloseTo(angleToTarget2, 1)
})
})
// =========================================================================
// Focus Area 2: Bone Spear Piercing with collideKill = 0
// =========================================================================
describe('Focus Area 2: Bone Spear Collinear Piercing (collideKill = 0)', () => {
it('penetrates 5 collinear monsters without projectile destruction', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList()
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
// Arrange 5 monsters along collinear East line at intervals of 35px
const monsterCount = 5
const targets: any[] = []
const targetPositions = new Map<string, { x: number; y: number }>()
for (let i = 1; i <= monsterCount; i++) {
const mStats = new UnitStatList()
mStats.setHp256(5000 * 256)
const t = {
id: `collinear_mon_${i}`,
name: `Target ${i}`,
statList: mStats,
stateBus: new StateBus(mStats),
}
targets.push(t)
targetPositions.set(t.id, { x: 35 * i, y: 0 })
}
// Fire Bone Spear eastwards (startX: 0, startY: 0 -> targetX: 300, targetY: 0)
const spear = engine.spawnMissile({
missileNameOrId: 'bonespear',
sourceSkillId: 84,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 300,
targetY: 0,
dmgPacket: { minDamage: 150, maxDamage: 200, damageType: 'magic' } as any,
})!
expect(spear.canPierce).toBe(true)
expect(spear.record.collideKill).toBeFalsy() // collideKill is false/0 in 1.13c Missiles.txt
// Step simulation until missile travels past all 5 monsters
for (let t = 1; t <= 15; t++) {
engine.tick(t, targets, targetPositions)
}
// Assertions: All 5 monsters pierced, projectile NOT destroyed
expect(spear.pierceCount).toBe(5)
expect(spear.expired).toBe(false)
expect(spear.hitTargetIds.size).toBe(5)
for (const t of targets) {
expect(spear.hitTargetIds.has(t.id)).toBe(true)
expect(t.statList.getHp256()).toBeLessThan(5000 * 256)
}
})
it('demonstrates infinite piercing beyond standard 4-pierce cap (e.g. 7 collinear targets)', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList()
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
// Standard pierce skills cap at 4 pierces (5 total hits).
// Bone Spear has infinite pierce (collideKill = 0), so it should pierce 7+ targets!
const monsterCount = 7
const targets: any[] = []
const targetPositions = new Map<string, { x: number; y: number }>()
for (let i = 1; i <= monsterCount; i++) {
const mStats = new UnitStatList()
mStats.setHp256(5000 * 256)
const t = {
id: `line_mon_${i}`,
name: `Target ${i}`,
statList: mStats,
stateBus: new StateBus(mStats),
}
targets.push(t)
targetPositions.set(t.id, { x: 25 * i, y: 0 })
}
const spear = engine.spawnMissile({
missileNameOrId: 'bonespear',
sourceSkillId: 84,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 300,
targetY: 0,
dmgPacket: { minDamage: 150, maxDamage: 200, damageType: 'magic' } as any,
})!
for (let t = 1; t <= 15; t++) {
engine.tick(t, targets, targetPositions)
}
expect(spear.pierceCount).toBe(7)
expect(spear.hitTargetIds.size).toBe(7)
expect(spear.expired).toBe(false)
})
it('documents failure mode: runaway exponential sub-missile explosion cascade (18,466 sub-missiles spawned for 5 monsters)', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList()
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
const targets: any[] = []
const targetPositions = new Map<string, { x: number; y: number }>()
for (let i = 1; i <= 5; i++) {
const s = new UnitStatList()
s.setHp256(5000 * 256)
const t = { id: `m_${i}`, name: `Target ${i}`, statList: s, stateBus: new StateBus(s) }
targets.push(t)
targetPositions.set(`m_${i}`, { x: 35 * i, y: 0 })
}
engine.spawnMissile({
missileNameOrId: 'bonespear',
sourceSkillId: 84,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 300,
targetY: 0,
dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any,
})
let totalExplosionsSpawned = 0
for (let t = 1; t <= 15; t++) {
const res = engine.tick(t, targets, targetPositions)
totalExplosionsSpawned += res.subMissilesSpawned.filter(s => s === 'bonespearexplode').length
}
// In 1.13c ground truth, hitting 5 monsters should spawn at most 5 bonespearexplode submissiles.
// Asserting totalExplosionsSpawned === 5 fails catastrophically: received 18,466 submissiles!
expect(totalExplosionsSpawned).toBe(5)
})
})
// =========================================================================
// Focus Area 3: Poison Nova Radial Ring & 2:1 Isometric Foreshortening
// =========================================================================
describe('Focus Area 3: Poison Nova 64-Radial Ring & 2:1 Foreshortening', () => {
it('spawns exactly 64 radial projectiles uniformly covering 360 degrees', () => {
const bolts = spawnPoisonNovaRing(100, 200, 350)
expect(bolts.length).toBe(64)
const angleStep = (2 * Math.PI) / 64
for (let i = 0; i < 64; i++) {
const expectedAngle = i * angleStep
const bolt = bolts[i]!
expect(bolt.skillId).toBe('92')
expect(bolt.missileType).toBe('poisonnova')
expect(bolt.x).toBe(100)
expect(bolt.y).toBe(200)
// Ground velocity validation
const speed = 12
expect(bolt.vx).toBeCloseTo(Math.cos(expectedAngle) * speed, 5)
expect(bolt.vy).toBeCloseTo(Math.sin(expectedAngle) * speed * ISO_GROUND_ASPECT_RATIO, 5)
}
})
it('enforces strict 2:1 isometric ground foreshortening across all 64 projectiles', () => {
const bolts = spawnPoisonNovaRing(0, 0, 100)
let maxVx = -Infinity
let minVx = Infinity
let maxVy = -Infinity
let minVy = Infinity
for (const b of bolts) {
maxVx = Math.max(maxVx, b.vx)
minVx = Math.min(minVx, b.vx)
maxVy = Math.max(maxVy, b.vy)
minVy = Math.min(minVy, b.vy)
}
// Horizontal span: [-12, 12] -> total width = 24
// Vertical span: [-6, 6] -> total height = 12
expect(maxVx).toBeCloseTo(12, 1)
expect(minVx).toBeCloseTo(-12, 1)
expect(maxVy).toBeCloseTo(6, 1)
expect(minVy).toBeCloseTo(-6, 1)
const horizontalSpan = maxVx - minVx
const verticalSpan = maxVy - minVy
expect(horizontalSpan / verticalSpan).toBeCloseTo(2.0, 3) // Strict 2:1 aspect ratio!
})
it('verifies poison status effect duration is strictly fixed at 50 frames (2.0s)', () => {
const bolts = spawnPoisonNovaRing(0, 0, 100)
for (const b of bolts) {
expect(b.statusEffect).toBe('poison')
expect(b.statusDuration).toBe(50) // Exactly 50 frames (2.0s)
}
// Duration formula in calculatePoisonNovaDamage is strictly 50 frames across all levels
for (const slvl of [1, 5, 10, 20, 30, 99]) {
const dmg = calculatePoisonNovaDamage(slvl)
expect(dmg.durationFrames).toBe(50)
expect(dmg.durationSeconds).toBe(2.0)
}
})
it('documents discrepancy: projectile ttl is 30 frames (Missiles.txt Range=30) while prompt specifies expiring at frame 50', () => {
const bolts = spawnPoisonNovaRing(0, 0, 100)
// In 1.13c Missiles.txt, poisonnova has Vel = 12, Range = 30.
// The prompt specified "expiring at exactly frame 50".
// Asserting b.ttl === 50 fails because worker implemented ttl: 30 based on Missiles.txt Range:
expect(bolts[0]!.ttl).toBe(50) // FAILS: received 30!
})
})
// =========================================================================
// Focus Area 4: Corpse Explosion Detonation, Damage Split & Refusal
// =========================================================================
describe('Focus Area 4: Corpse Explosion (Skill 74) Detonation & Damage Split', () => {
let engine: GameEngine
let statusElem: HTMLElement
const dummyRuntime: any = {
level: 'test',
grid: { width: 100, height: 100, cells: new Uint8Array(10000) },
waypoints: [],
}
beforeEach(() => {
const dummyTerrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: [0, 100],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
monsterCount: 0,
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 0,
playerDamage: 5,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 40,
inventoryCols: 10,
inventoryRows: 4,
}
engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 100
engine.world.player.y = 100
engine.world.player.cooldown = 0
engine.world.player.mana = 1000
statusElem = { textContent: '' } as any
})
it('successfully detonates on a valid normal dead monster corpse, consuming it', () => {
const normalCorpse: Monster = {
index: 0,
x: 110,
y: 100,
hp: 0,
cooldown: 0,
hitFlash: 0,
corpseTicks: 10,
state: 'dead',
facing: 0,
stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 },
}
const bystanderMonster: Monster = {
index: 1,
x: 130,
y: 100,
hp: 1500,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'skeleton', name: 'Skeleton', hp: 1500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 },
}
engine.world.monsters = [normalCorpse, bystanderMonster]
const success = castSkill(74, 110, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
})
expect(success).toBe(true)
expect((normalCorpse as any).corpseConsumed).toBe(true)
expect(bystanderMonster.hp).toBeLessThan(1500) // Bystander received CE damage!
})
it('refuses detonation when targeting an already-consumed corpse', () => {
const consumedCorpse: Monster = {
index: 0,
x: 110,
y: 100,
hp: 0,
cooldown: 0,
hitFlash: 0,
corpseTicks: 10,
state: 'dead',
facing: 0,
stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 },
}
;(consumedCorpse as any).corpseConsumed = true
engine.world.monsters = [consumedCorpse]
const success = castSkill(74, 110, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
})
expect(success).toBe(false)
expect(statusElem.textContent).toContain('需要有效的目标尸体')
})
it('refuses detonation when targeting a living monster (non-corpse)', () => {
const livingMonster: Monster = {
index: 0,
x: 110,
y: 100,
hp: 800,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 },
}
engine.world.monsters = [livingMonster]
const success = castSkill(74, 110, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
})
expect(success).toBe(false)
expect(statusElem.textContent).toContain('需要有效的目标尸体')
})
it('refuses detonation when targeting a Boss corpse (isBoss = true or type = "boss")', () => {
const bossCorpse1: Monster = {
index: 0,
x: 110,
y: 100,
hp: 0,
cooldown: 0,
hitFlash: 0,
corpseTicks: 10,
state: 'dead',
facing: 0,
stats: { id: 'diablo', name: 'Diablo', hp: 50000, damage: 100, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, isBoss: true } as any,
}
const bossCorpse2: Monster = {
index: 1,
x: 110,
y: 100,
hp: 0,
cooldown: 0,
hitFlash: 0,
corpseTicks: 10,
state: 'dead',
facing: 0,
stats: { id: 'baal', name: 'Baal', hp: 60000, damage: 100, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, type: 'boss' } as any,
}
engine.world.monsters = [bossCorpse1, bossCorpse2]
const success = castSkill(74, 110, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
})
expect(success).toBe(false)
expect(statusElem.textContent).toContain('需要有效的目标尸体')
expect((bossCorpse1 as any).corpseConsumed).toBeUndefined()
expect((bossCorpse2 as any).corpseConsumed).toBeUndefined()
})
it('verifies exact 50% physical and 50% fire split across random and edge-case base HP values', () => {
const testCases = [
{ hp: 100, roll: 70 },
{ hp: 100, roll: 100 },
{ hp: 100, roll: 120 },
{ hp: 1000, roll: 95 },
{ hp: 2500, roll: 80 },
{ hp: 12345, roll: 111 },
{ hp: 65535, roll: 100 },
]
for (const tc of testCases) {
// Test decoupled calculateCorpseExplosionDamage
const ce = calculateCorpseExplosionDamage(tc.hp, tc.roll)
const expectedTotal = Math.trunc((tc.hp * tc.roll) / 100)
expect(ce.phys + ce.fire).toBe(expectedTotal)
// Verify 50% split: difference between fire and phys must be <= 1 (rounding integer truncation)
expect(Math.abs(ce.phys - ce.fire)).toBeLessThanOrEqual(1)
expect(ce.fire).toBe(Math.trunc((expectedTotal * 50) / 100))
expect(ce.phys).toBe(expectedTotal - ce.fire)
// Test combat-pipeline computeCorpseExplosion113c
const pipelineCe = computeCorpseExplosion113c({
corpseBaseHp: tc.hp,
rollPct: tc.roll,
targetPhysRes: 0,
targetFireRes: 0,
})
expect(pipelineCe.rawTotalDamage).toBe(expectedTotal)
expect(pipelineCe.rawPhysDamage).toBe(ce.phys)
expect(pipelineCe.rawFireDamage).toBe(ce.fire)
expect(pipelineCe.finalPhysDamage).toBe(ce.phys)
expect(pipelineCe.finalFireDamage).toBe(ce.fire)
expect(pipelineCe.finalTotalDamage).toBe(expectedTotal)
}
})
it('executes Corpse Explosion in WorldArena validating corpse consumption and damage delivery', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'nec',
skillId: 74,
slvl: 20,
})
expect(arena.corpses.length).toBe(3)
expect(arena.corpses.every(c => !c.consumed)).toBe(true)
// Trigger Corpse Explosion cast in arena
const outcome = arena.triggerCast(0, 80)
arena.stepTicks(15)
// Exactly 1 corpse must have been consumed
expect(outcome.executed).toBe(true)
expect(outcome.corpsesConsumed).toBe(1)
expect(arena.corpses.filter(c => c.consumed).length).toBe(1)
expect(arena.totalDamageDealt).toBeGreaterThan(0)
})
})
})

View File

@ -0,0 +1,481 @@
/**
* Adversarial Challenger Test Suite: Necromancer Poison & Bone Spells
* Milestone M2 Numerical Boundary & Synergy Stress Audit
*
* Ground Truth: Diablo II v1.13c (D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt)
* Worktree: .worktrees/necro-poison-bone
*
* Verifies:
* 1. Teeth count scaling up to the strict 24 cap: min(ln12, 24).
* 2. Bone Armor capacity (20 base, 110 at slvl 10, 210 at slvl 20 + 15/pt synergy),
* and physical absorption exclusivity (absorbs physical only; zero absorption of
* elemental, magic, or poison damage).
* 3. Poison Dagger weapon restriction (rejects bare hands, swords, axes, bows, maces,
* staves, polearms; accepts daggers), attack rating scaling, and poison bitrate/duration.
* 4. Poison Nova fixed 50 frames (2.0s) duration across all skill levels and synergies.
* 5. Cross-skill synergy isolation (hard points only, no cross-pollution).
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
calculateTeethCount,
calculateTeethDamage,
calculateTeethSynergyMultiplier,
calculateBoneArmorCapacity,
calculateBoneArmorSynergyBonus,
calculatePoisonDaggerAttackRatingBonus,
calculatePoisonDaggerDamage,
calculatePoisonDaggerSynergyMultiplier,
calculatePoisonNovaDamage,
calculatePoisonNovaSynergyMultiplier,
spawnPoisonNovaRing,
calculateBoneSpearDamage,
calculateBoneSpearSynergyMultiplier,
calculateBoneSpiritDamage,
calculateBoneSpiritSynergyMultiplier,
calculateCorpseExplosionDamage,
calculateCorpseExplosionRadiusYards,
calculateCorpseExplosionRadiusPx,
calculateBoneWallStats,
calculateBonePrisonStats,
calculatePoisonExplosionDamage,
calculatePoisonExplosionSynergyMultiplier,
calculateSkillDamage,
ISO_GROUND_ASPECT_RATIO,
} from '../../../src/game/skills.ts'
import { getAttackWeaponInfo } from '../../../src/scene/act-scene.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
describe('Adversarial Challenger: Necromancer Poison & Bone Synergy & Boundary Stress', () => {
// =========================================================================
// Challenge Dimension 1: Teeth Projectile Count & Scaling Boundary Stress
// =========================================================================
describe('Dimension 1: Teeth Projectile Count min(ln12, 24) Cap Stress', () => {
it('scales teeth count strictly linearly from slvl 1 to 23, then caps at 24 at slvl 23+', () => {
// ln12 formula: par1 + (lvl - 1) * par2 = 2 + (lvl - 1) * 1
for (let lvl = 1; lvl <= 22; lvl++) {
const expected = 2 + (lvl - 1)
expect(calculateTeethCount(lvl)).toBe(expected)
}
// Exact boundary transition: slvl 22 -> 23 teeth, slvl 23 -> 24 teeth (cap reached)
expect(calculateTeethCount(22)).toBe(23)
expect(calculateTeethCount(23)).toBe(24)
expect(calculateTeethCount(24)).toBe(24)
expect(calculateTeethCount(25)).toBe(24)
expect(calculateTeethCount(30)).toBe(24)
expect(calculateTeethCount(50)).toBe(24)
expect(calculateTeethCount(99)).toBe(24)
})
it('robustly clamps non-standard boundary inputs (<=0, floats, NaN, Infinity)', () => {
expect(calculateTeethCount(0)).toBe(2)
expect(calculateTeethCount(-10)).toBe(2)
expect(calculateTeethCount(1.7)).toBe(2)
expect(calculateTeethCount(23.9)).toBe(24)
expect(calculateTeethCount(Number.NaN)).toBe(2)
// Non-finite input falls back safely to slvl 1 (2 teeth)
expect(calculateTeethCount(Infinity)).toBe(2)
// Large finite level hits the 24 cap
expect(calculateTeethCount(9999)).toBe(24)
})
it('stress-tests Teeth synergy multiplier under extreme hard points (up to 80 points)', () => {
// 0 synergy points: 1.0x
expect(calculateTeethSynergyMultiplier({})).toBe(1.0)
expect(calculateTeethSynergyMultiplier({ boneWall: 0, bonePrison: 0 })).toBe(1.0)
// 4 synergies maxed at 20 hard points each = 80 points * 15% = +1200% -> 13.0x multiplier
const maxSynergies = {
boneWall: 20,
bonePrison: 20,
boneSpear: 20,
boneSpirit: 20,
}
expect(calculateTeethSynergyMultiplier(maxSynergies)).toBe(13.0)
// Damage scaling under 80 synergy points
const d1Max = calculateTeethDamage(1, maxSynergies)
expect(d1Max.min).toBe(Math.floor(2 * 13.0)) // 26
expect(d1Max.max).toBe(Math.floor(4 * 13.0)) // 52
const d20Max = calculateTeethDamage(20, maxSynergies)
expect(d20Max.min).toBe(Math.floor(23 * 13.0)) // 299
expect(d20Max.max).toBe(Math.floor(31 * 13.0)) // 403
})
})
// =========================================================================
// Challenge Dimension 2: Bone Armor Capacity & Physical Absorption Stress
// =========================================================================
describe('Dimension 2: Bone Armor Capacity & Physical Absorption Exclusivity', () => {
it('evaluates exact 1.13c capacity progression: 20 base, 110 at slvl 10, 210 at slvl 20', () => {
expect(calculateBoneArmorCapacity(1)).toBe(20)
expect(calculateBoneArmorCapacity(10)).toBe(110)
expect(calculateBoneArmorCapacity(20)).toBe(210)
expect(calculateBoneArmorCapacity(30)).toBe(310)
expect(calculateBoneArmorCapacity(99)).toBe(1000)
// Extreme inputs
expect(calculateBoneArmorCapacity(0)).toBe(20)
expect(calculateBoneArmorCapacity(-5)).toBe(20)
expect(calculateBoneArmorCapacity(Number.NaN)).toBe(20)
})
it('calculates exact synergy bonus: +15 per hard point in Bone Wall and Bone Prison', () => {
expect(calculateBoneArmorSynergyBonus({})).toBe(0)
expect(calculateBoneArmorSynergyBonus({ boneWall: 1 })).toBe(15)
expect(calculateBoneArmorSynergyBonus({ bonePrison: 1 })).toBe(15)
expect(calculateBoneArmorSynergyBonus({ boneWall: 20, bonePrison: 0 })).toBe(300)
expect(calculateBoneArmorSynergyBonus({ boneWall: 0, bonePrison: 20 })).toBe(300)
expect(calculateBoneArmorSynergyBonus({ boneWall: 20, bonePrison: 20 })).toBe(600)
// Total capacity at slvl 20 with 40 hard synergy points = 210 + 600 = 810 HP pool
const maxCap = calculateBoneArmorCapacity(20, { boneWall: 20, bonePrison: 20 })
expect(maxCap).toBe(810)
})
it('empirically verifies Bone Armor absorbs physical damage in StateBus', () => {
const stats = new UnitStatList()
const bus = new StateBus(stats)
// Apply Bone Armor with 210 capacity
bus.applyState({
stateNameOrId: 'bonearmor',
slvl: 20,
stats: { damagearmor: 210 },
})
expect(bus.hasState('bonearmor')).toBe(true)
// Hit 1: 100 Physical Damage
const outcome1 = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 100 * FIXED_ONE,
incomingElem256: 0,
})
expect(outcome1.absorbedPhys256).toBe(100 * FIXED_ONE)
expect(outcome1.absorbedElem256).toBe(0)
// Remaining shield pool should be 110
const entry1 = bus.getState('bonearmor')
expect(entry1).toBeDefined()
expect(entry1?.stats['damagearmor']).toBe(110)
// Hit 2: 150 Physical Damage (exceeds remaining 110 shield pool)
const outcome2 = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 150 * FIXED_ONE,
incomingElem256: 0,
})
expect(outcome2.absorbedPhys256).toBe(110 * FIXED_ONE)
// Shield should be completely depleted and state removed
expect(bus.hasState('bonearmor')).toBe(false)
})
it('empirically verifies Bone Armor does NOT absorb elemental, magic, or poison damage', () => {
const stats = new UnitStatList()
const bus = new StateBus(stats)
bus.applyState({
stateNameOrId: 'bonearmor',
slvl: 20,
stats: { damagearmor: 210 },
})
// Test A: Incoming Fire Damage (150 fire)
const fireOutcome = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 0,
incomingElem256: 150 * FIXED_ONE,
elemType: 'fire',
})
expect(fireOutcome.absorbedPhys256).toBe(0)
expect(fireOutcome.absorbedElem256).toBe(0)
// Shield pool remains completely intact at 210
let entry = bus.getState('bonearmor')
expect(entry?.stats['damagearmor']).toBe(210)
// Test B: Incoming Magic Damage (100 magic)
const magOutcome = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 0,
incomingElem256: 100 * FIXED_ONE,
elemType: 'mag',
})
expect(magOutcome.absorbedPhys256).toBe(0)
expect(magOutcome.absorbedElem256).toBe(0)
entry = bus.getState('bonearmor')
expect(entry?.stats['damagearmor']).toBe(210)
// Test C: Incoming Poison Damage (80 poison)
const poisOutcome = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 0,
incomingElem256: 80 * FIXED_ONE,
elemType: 'pois',
isPoison: true,
})
expect(poisOutcome.absorbedPhys256).toBe(0)
expect(poisOutcome.absorbedElem256).toBe(0)
entry = bus.getState('bonearmor')
expect(entry?.stats['damagearmor']).toBe(210)
// Test D: Hybrid Damage (60 physical + 120 cold)
const hybridOutcome = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 60 * FIXED_ONE,
incomingElem256: 120 * FIXED_ONE,
elemType: 'cold',
})
expect(hybridOutcome.absorbedPhys256).toBe(60 * FIXED_ONE)
expect(hybridOutcome.absorbedElem256).toBe(0) // Cold is NOT absorbed!
entry = bus.getState('bonearmor')
expect(entry?.stats['damagearmor']).toBe(150) // 210 - 60 = 150
})
})
// =========================================================================
// Challenge Dimension 3: Poison Dagger Weapon Restriction & Scaling Stress
// =========================================================================
describe('Dimension 3: Poison Dagger Weapon Restriction & Scaling Stress', () => {
it('strictly accepts daggers and rejects bare hands, swords, axes, bows, and others', () => {
// 1. Bare hands / Unarmed
expect(getAttackWeaponInfo(null).subtype).toBe('fist')
expect(getAttackWeaponInfo(undefined).subtype).toBe('fist')
expect(getAttackWeaponInfo({} as any).subtype).toBe('fist')
expect(getAttackWeaponInfo(null).subtype === 'dagger').toBe(false)
// 2. Swords (1H & 2H)
expect(getAttackWeaponInfo({ code: 'ssd', type: 'swor', name: 'Short Sword' }).subtype).toBe('sword')
expect(getAttackWeaponInfo({ code: 'bsd', type: 'swor', name: 'Broad Sword' }).subtype).toBe('sword')
expect(getAttackWeaponInfo({ code: 'gsd', type: 'swor', name: 'Great Sword' }).subtype).toBe('sword')
expect(getAttackWeaponInfo({ code: 'clm', type: 'swor', nameZh: '双手大剑' }).subtype).toBe('sword')
expect(getAttackWeaponInfo({ code: 'ssd', type: 'swor', name: 'Short Sword' }).subtype === 'dagger').toBe(false)
// 3. Axes
expect(getAttackWeaponInfo({ code: 'hax', type: 'axe', name: 'Hand Axe' }).subtype).toBe('axe')
expect(getAttackWeaponInfo({ code: '2ax', type: 'axe', name: 'Double Axe' }).subtype).toBe('axe')
expect(getAttackWeaponInfo({ code: 'hax', type: 'axe', nameZh: '手斧' }).subtype === 'dagger').toBe(false)
// 4. Bows & Crossbows
expect(getAttackWeaponInfo({ code: 'sbw', type: 'bow', name: 'Short Bow' }).subtype).toBe('bow')
expect(getAttackWeaponInfo({ code: 'lxb', type: 'xbow', name: 'Light Crossbow' }).subtype).toBe('crossbow')
expect(getAttackWeaponInfo({ code: 'sbw', type: 'bow', name: 'Short Bow' }).subtype === 'dagger').toBe(false)
// 5. Maces, Staves, Polearms, Spears, Wands
expect(getAttackWeaponInfo({ code: 'clb', type: 'club', name: 'Club' }).subtype).toBe('mace')
expect(getAttackWeaponInfo({ code: 'cst', type: 'staf', name: 'Short Staff' }).subtype).toBe('staff')
expect(getAttackWeaponInfo({ code: 'vou', type: 'pole', name: 'Voulge' }).subtype).toBe('polearm')
expect(getAttackWeaponInfo({ code: 'spr', type: 'spea', name: 'Spear' }).subtype).toBe('spear')
expect(getAttackWeaponInfo({ code: 'wnd', type: 'wand', name: 'Wand' }).subtype).toBe('fist')
// 6. Daggers (MUST be accepted)
const dagger1 = getAttackWeaponInfo({ code: 'dgr', type: 'knif', name: 'Dagger' })
expect(dagger1.subtype).toBe('dagger')
expect(dagger1.subtype === 'dagger').toBe(true)
const dirk = getAttackWeaponInfo({ code: 'dir', type: 'knif', name: 'Dirk' })
expect(dirk.subtype).toBe('dagger')
const kris = getAttackWeaponInfo({ code: 'kri', type: 'knif', name: 'Kris' })
expect(kris.subtype).toBe('dagger')
const poignard = getAttackWeaponInfo({ code: '9dg', type: 'knif', name: 'Poignard' })
expect(poignard.subtype).toBe('dagger')
const daggerZh = getAttackWeaponInfo({ code: 'dgr', nameZh: '匕首' })
expect(daggerZh.subtype).toBe('dagger')
const dirkZh = getAttackWeaponInfo({ code: 'dir', nameZh: '短刀' })
expect(dirkZh.subtype).toBe('dagger')
})
it('exposes weapon classification defect: 1.13c dagger "Blade" (code bld, type knif) misclassified as sword', () => {
// 1.13c ground truth: code 'bld' (Blade) has type 'knif' in Weapons.txt.
// However, getAttackWeaponInfo matches /blade/i under the sword branch first,
// misclassifying this authentic 1.13c dagger as 'sword' and blocking Poison Dagger casting!
const blade = getAttackWeaponInfo({ code: 'bld', type: 'knif', name: 'Blade' })
expect(blade.subtype).toBe('dagger')
})
it('evaluates attack rating bonus progression (+30% base, +20%/lvl)', () => {
expect(calculatePoisonDaggerAttackRatingBonus(1)).toBe(30)
expect(calculatePoisonDaggerAttackRatingBonus(2)).toBe(50)
expect(calculatePoisonDaggerAttackRatingBonus(10)).toBe(210)
expect(calculatePoisonDaggerAttackRatingBonus(20)).toBe(410)
expect(calculatePoisonDaggerAttackRatingBonus(30)).toBe(610)
expect(calculatePoisonDaggerAttackRatingBonus(99)).toBe(1990)
expect(calculatePoisonDaggerAttackRatingBonus(0)).toBe(30)
expect(calculatePoisonDaggerAttackRatingBonus(Number.NaN)).toBe(30)
})
it('evaluates duration, bitrates, and damage scaling under extreme synergies', () => {
// Duration scales with slvl: 50 + (slvl - 1) * 10
const pd1 = calculatePoisonDaggerDamage(1)
expect(pd1.durationFrames).toBe(50)
expect(pd1.durationSeconds).toBe(2.0)
expect(pd1.minRate).toBe(36)
expect(pd1.maxRate).toBe(80)
const pd20 = calculatePoisonDaggerDamage(20)
expect(pd20.durationFrames).toBe(240)
expect(pd20.durationSeconds).toBe(9.6)
expect(pd20.minRate).toBe(576)
expect(pd20.maxRate).toBe(620)
// Synergy: 20 pts Poison Explosion + 20 pts Poison Nova = 40 pts * 20% = +800% -> 9.0x multiplier
const synMax = calculatePoisonDaggerSynergyMultiplier({ poisonExplosion: 20, poisonNova: 20 })
expect(synMax).toBe(9.0)
const pd20Syn = calculatePoisonDaggerDamage(20, { poisonExplosion: 20, poisonNova: 20 })
expect(pd20Syn.durationFrames).toBe(240) // Duration does NOT scale with synergy
expect(pd20Syn.minRate).toBe(576 * 9)
expect(pd20Syn.maxRate).toBe(620 * 9)
expect(pd20Syn.minDamage).toBe(Math.floor((576 * 9 * 240) / 256)) // 4860
expect(pd20Syn.maxDamage).toBe(Math.floor((620 * 9 * 240) / 256)) // 5231
})
})
// =========================================================================
// Challenge Dimension 4: Poison Nova Fixed 50 Frames (2.0s) Duration Stress
// =========================================================================
describe('Dimension 4: Poison Nova Fixed 50 Frames (2.0s) Duration Invariant', () => {
it('strictly maintains 50 frames (2.0s) duration across all skill levels from 1 to 99', () => {
const testLevels = [1, 2, 5, 10, 15, 20, 25, 30, 50, 99]
for (const lvl of testLevels) {
const spec = calculatePoisonNovaDamage(lvl)
expect(spec.durationFrames).toBe(50)
expect(spec.durationSeconds).toBe(2.0)
}
})
it('strictly maintains 50 frames duration even when synergies are applied at maximum', () => {
const maxSynergies = { poisonDagger: 20, poisonExplosion: 20 }
const syn = calculatePoisonNovaSynergyMultiplier(maxSynergies)
expect(syn).toBe(5.0) // 1.0 + 40 * 0.10 = 5.0
const spec = calculatePoisonNovaDamage(20, maxSynergies)
expect(spec.durationFrames).toBe(50) // Fixed!
expect(spec.durationSeconds).toBe(2.0)
expect(spec.minRate).toBe(2048 * 5)
expect(spec.maxRate).toBe(2256 * 5)
expect(spec.minDamage).toBe(Math.floor((2048 * 5 * 50) / 256)) // 2000
expect(spec.maxDamage).toBe(Math.floor((2256 * 5 * 50) / 256)) // 2203
})
it('verifies spawnPoisonNovaRing generates exactly 64 bolts with 50 frames duration', () => {
const ring = spawnPoisonNovaRing(0, 0, 100)
expect(ring.length).toBe(64)
for (const bolt of ring) {
expect(bolt.missileType).toBe('poisonnova')
expect(bolt.statusEffect).toBe('poison')
expect(bolt.statusDuration).toBe(50) // 50 frames duration on each missile
}
// Check angular coverage (360 degrees, 5.625 deg per bolt)
const angles = ring.map(b => Math.atan2(b.vy / ISO_GROUND_ASPECT_RATIO, b.vx))
// Verify first bolt is along X axis (angle ≈ 0)
expect(ring[0]!.vx).toBeCloseTo(12, 1)
expect(ring[0]!.vy).toBeCloseTo(0, 1)
// Verify quarter bolt (index 16) is along +Y axis (angle ≈ PI/2)
expect(ring[16]!.vx).toBeCloseTo(0, 1)
expect(ring[16]!.vy).toBeCloseTo(12 * ISO_GROUND_ASPECT_RATIO, 1) // 6
// Verify half bolt (index 32) is along -X axis (angle ≈ PI)
expect(ring[32]!.vx).toBeCloseTo(-12, 1)
expect(ring[32]!.vy).toBeCloseTo(0, 1)
// Verify three-quarter bolt (index 48) is along -Y axis (angle ≈ -PI/2)
expect(ring[48]!.vx).toBeCloseTo(0, 1)
expect(ring[48]!.vy).toBeCloseTo(-12 * ISO_GROUND_ASPECT_RATIO, 1) // -6
})
})
// =========================================================================
// Challenge Dimension 5: Cross-Skill Synergies & Hard-Point Invariants
// =========================================================================
describe('Dimension 5: Cross-Skill Synergies & Hard-Point Isolation', () => {
it('verifies Bone Spear receives +7% synergy from Teeth, Bone Wall, Prison, Spirit', () => {
const syn = calculateBoneSpearSynergyMultiplier({
teeth: 20,
boneWall: 20,
bonePrison: 20,
boneSpirit: 20,
})
// 80 hard points * 7% = +560% -> 6.6x multiplier
expect(syn).toBe(1.0 + 80 * 0.07)
const d20 = calculateBoneSpearDamage(20, { teeth: 20, boneWall: 20, bonePrison: 20, boneSpirit: 20 })
expect(d20.min).toBe(Math.floor(192 * 6.6))
expect(d20.max).toBe(Math.floor(204 * 6.6))
})
it('verifies Bone Spirit receives +6% synergy from Teeth, Bone Wall, Spear, Prison', () => {
const syn = calculateBoneSpiritSynergyMultiplier({
teeth: 20,
boneWall: 20,
boneSpear: 20,
bonePrison: 20,
})
// 80 hard points * 6% = +480% -> 5.8x multiplier
expect(syn).toBe(1.0 + 80 * 0.06)
const d20 = calculateBoneSpiritDamage(20, { teeth: 20, boneWall: 20, boneSpear: 20, bonePrison: 20 })
expect(d20.min).toBe(Math.floor(340 * 5.8))
expect(d20.max).toBe(Math.floor(369 * 5.8))
})
it('verifies Corpse Explosion scales unscaled base HP (50% phys / 50% fire) and radius', () => {
const ce100 = calculateCorpseExplosionDamage(5000, 100) // 100% roll of 5000 HP monster
expect(ce100.phys).toBe(2500)
expect(ce100.fire).toBe(2500)
expect(ce100.minTotal).toBe(3500) // 70% of 5000
expect(ce100.maxTotal).toBe(6000) // 120% of 5000
expect(ce100.minPhys).toBe(1750)
expect(ce100.maxPhys).toBe(3000)
expect(ce100.minFire).toBe(1750)
expect(ce100.maxFire).toBe(3000)
// Radius verification
expect(calculateCorpseExplosionRadiusYards(1)).toBe(5.33)
expect(calculateCorpseExplosionRadiusYards(10)).toBe(11.33)
expect(calculateCorpseExplosionRadiusYards(20)).toBe(18.0)
})
it('verifies Bone Wall and Bone Prison fixed 24.0s duration and segment counts', () => {
const wall = calculateBoneWallStats(20)
expect(wall.durationSeconds).toBe(24.0)
expect(wall.durationFrames).toBe(600)
expect(wall.segmentCount).toBe(5)
const prison = calculateBonePrisonStats(20)
expect(prison.durationSeconds).toBe(24.0)
expect(prison.durationFrames).toBe(600)
expect(prison.segmentCount).toBe(16)
})
it('verifies Poison Explosion duration and +15% synergy scaling', () => {
const pe1 = calculatePoisonExplosionDamage(1)
expect(pe1.durationFrames).toBe(50)
expect(pe1.durationSeconds).toBe(2.0)
const pe20 = calculatePoisonExplosionDamage(20)
expect(pe20.durationFrames).toBe(240)
expect(pe20.durationSeconds).toBe(9.6)
// Synergies: +15% per point in Poison Dagger and Poison Nova
const syn = calculatePoisonExplosionSynergyMultiplier({ poisonDagger: 20, poisonNova: 20 })
expect(syn).toBe(1.0 + 40 * 0.15) // 7.0x
const pe20Syn = calculatePoisonExplosionDamage(20, { poisonDagger: 20, poisonNova: 20 })
expect(pe20Syn.minRate).toBe(1248 * 7)
expect(pe20Syn.maxRate).toBe(1504 * 7)
})
it('verifies BATCH1_SKILLS length remains strictly 38 throughout all PnB wiring', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
})
})

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