Compare commits
2 Commits
f424a92a1d
...
29a53233a8
| Author | SHA1 | Date |
|---|---|---|
|
|
29a53233a8 | |
|
|
e070ed404d |
|
|
@ -19,9 +19,26 @@ import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './
|
||||||
import { getBlizzardDropOffset } from './blizzard-table.ts'
|
import { getBlizzardDropOffset } from './blizzard-table.ts'
|
||||||
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
|
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
|
||||||
import { calculateMeteorResidualFireDamage } from '../skills.ts'
|
import { calculateMeteorResidualFireDamage } from '../skills.ts'
|
||||||
|
import { compute5BandScaling } from './calc-ast.ts'
|
||||||
|
|
||||||
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
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 {
|
export interface StaticFieldResult {
|
||||||
readonly damage: number
|
readonly damage: number
|
||||||
readonly newHp: number
|
readonly newHp: number
|
||||||
|
|
@ -804,8 +821,8 @@ export class MissileEngine {
|
||||||
maxRangeTicks,
|
maxRangeTicks,
|
||||||
nextHit: effectiveRec.nextHit,
|
nextHit: effectiveRec.nextHit,
|
||||||
nextDelayFrames: effectiveRec.nextDelay,
|
nextDelayFrames: effectiveRec.nextDelay,
|
||||||
canPierce: effectiveRec.pierce || (params.pierceChancePct ?? 0) > 0,
|
canPierce: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear',
|
||||||
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce ? 100 : 0),
|
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0),
|
||||||
pierceCount: 0,
|
pierceCount: 0,
|
||||||
hitTargetIds: new Set<string>(),
|
hitTargetIds: new Set<string>(),
|
||||||
dmgPacket: params.dmgPacket,
|
dmgPacket: params.dmgPacket,
|
||||||
|
|
@ -1007,6 +1024,37 @@ export class MissileEngine {
|
||||||
m.x += Math.cos(m.angleRad) * (radius * 0.25)
|
m.x += Math.cos(m.angleRad) * (radius * 0.25)
|
||||||
m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5)
|
m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5)
|
||||||
} else {
|
} 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 prevX = m.x
|
||||||
const prevY = m.y
|
const prevY = m.y
|
||||||
m.x += m.vx
|
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 =
|
const isInfinitePierce =
|
||||||
m.name === 'lightningbolt' ||
|
(m.name === 'lightningbolt' ||
|
||||||
m.name === 'lightning' ||
|
m.name === 'lightning' ||
|
||||||
(!m.record.collideKill && m.canPierce)
|
m.name === 'bonespear' ||
|
||||||
|
(!m.record.collideKill && m.canPierce)) &&
|
||||||
|
!m.name.includes('explode') &&
|
||||||
|
!m.name.endsWith('explode')
|
||||||
if (
|
if (
|
||||||
m.name === 'meteorfire' ||
|
m.name === 'meteorfire' ||
|
||||||
m.name === 'firewall' ||
|
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
|
// Ground fire and stream flames persist for their full duration and do not die on hit
|
||||||
} else if (isInfinitePierce) {
|
} else if (isInfinitePierce) {
|
||||||
m.pierceCount += 1
|
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) {
|
} else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
||||||
m.pierceCount += 1
|
m.pierceCount += 1
|
||||||
} else if (m.record.collideKill || !m.canPierce) {
|
} else if (m.record.collideKill || !m.canPierce) {
|
||||||
|
|
@ -1398,7 +1464,34 @@ export class MissileEngine {
|
||||||
dmgPacket: m.dmgPacket,
|
dmgPacket: m.dmgPacket,
|
||||||
})
|
})
|
||||||
subMissilesSpawned.push(expName)
|
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
|
m.expired = true
|
||||||
break
|
break
|
||||||
|
|
@ -1692,6 +1785,10 @@ export function spawnNovaRing(
|
||||||
skillId?: string
|
skillId?: string
|
||||||
missileType?: string
|
missileType?: string
|
||||||
fromPlayer?: boolean
|
fromPlayer?: boolean
|
||||||
|
speed?: number
|
||||||
|
ttl?: number
|
||||||
|
statusEffect?: 'chill' | 'freeze' | 'poison'
|
||||||
|
statusDuration?: number
|
||||||
}
|
}
|
||||||
): {
|
): {
|
||||||
skillId: string
|
skillId: string
|
||||||
|
|
@ -1704,11 +1801,16 @@ export function spawnNovaRing(
|
||||||
ttl: number
|
ttl: number
|
||||||
fromPlayer: boolean
|
fromPlayer: boolean
|
||||||
nextDelay: number
|
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 skillId = options?.skillId ?? '48'
|
||||||
const missileType = options?.missileType ?? 'electricnova'
|
const missileType = options?.missileType ?? 'electricnova'
|
||||||
const fromPlayer = options?.fromPlayer ?? true
|
const fromPlayer = options?.fromPlayer ?? true
|
||||||
|
const statusEffect = options?.statusEffect
|
||||||
|
const statusDuration = options?.statusDuration
|
||||||
const bolts: {
|
const bolts: {
|
||||||
skillId: string
|
skillId: string
|
||||||
missileType: string
|
missileType: string
|
||||||
|
|
@ -1720,6 +1822,8 @@ export function spawnNovaRing(
|
||||||
ttl: number
|
ttl: number
|
||||||
fromPlayer: boolean
|
fromPlayer: boolean
|
||||||
nextDelay: number
|
nextDelay: number
|
||||||
|
statusEffect?: 'chill' | 'freeze' | 'poison'
|
||||||
|
statusDuration?: number
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
for (let i = 0; i < 64; i++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
|
|
@ -1735,15 +1839,42 @@ export function spawnNovaRing(
|
||||||
vx,
|
vx,
|
||||||
vy,
|
vy,
|
||||||
damage,
|
damage,
|
||||||
ttl: 13,
|
ttl,
|
||||||
fromPlayer,
|
fromPlayer,
|
||||||
nextDelay: 4,
|
nextDelay: 4,
|
||||||
|
...(statusEffect !== undefined ? { statusEffect } : {}),
|
||||||
|
...(statusDuration !== undefined ? { statusDuration } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return bolts
|
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 {
|
export interface StaticFieldTarget {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly x: number
|
readonly x: number
|
||||||
|
|
@ -2969,3 +3100,537 @@ export function calculateEnchantAttackDamage(params: {
|
||||||
const average = Math.round(((min + max) / 2) * 10) / 10
|
const average = Math.round(((min + max) / 2) * 10) / 10
|
||||||
return { min, max, average }
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,31 @@ import {
|
||||||
calculateEnchantDuration,
|
calculateEnchantDuration,
|
||||||
calculateFireMasteryBonus,
|
calculateFireMasteryBonus,
|
||||||
calculateEnchantAttackDamage,
|
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'
|
} from './engine/missile-engine.ts'
|
||||||
export {
|
export {
|
||||||
calculateNovaDamage,
|
calculateNovaDamage,
|
||||||
|
|
@ -148,6 +173,31 @@ export {
|
||||||
calculateEnchantDuration,
|
calculateEnchantDuration,
|
||||||
calculateFireMasteryBonus,
|
calculateFireMasteryBonus,
|
||||||
calculateEnchantAttackDamage,
|
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'
|
} from './engine/missile-engine.ts'
|
||||||
import {
|
import {
|
||||||
BLIZZARD_CIRCLE_X,
|
BLIZZARD_CIRCLE_X,
|
||||||
|
|
@ -3803,6 +3853,18 @@ const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly<Record<string, string>> = Object
|
||||||
hydra: 'fire_cast_2',
|
hydra: 'fire_cast_2',
|
||||||
frozenorb: 'ice_cast_3',
|
frozenorb: 'ice_cast_3',
|
||||||
'frozen orb': '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 {
|
export function getSkillCastOverlay(skillId: number | string): string | undefined {
|
||||||
if (typeof skillId === 'number') {
|
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)
|
const num = parseInt(skillId, 10)
|
||||||
if (!Number.isNaN(num)) {
|
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 normalized = skillId.toLowerCase().trim()
|
||||||
const overlay =
|
const overlay =
|
||||||
|
|
@ -5121,6 +5191,44 @@ export function calculateSkillDamage(
|
||||||
if (skillId === 62 || skillId === '62' || skillId === 'hydra') {
|
if (skillId === 62 || skillId === '62' || skillId === 'hydra') {
|
||||||
return calculateHydraDamage(slvl, synergies as any)
|
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 numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId)
|
||||||
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
|
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||||||
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
||||||
import { npcDialog } from '../game/quests.ts'
|
import { npcDialog } from '../game/quests.ts'
|
||||||
import type { NpcDef } 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 type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||||
import {
|
import {
|
||||||
|
|
@ -866,6 +866,12 @@ const XBOW_CODES = new Set([
|
||||||
'6lx', '6mx', '6hx', '6rx',
|
'6lx', '6mx', '6hx', '6rx',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const DAGGER_CODES = new Set([
|
||||||
|
'dgr', 'dir', 'kri', 'bld',
|
||||||
|
'9dg', '9di', '9kr', '9bl',
|
||||||
|
'7dg', '7di', '7kr', '7bl',
|
||||||
|
])
|
||||||
|
|
||||||
export interface AttackWeaponInputItem {
|
export interface AttackWeaponInputItem {
|
||||||
code?: string | undefined
|
code?: string | undefined
|
||||||
type?: string | undefined
|
type?: string | undefined
|
||||||
|
|
@ -939,7 +945,15 @@ export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefi
|
||||||
let subtype: AttackWeaponSubtype | null = null
|
let subtype: AttackWeaponSubtype | null = null
|
||||||
let reach = 52
|
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'
|
subtype = 'sword'
|
||||||
reach = 52
|
reach = 52
|
||||||
} else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) {
|
} 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)) {
|
} 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'
|
subtype = 'mace'
|
||||||
reach = 52
|
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)) {
|
} else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) {
|
||||||
subtype = 'staff'
|
subtype = 'staff'
|
||||||
reach = 52
|
reach = 52
|
||||||
|
|
@ -1094,7 +1105,11 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
||||||
case 46: return 11
|
case 46: return 11
|
||||||
case 51: return 22
|
case 51: return 22
|
||||||
case 55: return 10
|
case 55: return 10
|
||||||
|
case 73: return 3
|
||||||
|
case 78: return 17
|
||||||
|
case 83: return 8
|
||||||
case 84: return 7
|
case 84: return 7
|
||||||
|
case 88: return 27
|
||||||
case 93: return 12
|
case 93: return 12
|
||||||
case 92: return 20
|
case 92: return 20
|
||||||
case 12: return 4
|
case 12: return 4
|
||||||
|
|
@ -1858,6 +1873,239 @@ export function castSkill(
|
||||||
return true
|
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
|
// Projectile Skills
|
||||||
let speed = 450
|
let speed = 450
|
||||||
let range = 450
|
let range = 450
|
||||||
|
|
@ -1970,17 +2218,32 @@ export function castSkill(
|
||||||
const missile = getMissileTxtData('teeth')
|
const missile = getMissileTxtData('teeth')
|
||||||
speed = missile.speedPxPerSec || 400
|
speed = missile.speedPxPerSec || 400
|
||||||
range = missile.distancePx || 800
|
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
|
delay = 0
|
||||||
missileType = 'teeth'
|
missileType = 'teeth'
|
||||||
multiCount = 3
|
multiCount = calculateTeethCount(slvl)
|
||||||
spreadAngle = 0.40
|
spreadAngle = Math.min(1.2, 0.25 + multiCount * 0.04)
|
||||||
|
nextDelay = 4
|
||||||
skillName = '牙'
|
skillName = '牙'
|
||||||
} else if (skillId === 84) {
|
} else if (skillId === 84) {
|
||||||
const missile = getMissileTxtData('bonespear')
|
const missile = getMissileTxtData('bonespear')
|
||||||
speed = missile.speedPxPerSec || 600
|
speed = missile.speedPxPerSec || 600
|
||||||
range = missile.distancePx || 960
|
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
|
delay = 0
|
||||||
pierce = true
|
pierce = true
|
||||||
missileType = 'bonespear'
|
missileType = 'bonespear'
|
||||||
|
|
@ -1989,7 +2252,14 @@ export function castSkill(
|
||||||
const missile = getMissileTxtData('bonespirit')
|
const missile = getMissileTxtData('bonespirit')
|
||||||
speed = missile.speedPxPerSec || 300
|
speed = missile.speedPxPerSec || 300
|
||||||
range = missile.distancePx || 1536
|
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
|
delay = 0
|
||||||
homing = true
|
homing = true
|
||||||
missileType = 'bonespirit'
|
missileType = 'bonespirit'
|
||||||
|
|
@ -1998,13 +2268,19 @@ export function castSkill(
|
||||||
const missile = getMissileTxtData('poisonnova')
|
const missile = getMissileTxtData('poisonnova')
|
||||||
speed = missile.speedPxPerSec || 300
|
speed = missile.speedPxPerSec || 300
|
||||||
range = missile.distancePx || 360
|
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
|
delay = 0
|
||||||
statusEffect = 'poison'
|
statusEffect = 'poison'
|
||||||
statusDuration = 100
|
statusDuration = pnDmg.durationFrames
|
||||||
missileType = 'poisonnova'
|
missileType = 'poisonnova'
|
||||||
isNova = true
|
isNova = true
|
||||||
multiCount = 32
|
multiCount = 64
|
||||||
|
nextDelay = 4
|
||||||
skillName = '剧毒新星'
|
skillName = '剧毒新星'
|
||||||
} else if (skillId === 48) {
|
} else if (skillId === 48) {
|
||||||
const missile = getMissileTxtData('nova')
|
const missile = getMissileTxtData('nova')
|
||||||
|
|
@ -2185,7 +2461,7 @@ export function castSkill(
|
||||||
for (let i = 0; i < multiCount; i++) {
|
for (let i = 0; i < multiCount; i++) {
|
||||||
const angle = startAngle + i * step
|
const angle = startAngle + i * step
|
||||||
const pVx = Math.cos(angle) * perTick
|
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({
|
engine.projectiles.push({
|
||||||
skillId: String(skillId),
|
skillId: String(skillId),
|
||||||
x: player.x,
|
x: player.x,
|
||||||
|
|
@ -2204,6 +2480,7 @@ export function castSkill(
|
||||||
statusEffect,
|
statusEffect,
|
||||||
statusDuration,
|
statusDuration,
|
||||||
isUndeadOnly,
|
isUndeadOnly,
|
||||||
|
nextDelay,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Reference in New Issue