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