feat(ama): implement Milestone M12 Amazon Bow and Crossbow skills tree (#459)

This commit is contained in:
troytt 2026-09-24 19:08:16 +00:00
parent e3f03b255e
commit 88c57f7ed6
26 changed files with 2404 additions and 23 deletions

View File

@ -323,6 +323,7 @@ export interface SUnitDmgPacket {
readonly elemMin256?: number
readonly elemMax256?: number
readonly elemDurationFrames?: number
readonly slvl?: number
readonly physToElemPct?: number // e.g. Berserk 100% magic, Magic Arrow / Fire Arrow %
readonly concentrationBonusPct?: number // active Concentration aura %ED for Blessed Hammer (50% efficiency)
readonly convictionPierce?: number
@ -460,7 +461,15 @@ export function executeSUnitDmg(
// 3. Weapon `srcdam` (128 = 100%) + Physical Damage calculation
const hasFlatPhys = (packet.flatPhysMin256 !== undefined && packet.flatPhysMin256 > 0) || (packet.flatPhysMax256 !== undefined && packet.flatPhysMax256 > 0)
const isSpellWithoutFlatPhys = attackKind === 'spell' && !hasFlatPhys
const srcDam = isSpellWithoutFlatPhys ? 0 : (packet.srcDam ?? (attackKind !== 'spell' ? 128 : 0))
const srcDam =
isSpellWithoutFlatPhys
? 0
: (packet.srcDam ??
(packet.skillId === 12 || packet.skillId === 26
? 96
: attackKind !== 'spell'
? 128
: 0))
let rawPhys256 = 0
if (!isSpellWithoutFlatPhys && (srcDam > 0 || hasFlatPhys)) {
const weapMin256 = (attacker.weaponMinPhys ?? 25) * FIXED_ONE
@ -517,7 +526,15 @@ export function executeSUnitDmg(
// 4. Physical-to-Elemental/Magic Conversion (e.g. Berserk 100% Magic, Magic Arrow, Fire Arrow, Cold Arrow)
let rawElem256 = Math.trunc(((packet.elemMin256 ?? 0) + (packet.elemMax256 ?? 0)) / 2)
const convPct = Math.max(0, Math.min(100, packet.physToElemPct ?? (packet.skillId === 152 ? 100 : 0)))
const defaultConvPct =
packet.skillId === 152
? 100
: packet.skillId === 7 || packet.skillId === 11
? 80
: packet.skillId === 6
? Math.min(100, 5 + Math.max(0, (packet.slvl ?? attacker.statList.getEffectiveSkillLevel(6) ?? 1) - 1))
: 0
const convPct = Math.max(0, Math.min(100, packet.physToElemPct ?? defaultConvPct))
if (convPct > 0 && rawPhys256 > 0) {
const converted256 = Math.trunc((rawPhys256 * convPct) / 100)
rawPhys256 -= converted256
@ -525,7 +542,14 @@ export function executeSUnitDmg(
}
// 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field / Enchant rules
let elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase()
let elemType = (
packet.elemType ??
(packet.skillId === 6 || packet.skillId === 152 || packet.skillId === 112
? 'mag'
: packet.skillId === 11 || packet.skillId === 21 || packet.skillId === 31
? 'cold'
: 'fire')
).toLowerCase()
// Added elemental damage from Paladin offensive auras (Holy Fire, Holy Freeze, Holy Shock) on physical attacks
if (attackKind !== 'spell' && packet.skillId !== 97) {

View File

@ -20,6 +20,14 @@ import { getBlizzardDropOffset } from './blizzard-table.ts'
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
import { calculateMeteorResidualFireDamage } from '../skills.ts'
import { compute5BandScaling, computeDiminishingReturns } from './calc-ast.ts'
import {
calculateMultipleShotStats,
calculateStrafeStats,
calculateIceArrowStats,
calculateFreezingArrowStats,
generateMultipleShotAngles,
isMultipleShotCenterArrow,
} from '../skills/amazon-bow.ts'
export const ISO_GROUND_ASPECT_RATIO = 0.5
@ -647,6 +655,8 @@ export interface ActiveMissile {
originX?: number | undefined
originY?: number | undefined
spiralTheta?: number | undefined
readonly volleyId?: string | undefined
readonly canTriggerProcs?: boolean | undefined
}
export interface MissileTerrainCollision {
@ -668,6 +678,7 @@ export class MissileEngine {
readonly registry: D2DataRegistry
private readonly missiles: ActiveMissile[] = []
private readonly nextDelayExpireByTarget = new Map<string, number>()
private readonly volleyHits = new Set<string>()
private uidCounter = 1
terrain?: MissileTerrainCollision | undefined
@ -704,6 +715,8 @@ export class MissileEngine {
vx?: number
vy?: number
angleRad?: number
volleyId?: string
canTriggerProcs?: boolean
}): ActiveMissile | null {
const rec =
typeof params.missileNameOrId === 'number'
@ -880,8 +893,18 @@ export class MissileEngine {
maxRangeTicks,
nextHit: effectiveRec.nextHit,
nextDelayFrames: effectiveRec.nextDelay,
canPierce: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear',
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0),
canPierce:
params.sourceSkillId === 22 ||
effectiveRec.name.toLowerCase() === 'guidedarrow' ||
effectiveRec.id === 86
? false
: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear',
pierceChancePct:
params.sourceSkillId === 22 ||
effectiveRec.name.toLowerCase() === 'guidedarrow' ||
effectiveRec.id === 86
? 0
: (params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0)),
pierceCount: 0,
hitTargetIds: new Set<string>(),
dmgPacket: params.dmgPacket,
@ -889,6 +912,8 @@ export class MissileEngine {
lastHitTargetId: params.lastHitTargetId,
expired: false,
isTurret,
...(params.volleyId !== undefined ? { volleyId: params.volleyId } : {}),
...(params.canTriggerProcs !== undefined ? { canTriggerProcs: params.canTriggerProcs } : {}),
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
...(params.fireCadenceTicks !== undefined
? { fireCadenceTicks: params.fireCadenceTicks }
@ -1093,7 +1118,10 @@ export class MissileEngine {
// 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.toLowerCase() === 'bonespirit' ||
m.name.toLowerCase() === 'guidedarrow' ||
m.sourceSkillId === 22 ||
m.record.pSrvDoFunc === 7) &&
!m.name.includes('explode')
) {
let closestTarget: { x: number; y: number } | null = null
@ -1115,9 +1143,17 @@ export class MissileEngine {
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)
let diff = targetAngle - m.angleRad
while (diff < -Math.PI) diff += Math.PI * 2
while (diff > Math.PI) diff -= Math.PI * 2
const maxTurn = 0.45
if (Math.abs(diff) <= maxTurn) {
m.angleRad = targetAngle
m.vx = Math.cos(targetAngle) * speed
m.vy = Math.sin(targetAngle) * speed * ISO_GROUND_ASPECT_RATIO
} else {
m.angleRad += Math.sign(diff) * maxTurn
}
m.vx = Math.cos(m.angleRad) * speed
m.vy = Math.sin(m.angleRad) * speed * ISO_GROUND_ASPECT_RATIO
}
}
@ -1258,6 +1294,7 @@ export class MissileEngine {
// Check collision against alive enemy targets
if (
(m.name === 'meteorfire' && m.ageTicks % 5 === 0) ||
(m.name === 'immolationfire' && m.ageTicks % 5 === 0) ||
m.name === 'firewall' ||
m.name === 'blaze' ||
m.name === 'groundfirebig' ||
@ -1317,6 +1354,7 @@ export class MissileEngine {
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
const isGroundPatch =
m.name === 'meteorfire' ||
m.name === 'immolationfire' ||
m.name === 'firewall' ||
m.name === 'blaze' ||
m.name === 'groundfirebig'
@ -1324,6 +1362,21 @@ export class MissileEngine {
? Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO)
: Math.hypot(pos.x - m.x, pos.y - m.y)
if (dist <= hitRadius) {
// Multiple Shot volley multi-hit immunity
if (m.volleyId) {
const vKey = `${m.volleyId}:${target.id}`
if (this.volleyHits.has(vKey)) {
continue
}
this.volleyHits.add(vKey)
}
// Guided Arrow strict zero-pierce enforcement
if (m.sourceSkillId === 22 || m.name.toLowerCase() === 'guidedarrow' || m.missileId === 86) {
;(m as any).canPierce = false
;(m as any).pierceChancePct = 0
}
// Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0`
if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) {
continue
@ -1401,14 +1454,119 @@ export class MissileEngine {
}
}
// `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`)
// Ice Arrow (Skill 21 / missile icearrow): freezes primary target solid
if (m.name === 'icearrow' || m.sourceSkillId === 21) {
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateIceArrowStats(m.slvl).freezeFrames
target.stateBus.applyState({
stateNameOrId: 'freeze',
sourceSkillId: 21,
slvl: m.slvl,
durationFrames: frzFrames,
})
}
}
// Freezing Arrow (Skill 31 / missile freezingarrow): freezes primary target solid
if (m.name === 'freezingarrow' || m.sourceSkillId === 31) {
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateFreezingArrowStats(m.slvl).freezeFrames
target.stateBus.applyState({
stateNameOrId: 'freeze',
sourceSkillId: 31,
slvl: m.slvl,
durationFrames: frzFrames,
})
}
}
// Sub-missiles on hit for Amazon Bow skills (explodingarrow, icearrow, immolationarrow, freezingarrow)
if (m.name === 'explodingarrow' || m.sourceSkillId === 16) {
const expName = m.record.explosionMissile || 'explodingarrowexp'
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)
}
if (m.name === 'immolationarrow' || m.sourceSkillId === 27) {
const patchName = m.record.hitSubMissile1 || 'immolationfire'
this.spawnMissile({
missileNameOrId: patchName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
maxRangeTicks: 75,
})
subMissilesSpawned.push(patchName)
}
if (m.name === 'freezingarrow' || m.sourceSkillId === 31) {
const expName = m.record.explosionMissile || 'freezingarrowexp'
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)
}
if (m.name === 'icearrow' || m.sourceSkillId === 21) {
const expName = m.record.explosionMissile || 'icearrowexplode'
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)
}
// `pSrvHitFunc = 1` or `4` or `9` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Immolation Arrow`, `Glacial Spike`)
if (
m.record.pSrvHitFunc === 1 ||
m.record.pSrvHitFunc === 4 ||
m.record.pSrvHitFunc === 9 ||
m.record.pSrvHitFunc === 13
m.record.pSrvHitFunc === 13 ||
m.sourceSkillId === 16 ||
m.sourceSkillId === 27 ||
m.sourceSkillId === 31 ||
m.name === 'explodingarrow' ||
m.name === 'immolationarrow' ||
m.name === 'freezingarrow'
) {
const splashRadius = (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) ? 64 : 85
const isBowAoE =
m.sourceSkillId === 16 ||
m.sourceSkillId === 27 ||
m.sourceSkillId === 31 ||
m.name === 'explodingarrow' ||
m.name === 'immolationarrow' ||
m.name === 'freezingarrow'
const splashRadius = isBowAoE ? 80 : (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55 ? 64 : 85)
for (const other of targets) {
if (other.id === target.id || other.statList.getHp256() <= 0) continue
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
@ -1420,6 +1578,18 @@ export class MissileEngine {
damage: splashOut.totalDamage,
immune: splashOut.immuneToElem || splashOut.immuneToPhys,
})
// Freezing Arrow splash targets also receive solid freeze
if (m.name === 'freezingarrow' || m.sourceSkillId === 31) {
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateFreezingArrowStats(m.slvl).freezeFrames
other.stateBus.applyState({
stateNameOrId: 'freeze',
sourceSkillId: 31,
slvl: m.slvl,
durationFrames: frzFrames,
})
}
}
// Glacial Spike splash targets also receive solid freeze
if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
@ -1524,6 +1694,7 @@ export class MissileEngine {
!m.name.endsWith('explode')
if (
m.name === 'meteorfire' ||
m.name === 'immolationfire' ||
m.name === 'firewall' ||
m.name === 'blaze' ||
m.name === 'groundfirebig' ||
@ -1637,6 +1808,63 @@ export class MissileEngine {
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
} else if (m.name === 'explodingarrow' || m.sourceSkillId === 16) {
const expName = m.record.explosionMissile || 'explodingarrowexp'
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 === 'icearrow' || m.sourceSkillId === 21) {
const expName = m.record.explosionMissile || 'icearrowexplode'
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 === 'immolationarrow' || m.sourceSkillId === 27) {
const patchName = m.record.hitSubMissile1 || 'immolationfire'
this.spawnMissile({
missileNameOrId: patchName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
maxRangeTicks: 75,
})
subMissilesSpawned.push(patchName)
} else if (m.name === 'freezingarrow' || m.sourceSkillId === 31) {
const expName = m.record.explosionMissile || 'freezingarrowexp'
this.spawnMissile({
missileNameOrId: expName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
}
m.expired = true
break
@ -1845,6 +2073,132 @@ export class MissileEngine {
return spawned
}
/**
* Spawns authentic 1.13c Multiple Shot volley (Skill 12):
* - Arrow count: min(24, 2 + slvl)
* - Fan arc spread (~45-50 degrees) symmetric around aim vector
* - 75% weapon damage (srcDam = 96)
* - Center 2 arrows flagged to trigger procs; outer arrows cannot
* - Shared volleyId enforces single-target hit immunity
*/
spawnMultipleShotVolley(params: {
sourceSkillId?: number
slvl: number
owner: CombatUnitContext
startX: number
startY: number
targetX: number
targetY: number
dmgPacket: SUnitDmgPacket
pierceChancePct?: number
missileName?: string
}): ActiveMissile[] {
const stats = calculateMultipleShotStats(params.slvl)
const arrowCount = stats.arrowCount
const dx = params.targetX - params.startX
const dy = (params.targetY - params.startY) / ISO_GROUND_ASPECT_RATIO
const aimAngleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
const angles = generateMultipleShotAngles(aimAngleRad, arrowCount, (50 * Math.PI) / 180)
const volleyId = `ms-volley-${String(this.uidCounter++)}`
const missiles: ActiveMissile[] = []
const missileName = params.missileName || 'arrow'
for (let i = 0; i < arrowCount; i += 1) {
const angle = angles[i]!
const canTriggerProcs = isMultipleShotCenterArrow(i, arrowCount)
const m = this.spawnMissile({
missileNameOrId: missileName,
sourceSkillId: params.sourceSkillId ?? 12,
slvl: params.slvl,
owner: params.owner,
startX: params.startX,
startY: params.startY,
targetX: params.startX + Math.cos(angle) * 200,
targetY: params.startY + Math.sin(angle) * 200 * ISO_GROUND_ASPECT_RATIO,
angleRad: angle,
dmgPacket: {
...params.dmgPacket,
srcDam: 96,
},
...(params.pierceChancePct !== undefined ? { pierceChancePct: params.pierceChancePct } : {}),
volleyId,
canTriggerProcs,
})
if (m) missiles.push(m)
}
return missiles
}
/**
* Spawns authentic 1.13c Strafe sequence (Skill 26):
* - Arrow count: min(10, 4 + floor(slvl/2))
* - Cycles target acquisition among alive nearby enemies
* - 75% weapon damage (srcDam = 96) + Enhanced Damage bonus (+5%/lvl)
* - Locks caster in stationary state for lockInFrames
*/
spawnStrafeVolley(params: {
sourceSkillId?: number
slvl: number
owner: CombatUnitContext
startX: number
startY: number
targets?: readonly CombatUnitContext[]
targetPositions?: ReadonlyMap<string, { x: number; y: number }>
targetX?: number
targetY?: number
dmgPacket: SUnitDmgPacket
pierceChancePct?: number
missileName?: string
}): ActiveMissile[] {
const stats = calculateStrafeStats(params.slvl)
const arrowCount = stats.arrowCount
const aliveTargets = params.targets ? params.targets.filter(t => t.statList.getHp256() > 0) : []
const missiles: ActiveMissile[] = []
const missileName = params.missileName || 'arrow'
// Strafe stationary lock-in state on player
params.owner.stateBus.applyState({
stateNameOrId: 'strafe_lockin',
sourceSkillId: 26,
slvl: params.slvl,
durationFrames: stats.lockInFrames,
})
for (let i = 0; i < arrowCount; i += 1) {
let tX = params.targetX ?? params.startX + 200
let tY = params.targetY ?? params.startY
if (aliveTargets.length > 0) {
const assignedTarget = aliveTargets[i % aliveTargets.length]!
const pos = params.targetPositions?.get(assignedTarget.id) ?? { x: tX, y: tY }
tX = pos.x
tY = pos.y
}
const dx = tX - params.startX
const dy = (tY - params.startY) / ISO_GROUND_ASPECT_RATIO
const angle = dx === 0 && dy === 0 ? 0 : Math.atan2(dy, dx)
const m = this.spawnMissile({
missileNameOrId: missileName,
sourceSkillId: params.sourceSkillId ?? 26,
slvl: params.slvl,
owner: params.owner,
startX: params.startX,
startY: params.startY,
targetX: tX,
targetY: tY,
angleRad: angle,
dmgPacket: {
...params.dmgPacket,
srcDam: 96,
physDamagePct: (params.dmgPacket.physDamagePct ?? 0) + stats.enhancedDamagePct,
},
...(params.pierceChancePct !== undefined ? { pierceChancePct: params.pierceChancePct } : {}),
canTriggerProcs: true,
})
if (m) missiles.push(m)
}
return missiles
}
/**
* Executes authentic 1.13c instantaneous 360° Static Field discharge with difficulty floors and LOS.
* Cuts 25% current HP with Normal (1 HP), Nightmare (33% max HP), and Hell (50% max HP) pre-check clamps.

View File

@ -285,6 +285,54 @@ export {
METEOR_FIRE_SUBTILE_OFFSETS,
type MeteorFireSubtileOffset,
} from './engine/meteor-table.ts'
import {
calculateMagicArrowStats,
calculateFireArrowStats,
calculateColdArrowStats,
calculateMultipleShotStats,
calculateExplodingArrowStats,
calculateIceArrowStats,
calculateGuidedArrowStats,
calculateStrafeStats,
calculateImmolationArrowStats,
calculateFreezingArrowStats,
generateMultipleShotAngles,
isMultipleShotCenterArrow,
type MagicArrowStats,
type FireArrowStats,
type ColdArrowStats,
type MultipleShotStats,
type ExplodingArrowStats,
type IceArrowStats,
type GuidedArrowStats,
type StrafeStats,
type ImmolationArrowStats,
type FreezingArrowStats,
} from './skills/amazon-bow.ts'
export {
calculateMagicArrowStats,
calculateFireArrowStats,
calculateColdArrowStats,
calculateMultipleShotStats,
calculateExplodingArrowStats,
calculateIceArrowStats,
calculateGuidedArrowStats,
calculateStrafeStats,
calculateImmolationArrowStats,
calculateFreezingArrowStats,
generateMultipleShotAngles,
isMultipleShotCenterArrow,
type MagicArrowStats,
type FireArrowStats,
type ColdArrowStats,
type MultipleShotStats,
type ExplodingArrowStats,
type IceArrowStats,
type GuidedArrowStats,
type StrafeStats,
type ImmolationArrowStats,
type FreezingArrowStats,
} from './skills/amazon-bow.ts'
/** One castable skill, read from a table row. */
export interface SkillDef {
@ -5549,6 +5597,36 @@ export function isBatch1Skill(skillId: number): boolean {
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
*/
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
if (skillId === 6) {
return calculateMagicArrowStats(effectiveLevel).manaCost
}
if (skillId === 7) {
return calculateFireArrowStats(effectiveLevel).manaCost
}
if (skillId === 11) {
return calculateColdArrowStats(effectiveLevel).manaCost
}
if (skillId === 12) {
return calculateMultipleShotStats(effectiveLevel).manaCost
}
if (skillId === 16) {
return calculateExplodingArrowStats(effectiveLevel).manaCost
}
if (skillId === 21) {
return calculateIceArrowStats(effectiveLevel).manaCost
}
if (skillId === 22) {
return calculateGuidedArrowStats(effectiveLevel).manaCost
}
if (skillId === 26) {
return calculateStrafeStats(effectiveLevel).manaCost
}
if (skillId === 27) {
return calculateImmolationArrowStats(effectiveLevel).manaCost
}
if (skillId === 31) {
return calculateFreezingArrowStats(effectiveLevel).manaCost
}
if (skillId === 38) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 24, lvlmana = 4, manashift = 5 (factor = 0.125)
@ -5682,6 +5760,15 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
*/
export function getSkillCooldownTicks(skillId: number): number {
if (
skillId === 6 ||
skillId === 7 ||
skillId === 11 ||
skillId === 12 ||
skillId === 16 ||
skillId === 21 ||
skillId === 22 ||
skillId === 26 ||
skillId === 31 ||
skillId === 38 ||
skillId === 40 ||
skillId === 41 ||
@ -5705,6 +5792,9 @@ export function getSkillCooldownTicks(skillId: number): number {
) {
return 0
}
if (skillId === 27) {
return 25 // Immolation Arrow: 25 frames (1.0s)
}
if (skillId === 51) {
return 35
}
@ -5753,6 +5843,68 @@ export function calculateSkillDamage(
},
weaponDamage?: { min: number; max: number }
): { min: number; max: number } {
if (skillId === 6 || skillId === '6' || skillId === 'magicarrow' || skillId === 'magic arrow') {
const stats = calculateMagicArrowStats(slvl)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.flatMagicDamage, max: baseMax + stats.flatMagicDamage }
}
if (skillId === 7 || skillId === '7' || skillId === 'firearrow' || skillId === 'fire arrow') {
const stats = calculateFireArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 11 || skillId === '11' || skillId === 'coldarrow' || skillId === 'cold arrow') {
const stats = calculateColdArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 12 || skillId === '12' || skillId === 'multipleshot' || skillId === 'multiple shot') {
const stats = calculateMultipleShotStats(slvl)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: Math.floor(baseMin * stats.damageMultiplier), max: Math.floor(baseMax * stats.damageMultiplier) }
}
if (skillId === 16 || skillId === '16' || skillId === 'explodingarrow' || skillId === 'exploding arrow') {
const stats = calculateExplodingArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minBlastDamage, max: baseMax + stats.maxBlastDamage }
}
if (skillId === 21 || skillId === '21' || skillId === 'icearrow' || skillId === 'ice arrow') {
const stats = calculateIceArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
}
if (skillId === 22 || skillId === '22' || skillId === 'guidedarrow' || skillId === 'guided arrow') {
const stats = calculateGuidedArrowStats(slvl)
const ed = 1 + stats.enhancedDamagePct / 100
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
}
if (skillId === 26 || skillId === '26' || skillId === 'strafe') {
const stats = calculateStrafeStats(slvl)
const mult = stats.damageMultiplier * (1 + stats.enhancedDamagePct / 100)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: Math.floor(baseMin * mult), max: Math.floor(baseMax * mult) }
}
if (skillId === 27 || skillId === '27' || skillId === 'immolationarrow' || skillId === 'immolation arrow') {
const stats = calculateImmolationArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minExplosionDamage, max: baseMax + stats.maxExplosionDamage }
}
if (skillId === 31 || skillId === '31' || skillId === 'freezingarrow' || skillId === 'freezing arrow') {
const stats = calculateFreezingArrowStats(slvl, synergies as any)
const baseMin = weaponDamage?.min ?? 0
const baseMax = weaponDamage?.max ?? 0
return { min: baseMin + stats.minColdDamage, max: baseMax + stats.maxColdDamage }
}
if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') {
return calculateChargedBoltDamage(slvl, synergies as any)
}

View File

@ -0,0 +1,648 @@
/**
* Diablo II: Lord of Destruction v1.13c — Amazon Bow & Crossbow Skills Tree Module
*
* Dedicated pure calculation and kinematics formulas for the 10 bow/crossbow skills:
* - Skill 006: Magic Arrow
* - Skill 007: Fire Arrow
* - Skill 011: Cold Arrow
* - Skill 012: Multiple Shot
* - Skill 016: Exploding Arrow
* - Skill 021: Ice Arrow
* - Skill 022: Guided Arrow
* - Skill 026: Strafe
* - Skill 027: Immolation Arrow
* - Skill 031: Freezing Arrow
*
* 1.13c Ground Truth:
* - D2Common.dll / D2Game.dll / Patch_D2.mpq: Skills.txt & Missiles.txt
* - Fixed-point 24.8 mana calculations
* - 5-band scaling across levels 1, 2..8, 9..16, 17..22, 23..28, 29+
* - Guided Arrow strict zero-pierce invariant (`canPierce = false`, `pierceChancePct = 0`)
* - Multiple Shot fan distribution, center-2 proc limitation, and single-target hit immunity
* - Immolation Arrow 25-frame casting cooldown and 75-frame ground fire patch
* - Freezing Arrow 80 sub-pixel cold splash and 50-frame freeze duration
*/
import { compute5BandScaling } from '../engine/calc-ast.ts'
// --- Interfaces for Skill Calculations ---
export interface MagicArrowStats {
readonly conversionPct: number
readonly flatMagicDamage: number
readonly minDamage: number
readonly maxDamage: number
readonly toHitBonusPct: number
readonly manaCost: number
readonly manaCost256: number
readonly noAmmo: boolean
readonly requiresAmmo: boolean
}
export interface FireArrowStats {
readonly minDamage: number
readonly maxDamage: number
readonly baseMinDamage: number
readonly baseMaxDamage: number
readonly physToElemPct: number
readonly srcDam: number
readonly toHitBonusPct: number
readonly synergyMultiplier: number
readonly synergyBonusPct: number
readonly manaCost: number
readonly manaCost256: number
}
export interface ColdArrowStats {
readonly minDamage: number
readonly maxDamage: number
readonly baseMinDamage: number
readonly baseMaxDamage: number
readonly durationFrames: number
readonly physToElemPct: number
readonly srcDam: number
readonly toHitBonusPct: number
readonly synergyMultiplier: number
readonly synergyBonusPct: number
readonly manaCost: number
readonly manaCost256: number
}
export interface MultipleShotStats {
readonly arrowCount: number
readonly damageMultiplier: number
readonly srcDam: number
readonly centerProcArrows: number
readonly onlyCenterArrowsProc: boolean
readonly singleTargetImmunity: boolean
readonly toHitBonusPct: number
readonly manaCost: number
readonly manaCost256: number
}
export interface ExplodingArrowStats {
readonly minBlastDamage: number
readonly maxBlastDamage: number
readonly baseMinBlastDamage: number
readonly baseMaxBlastDamage: number
readonly minDamage: number
readonly maxDamage: number
readonly radiusPx: number
readonly radiusSubtiles: number
readonly toHitBonusPct: number
readonly synergyMultiplier: number
readonly synergyBonusPct: number
readonly manaCost: number
readonly manaCost256: number
}
export interface IceArrowStats {
readonly minDamage: number
readonly maxDamage: number
readonly baseMinDamage: number
readonly baseMaxDamage: number
readonly freezeFrames: number
readonly baseFreezeFrames: number
readonly toHitBonusPct: number
readonly dmgSynergyMultiplier: number
readonly lenSynergyMultiplier: number
readonly manaCost: number
readonly manaCost256: number
}
export interface GuidedArrowStats {
readonly enhancedDamagePct: number
readonly alwaysHits: boolean
readonly autoHit: boolean
readonly canPierce: boolean
readonly pierceChancePct: number
readonly toHitBonusPct: number
readonly manaCost: number
readonly manaCost256: number
}
export interface StrafeStats {
readonly arrowCount: number
readonly damageMultiplier: number
readonly srcDam: number
readonly enhancedDamagePct: number
readonly stationaryLockIn: boolean
readonly lockInFrames: number
readonly toHitBonusPct: number
readonly manaCost: number
readonly manaCost256: number
}
export interface ImmolationArrowStats {
readonly minExplosionDamage: number
readonly maxExplosionDamage: number
readonly baseMinExplosionDamage: number
readonly baseMaxExplosionDamage: number
readonly minDamage: number
readonly maxDamage: number
readonly firePatchDurationFrames: number
readonly firePatchMinDamagePerSec: number
readonly firePatchMaxDamagePerSec: number
readonly cooldownFrames: number
readonly toHitBonusPct: number
readonly explosionSynergyMultiplier: number
readonly firePatchSynergyMultiplier: number
readonly manaCost: number
readonly manaCost256: number
}
export interface FreezingArrowStats {
readonly minColdDamage: number
readonly maxColdDamage: number
readonly baseMinColdDamage: number
readonly baseMaxColdDamage: number
readonly minDamage: number
readonly maxDamage: number
readonly freezeFrames: number
readonly baseFreezeFrames: number
readonly radiusPx: number
readonly radiusSubtiles: number
readonly toHitBonusPct: number
readonly coldSynergyMultiplier: number
readonly lenSynergyMultiplier: number
readonly manaCost: number
readonly manaCost256: number
}
// --- Pure Calculation Functions ---
/**
* Skill 006: Magic Arrow
* Physical-to-magic conversion `min(100, 5 + (slvl-1))%`,
* flat magic damage 5-band scaling (1, 1, 2, 3, 4, 5),
* AR bonus `+10 + 9*(slvl-1)%`,
* mana `max(0, 1.5 - 0.125*(slvl-1))` in 24.8 fixed point (reaches 0 at lvl 13),
* zero ammo consumption invariant (`noammo: 1`).
*/
export function calculateMagicArrowStats(slvl: number): MagicArrowStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const conversionPct = Math.min(100, Math.max(0, 5 + (lvl - 1)))
const flatMagicDamage = compute5BandScaling(lvl, 1, 1, 2, 3, 4, 5)
const toHitBonusPct = 10 + 9 * (lvl - 1)
const manaCost = Math.max(0, 1.5 - 0.125 * (lvl - 1))
const manaCost256 = Math.max(0, 384 - 32 * (lvl - 1))
return {
conversionPct,
flatMagicDamage,
minDamage: flatMagicDamage,
maxDamage: flatMagicDamage,
toHitBonusPct,
manaCost,
manaCost256,
noAmmo: true,
requiresAmmo: false,
}
}
/**
* Skill 007: Fire Arrow
* 80% physical-to-fire conversion (`srcdam = 80`),
* 5-band fire damage:
* Min: (1, 2, 3, 6, 12, 24)
* Max: (4, 2, 3, 7, 14, 27)
* Exploding Arrow synergy (+12%/lvl),
* AR bonus `+10 + 9*(slvl-1)%`,
* mana `3.0 + 0.25*(slvl-1)`.
*/
export function calculateFireArrowStats(
slvl: number,
synergies?: number | { readonly explodingArrow?: number; readonly [key: string]: any }
): FireArrowStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const baseMin = compute5BandScaling(lvl, 1, 2, 3, 6, 12, 24)
const baseMax = compute5BandScaling(lvl, 4, 2, 3, 7, 14, 27)
let synergyMultiplier = 1.0
let synergyBonusPct = 0
if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) {
synergyMultiplier = synergies
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
} else if (synergies && typeof synergies === 'object') {
const synBlvl = Math.max(0, Math.floor(synergies?.explodingArrow ?? (synergies as any)?.['Exploding Arrow'] ?? 0))
synergyBonusPct = synBlvl * 12
synergyMultiplier = 1 + synergyBonusPct / 100
}
const minDamage = Math.floor(baseMin * synergyMultiplier)
const maxDamage = Math.floor(baseMax * synergyMultiplier)
const toHitBonusPct = 10 + 9 * (lvl - 1)
const manaCost = 3.0 + 0.25 * (lvl - 1)
const manaCost256 = Math.round(manaCost * 256)
return {
minDamage,
maxDamage,
baseMinDamage: baseMin,
baseMaxDamage: baseMax,
physToElemPct: 80,
srcDam: 80,
toHitBonusPct,
synergyMultiplier,
synergyBonusPct,
manaCost,
manaCost256,
}
}
/**
* Skill 011: Cold Arrow
* 80% physical-to-cold conversion (`srcdam = 80`),
* 5-band cold damage (Skills.txt: eMin=6, eMax=8 with hitShift=7 factor 0.5):
* Min: (6, 4, 5, 8, 16, 42) * 0.5
* Max: (8, 4, 5, 9, 17, 44) * 0.5
* Chill duration `50 + 10*(slvl-1)` frames,
* Ice Arrow synergy (+12%/lvl),
* AR bonus `+10 + 9*(slvl-1)%`,
* mana `3.5 + 0.25*(slvl-1)`.
*/
export function calculateColdArrowStats(
slvl: number,
synergies?: number | { readonly iceArrow?: number; readonly [key: string]: any }
): ColdArrowStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const baseMin = compute5BandScaling(lvl, 6, 4, 5, 8, 16, 42) * 0.5
const baseMax = compute5BandScaling(lvl, 8, 4, 5, 9, 17, 44) * 0.5
let synergyMultiplier = 1.0
let synergyBonusPct = 0
if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) {
synergyMultiplier = synergies
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
} else if (synergies && typeof synergies === 'object') {
const synBlvl = Math.max(0, Math.floor(synergies?.iceArrow ?? (synergies as any)?.['Ice Arrow'] ?? 0))
synergyBonusPct = synBlvl * 12
synergyMultiplier = 1 + synergyBonusPct / 100
}
const minDamage = Math.floor(baseMin * synergyMultiplier)
const maxDamage = Math.floor(baseMax * synergyMultiplier)
const durationFrames = 50 + 10 * (lvl - 1)
const toHitBonusPct = 10 + 9 * (lvl - 1)
const manaCost = 3.5 + 0.25 * (lvl - 1)
const manaCost256 = Math.round(manaCost * 256)
return {
minDamage,
maxDamage,
baseMinDamage: baseMin,
baseMaxDamage: baseMax,
durationFrames,
physToElemPct: 80,
srcDam: 80,
toHitBonusPct,
synergyMultiplier,
synergyBonusPct,
manaCost,
manaCost256,
}
}
/**
* Skill 012: Multiple Shot
* Arrow count `min(24, 2 + slvl)`,
* 75% weapon damage (`srcdam = 96`),
* center 2 arrows proc flag,
* single-target multi-hit immunity per volley,
* mana `4 + 1*(slvl-1)`.
*/
export function calculateMultipleShotStats(slvl: number): MultipleShotStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const arrowCount = Math.min(24, Math.max(2, 2 + lvl))
const manaCost = 4 + 1 * (lvl - 1)
const manaCost256 = manaCost * 256
return {
arrowCount,
damageMultiplier: 0.75,
srcDam: 96,
centerProcArrows: 2,
onlyCenterArrowsProc: true,
singleTargetImmunity: true,
toHitBonusPct: 0,
manaCost,
manaCost256,
}
}
/**
* Skill 016: Exploding Arrow
* 5-band blast fire damage:
* Min: (2, 5, 7, 9, 12, 20)
* Max: (6, 5, 8, 11, 14, 23)
* AoE 80 sub-pixels (5.33 yards / 2.66 tiles),
* Fire Arrow synergy (+12%/lvl),
* AR bonus `+20 + 9*(slvl-1)%`,
* mana `5 + 0.5*(slvl-1)`.
*/
export function calculateExplodingArrowStats(
slvl: number,
synergies?: number | { readonly fireArrow?: number; readonly [key: string]: any }
): ExplodingArrowStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const baseMin = compute5BandScaling(lvl, 2, 5, 7, 9, 12, 20)
const baseMax = compute5BandScaling(lvl, 6, 5, 8, 11, 14, 23)
let synergyMultiplier = 1.0
let synergyBonusPct = 0
if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) {
synergyMultiplier = synergies
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
} else if (synergies && typeof synergies === 'object') {
const synBlvl = Math.max(0, Math.floor(synergies?.fireArrow ?? (synergies as any)?.['Fire Arrow'] ?? 0))
synergyBonusPct = synBlvl * 12
synergyMultiplier = 1 + synergyBonusPct / 100
}
const minBlastDamage = Math.floor(baseMin * synergyMultiplier)
const maxBlastDamage = Math.floor(baseMax * synergyMultiplier)
const toHitBonusPct = 20 + 9 * (lvl - 1)
const manaCost = 5 + 0.5 * (lvl - 1)
const manaCost256 = Math.round(manaCost * 256)
return {
minBlastDamage,
maxBlastDamage,
baseMinBlastDamage: baseMin,
baseMaxBlastDamage: baseMax,
minDamage: minBlastDamage,
maxDamage: maxBlastDamage,
radiusPx: 80,
radiusSubtiles: 80,
toHitBonusPct,
synergyMultiplier,
synergyBonusPct,
manaCost,
manaCost256,
}
}
/**
* Skill 021: Ice Arrow
* 5-band cold damage:
* Min: (6, 6, 12, 18, 26, 36)
* Max: (10, 6, 13, 19, 27, 38)
* Freeze duration `50 + 5*(slvl-1)` frames,
* AR bonus `+20 + 9*(slvl-1)%`,
* Cold Arrow synergy (+8% damage/lvl),
* Freezing Arrow synergy (+5% length/lvl),
* mana `4 + 0.25*(slvl-1)`.
*/
export function calculateIceArrowStats(
slvl: number,
synergies?: number | {
readonly coldArrow?: number
readonly freezingArrow?: number
readonly [key: string]: any
}
): IceArrowStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const baseMin = compute5BandScaling(lvl, 6, 6, 12, 18, 26, 36)
const baseMax = compute5BandScaling(lvl, 10, 6, 13, 19, 27, 38)
let dmgSynergyMultiplier = 1.0
let lenSynergyMultiplier = 1.0
if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) {
dmgSynergyMultiplier = synergies
} else if (synergies && typeof synergies === 'object') {
const caBlvl = Math.max(0, Math.floor(synergies?.coldArrow ?? (synergies as any)?.['Cold Arrow'] ?? 0))
const dmgSynPct = caBlvl * 8
dmgSynergyMultiplier = 1 + dmgSynPct / 100
const faBlvl = Math.max(0, Math.floor(synergies?.freezingArrow ?? (synergies as any)?.['Freezing Arrow'] ?? 0))
const lenSynPct = faBlvl * 5
lenSynergyMultiplier = 1 + lenSynPct / 100
}
const minDamage = Math.floor(baseMin * dmgSynergyMultiplier)
const maxDamage = Math.floor(baseMax * dmgSynergyMultiplier)
const baseFreezeFrames = 50 + 5 * (lvl - 1)
const freezeFrames = Math.floor(baseFreezeFrames * lenSynergyMultiplier)
const toHitBonusPct = 20 + 9 * (lvl - 1)
const manaCost = 4 + 0.25 * (lvl - 1)
const manaCost256 = Math.round(manaCost * 256)
return {
minDamage,
maxDamage,
baseMinDamage: baseMin,
baseMaxDamage: baseMax,
freezeFrames,
baseFreezeFrames,
toHitBonusPct,
dmgSynergyMultiplier,
lenSynergyMultiplier,
manaCost,
manaCost256,
}
}
/**
* Skill 022: Guided Arrow
* Enhanced damage `+5%*(slvl-1)`,
* auto-hit invariant (`alwaysHits = true`),
* strict `canPierce = false` and `pierceChancePct = 0` (1.13c ground truth),
* mana `max(1.75, 8.0 - 0.25*(slvl-1))`.
*/
export function calculateGuidedArrowStats(slvl: number): GuidedArrowStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const enhancedDamagePct = 5 * (lvl - 1)
const manaCost = Math.max(1.75, 8.0 - 0.25 * (lvl - 1))
const manaCost256 = Math.round(manaCost * 256)
return {
enhancedDamagePct,
alwaysHits: true,
autoHit: true,
canPierce: false,
pierceChancePct: 0,
toHitBonusPct: 0,
manaCost,
manaCost256,
}
}
/**
* Skill 026: Strafe
* Arrow count `min(10, 4 + floor(slvl/2))`,
* 75% weapon damage (`srcdam = 96`),
* Enhanced damage `+5%*(slvl-1)`,
* stationary lock-in duration,
* mana fixed at 11.
*/
export function calculateStrafeStats(slvl: number): StrafeStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const arrowCount = Math.min(10, Math.max(4, 4 + Math.floor(lvl / 2)))
const enhancedDamagePct = 5 * (lvl - 1)
const lockInFrames = arrowCount * 2 + 4
return {
arrowCount,
damageMultiplier: 0.75,
srcDam: 96,
enhancedDamagePct,
stationaryLockIn: true,
lockInFrames,
toHitBonusPct: 0,
manaCost: 11,
manaCost256: 2816,
}
}
/**
* Skill 027: Immolation Arrow
* 5-band explosion fire damage:
* Min: (12, 12, 23, 34, 36, 38)
* Max: (23, 12, 23, 34, 36, 38)
* 75-frame ground fire patch (`immolationfire`),
* 25-frame casting cooldown (1.0s delay),
* Fire Arrow synergies (+5% explosion, +10% fire patch),
* AR bonus `+30 + 9*(slvl-1)%`,
* mana `6 + 0.5*(slvl-1)`.
*/
export function calculateImmolationArrowStats(
slvl: number,
synergies?: number | { readonly fireArrow?: number; readonly [key: string]: any }
): ImmolationArrowStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const baseMin = compute5BandScaling(lvl, 12, 12, 23, 34, 36, 38)
const baseMax = compute5BandScaling(lvl, 23, 12, 23, 34, 36, 38)
let explosionSynergyMultiplier = 1.0
let firePatchSynergyMultiplier = 1.0
if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) {
explosionSynergyMultiplier = synergies
firePatchSynergyMultiplier = synergies
} else if (synergies && typeof synergies === 'object') {
const faBlvl = Math.max(0, Math.floor(synergies?.fireArrow ?? (synergies as any)?.['Fire Arrow'] ?? 0))
const expSynPct = faBlvl * 5
explosionSynergyMultiplier = 1 + expSynPct / 100
const patchSynPct = faBlvl * 10
firePatchSynergyMultiplier = 1 + patchSynPct / 100
}
const minExplosionDamage = Math.floor(baseMin * explosionSynergyMultiplier)
const maxExplosionDamage = Math.floor(baseMax * explosionSynergyMultiplier)
// 1.13c immolationfire (missile 230) base ~7-10 damage/sec scaled by synergy
const firePatchMinDamagePerSec = Math.floor(7 * firePatchSynergyMultiplier)
const firePatchMaxDamagePerSec = Math.floor(10 * firePatchSynergyMultiplier)
const toHitBonusPct = 30 + 9 * (lvl - 1)
const manaCost = 6 + 0.5 * (lvl - 1)
const manaCost256 = Math.round(manaCost * 256)
return {
minExplosionDamage,
maxExplosionDamage,
baseMinExplosionDamage: baseMin,
baseMaxExplosionDamage: baseMax,
minDamage: minExplosionDamage,
maxDamage: maxExplosionDamage,
firePatchDurationFrames: 75,
firePatchMinDamagePerSec,
firePatchMaxDamagePerSec,
cooldownFrames: 25,
toHitBonusPct,
explosionSynergyMultiplier,
firePatchSynergyMultiplier,
manaCost,
manaCost256,
}
}
/**
* Skill 031: Freezing Arrow
* 5-band frost blast cold damage:
* Min: (40, 10, 15, 20, 22, 24)
* Max: (50, 10, 15, 20, 22, 24)
* AoE 80 sub-pixels (5.33 yards / 2.66 tiles),
* 50-frame freeze duration,
* Cold Arrow synergy (+12% damage/lvl),
* Ice Arrow synergy (+5% length/lvl),
* AR bonus `+40 + 9*(slvl-1)%`,
* mana `9 + 0.5*(slvl-1)`.
*/
export function calculateFreezingArrowStats(
slvl: number,
synergies?: number | {
readonly coldArrow?: number
readonly iceArrow?: number
readonly [key: string]: any
}
): FreezingArrowStats {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const baseMin = compute5BandScaling(lvl, 40, 10, 15, 20, 22, 24)
const baseMax = compute5BandScaling(lvl, 50, 10, 15, 20, 22, 24)
let coldSynergyMultiplier = 1.0
let lenSynergyMultiplier = 1.0
if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) {
coldSynergyMultiplier = synergies
} else if (synergies && typeof synergies === 'object') {
const caBlvl = Math.max(0, Math.floor(synergies?.coldArrow ?? (synergies as any)?.['Cold Arrow'] ?? 0))
const coldSynPct = caBlvl * 12
coldSynergyMultiplier = 1 + coldSynPct / 100
const iaBlvl = Math.max(0, Math.floor(synergies?.iceArrow ?? (synergies as any)?.['Ice Arrow'] ?? 0))
const lenSynPct = iaBlvl * 5
lenSynergyMultiplier = 1 + lenSynPct / 100
}
const minColdDamage = Math.floor(baseMin * coldSynergyMultiplier)
const maxColdDamage = Math.floor(baseMax * coldSynergyMultiplier)
const freezeFrames = Math.floor(50 * lenSynergyMultiplier)
const toHitBonusPct = 40 + 9 * (lvl - 1)
const manaCost = 9 + 0.5 * (lvl - 1)
const manaCost256 = Math.round(manaCost * 256)
return {
minColdDamage,
maxColdDamage,
baseMinColdDamage: baseMin,
baseMaxColdDamage: baseMax,
minDamage: minColdDamage,
maxDamage: maxColdDamage,
freezeFrames,
baseFreezeFrames: 50,
radiusPx: 80,
radiusSubtiles: 80,
toHitBonusPct,
coldSynergyMultiplier,
lenSynergyMultiplier,
manaCost,
manaCost256,
}
}
// --- Kinematics Helpers ---
/**
* Computes symmetrical fan angles for Multiple Shot.
* Spreads `arrowCount` arrows across an arc centered at `aimAngleRad`.
*/
export function generateMultipleShotAngles(
aimAngleRad: number,
arrowCount: number,
arcRad: number = (45 * Math.PI) / 180
): number[] {
if (arrowCount <= 1) return [aimAngleRad]
const angles: number[] = []
const halfArc = arcRad / 2
const step = arcRad / (arrowCount - 1)
for (let i = 0; i < arrowCount; i += 1) {
angles.push(aimAngleRad - halfArc + i * step)
}
return angles
}
/**
* Checks if an arrow index in a Multiple Shot volley is one of the two center arrows.
* Only center arrows can trigger "chance to cast on striking" procs and knockback in 1.13c.
*/
export function isMultipleShotCenterArrow(index: number, arrowCount: number): boolean {
if (arrowCount <= 2) return true
const mid1 = Math.floor((arrowCount - 1) / 2)
const mid2 = Math.ceil((arrowCount - 1) / 2)
return index === mid1 || index === mid2
}

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateMagicArrowStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateFireArrowStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateColdArrowStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,9 @@ export const skillModule: SkillModule = {
},
}
export {
calculateMultipleShotStats,
generateMultipleShotAngles,
isMultipleShotCenterArrow,
} from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateExplodingArrowStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateIceArrowStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateGuidedArrowStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateStrafeStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateImmolationArrowStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateFreezingArrowStats } from '../../amazon-bow.ts'
export default skillModule

View File

@ -154,7 +154,16 @@ export function evaluateSkillCore113c(ctx: SkillEvalContext): SkillEvalResult {
let elemHitShift = skill.hitShift
let elemType = skill.eType
const primaryMissileName = skill.srvMissile || skill.srvMissileA || skill.cltMissile || skill.cltMissileA
const primaryMissileName =
skill.srvMissile ||
skill.srvMissileA ||
skill.cltMissile ||
skill.cltMissileA ||
(skill.id === 22
? 'guidedarrow'
: skill.id === 12 || skill.id === 26
? 'arrow'
: '')
if (baseEMin === 0 && baseEMax === 0 && primaryMissileName) {
const msl = registry.getMissileByName(primaryMissileName)
if (msl && (msl.eMin > 0 || msl.eMax > 0)) {
@ -980,10 +989,43 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
}
const piercePct =
caster.statList.getEffectiveSkillLevel(33) > 0
skill.id === 22
? 0
: caster.statList.getEffectiveSkillLevel(33) > 0
? Math.min(95, 25 + caster.statList.getEffectiveSkillLevel(33) * 3)
: 0
if (skill.id === 12) {
const vol = missileEngine.spawnMultipleShotVolley({
sourceSkillId: 12,
slvl: evalResult.slvl,
owner: caster,
startX: 0,
startY: 0,
targetX,
targetY,
dmgPacket,
pierceChancePct: piercePct,
missileName: evalResult.missileA || 'arrow',
})
missilesSpawned.push(...vol.map(m => m.name))
} else if (skill.id === 26) {
const st = missileEngine.spawnStrafeVolley({
sourceSkillId: 26,
slvl: evalResult.slvl,
owner: caster,
startX: 0,
startY: 0,
targets,
targetPositions: targetPositions ?? new Map(),
targetX,
targetY,
dmgPacket,
pierceChancePct: piercePct,
missileName: evalResult.missileA || 'arrow',
})
missilesSpawned.push(...st.map(m => m.name))
} else {
for (let i = 0; i < projectileCount; i += 1) {
const spreadOffset = (i - (projectileCount - 1) / 2) * 14
const m = missileEngine.spawnMissile({
@ -1000,6 +1042,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
})
if (m) missilesSpawned.push(m.name)
}
}
// Advance missile engine so immediate or close-range missiles land and record damage
const stepRes = missileEngine.tick(currentTick, targets, targetPositions)

View File

@ -0,0 +1,566 @@
/**
* Diablo II: Lord of Destruction v1.13c — Amazon Bow & Crossbow Kinematics Stress Suite
*
* Comprehensive adversarial and E2E kinematics integration verification:
* 1. Guided Arrow homing continuous angle steering and strict zero-pierce invariant under 100% Pierce skill.
* 2. Multiple Shot 2..24 arrow scaling, symmetrical fan arc, center 2 proc flag, and single-target hit immunity.
* 3. Strafe 4..10 arrow scaling, multi-target cycling, and stationary lock-in state.
* 4. Immolation Arrow 25-frame casting cooldown and 75-frame ground fire patch ticking.
* 5. Freezing Arrow 80 sub-pixel AoE cold splash and solid freeze application.
* 6. Magic Arrow physical-to-magic conversion and zero-ammo requirement invariant.
* 7. System invariant: BATCH1_SKILLS length strictly maintained at 38; zero mocks.
*
* Ground Truth:
* - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
getSkillManaCost,
getSkillCooldownTicks,
calculateSkillDamage,
} from '../../../src/game/skills.ts'
import {
calculateMagicArrowStats,
calculateFireArrowStats,
calculateColdArrowStats,
calculateMultipleShotStats,
calculateExplodingArrowStats,
calculateIceArrowStats,
calculateGuidedArrowStats,
calculateStrafeStats,
calculateImmolationArrowStats,
calculateFreezingArrowStats,
generateMultipleShotAngles,
isMultipleShotCenterArrow,
} from '../../../src/game/skills/amazon-bow.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/game/engine/missile-engine.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import type { CombatUnitContext, SUnitDmgPacket } from '../../../src/game/engine/combat-pipeline.ts'
function createTestCombatUnit(
id: string,
pos: { x: number; y: number },
registry: any,
opts?: { hp?: number; cannotBeFrozen?: boolean; isBoss?: boolean }
): CombatUnitContext {
const statList = new UnitStatList(registry, {
level: 80,
hitpoints: (opts?.hp ?? 10000) * FIXED_ONE,
maxhp: (opts?.hp ?? 10000) * FIXED_ONE,
armorclass: 400,
fireresist: 0,
coldresist: 0,
lightresist: 0,
poisonresist: 0,
damageresist: 0,
magicresist: 0,
x: pos.x,
y: pos.y,
...(opts?.cannotBeFrozen ? { cannot_be_frozen: 1 } : {}),
})
const stateBus = new StateBus(statList, registry)
return {
id,
name: id,
statList,
stateBus,
isBoss: opts?.isBoss ?? false,
}
}
describe('Amazon Bow & Crossbow Kinematics Adversarial Stress Suite (1.13c Ground Truth)', () => {
it('enforces System Invariants: BATCH1_SKILLS length strictly equals 38 and zero mocks utilized', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
expect(BATCH1_SKILLS[6]).toBeDefined() // Magic Arrow
expect(BATCH1_SKILLS[7]).toBeDefined() // Fire Arrow
expect(BATCH1_SKILLS[11]).toBeDefined() // Cold Arrow
})
describe('Guided Arrow: Homing Kinematics, Continuous Steering & Strict Zero-Pierce', () => {
it('strictly forces canPierce = false and pierceChancePct = 0 even with 100% Pierce skill', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
// Simulate 100% Pierce skill from Skill 33 or Razortail
owner.statList.setBaseSkillLevel(33, 20)
const dmgPacket: SUnitDmgPacket = {
skillId: 22,
attackKind: 'missile',
srcDam: 128,
physDamagePct: 95, // Level 20 GA: +95% ED
autoHit: true,
}
// Spawn Guided Arrow with pierceChancePct: 100 explicitly requested
const missile = missileEngine.spawnMissile({
missileNameOrId: 'guidedarrow',
sourceSkillId: 22,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket,
pierceChancePct: 100, // Adversarial input: 100% pierce
})
expect(missile).not.toBeNull()
// In 1.13c, Guided Arrow NEVER pierces under any circumstances
expect(missile!.canPierce).toBe(false)
expect(missile!.pierceChancePct).toBe(0)
})
it('continuously steers towards moving targets in 2:1 isometric space and auto-hits target defense', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
const targetPos = { x: 100, y: 60 }
const target = createTestCombatUnit('moving-target', targetPos, registry)
const targetPositions = new Map<string, { x: number; y: number }>()
targetPositions.set(target.id, targetPos)
const dmgPacket: SUnitDmgPacket = {
skillId: 22,
attackKind: 'missile',
srcDam: 128,
physDamagePct: 95,
autoHit: true,
}
// Fire in opposite direction (x: -100, y: 0)
const missile = missileEngine.spawnMissile({
missileNameOrId: 'guidedarrow',
sourceSkillId: 22,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: -100,
targetY: 0,
dmgPacket,
})!
const initialAngle = missile.angleRad
expect(initialAngle).toBeCloseTo(Math.PI)
// Step missile engine ticks and verify steering redirects towards target
for (let tick = 1; tick <= 5; tick++) {
missileEngine.tick(tick, [target], targetPositions)
}
// Missile angle should have turned away from Math.PI towards positive angle
expect(missile.angleRad).not.toBe(initialAngle)
expect(missile.expired).toBe(false)
// Now move target directly in front of current missile position and step until hit
targetPositions.set(target.id, { x: missile.x + 20, y: missile.y })
const stepRes = missileEngine.tick(6, [target], targetPositions)
expect(stepRes.hits.length).toBeGreaterThanOrEqual(1)
expect(stepRes.hits[0]!.targetId).toBe(target.id)
expect(stepRes.hits[0]!.damage).toBeGreaterThan(0)
// Missile must expire immediately on hit (zero pierce)
expect(missile.expired).toBe(true)
})
})
describe('Multiple Shot: 2..24 Scaling, Fan Distribution, Center Procs & Hit Immunity', () => {
it('scales arrow count from 3 at level 1 to hard engine cap of 24 at level 22+', () => {
expect(calculateMultipleShotStats(1).arrowCount).toBe(3)
expect(calculateMultipleShotStats(5).arrowCount).toBe(7)
expect(calculateMultipleShotStats(10).arrowCount).toBe(12)
expect(calculateMultipleShotStats(20).arrowCount).toBe(22)
expect(calculateMultipleShotStats(22).arrowCount).toBe(24)
expect(calculateMultipleShotStats(30).arrowCount).toBe(24)
expect(calculateMultipleShotStats(50).arrowCount).toBe(24)
})
it('generates symmetric monotonic fan angles within 45-50 degree cone', () => {
const angles = generateMultipleShotAngles(0, 7, (50 * Math.PI) / 180)
expect(angles.length).toBe(7)
// Symmetrical about 0
expect(angles[3]).toBeCloseTo(0)
expect(angles[0]).toBeCloseTo(-angles[6]!)
expect(angles[1]).toBeCloseTo(-angles[5]!)
// Monotonic progression
for (let i = 0; i < angles.length - 1; i++) {
expect(angles[i + 1]!).toBeGreaterThan(angles[i]!)
}
})
it('flags strictly the center 2 arrows for on-striking procs; outer arrows cannot proc', () => {
// For a 6-arrow volley (indices 0, 1, 2, 3, 4, 5): center two are 2 and 3
expect(isMultipleShotCenterArrow(0, 6)).toBe(false)
expect(isMultipleShotCenterArrow(1, 6)).toBe(false)
expect(isMultipleShotCenterArrow(2, 6)).toBe(true)
expect(isMultipleShotCenterArrow(3, 6)).toBe(true)
expect(isMultipleShotCenterArrow(4, 6)).toBe(false)
expect(isMultipleShotCenterArrow(5, 6)).toBe(false)
// For a 24-arrow volley: center two are 11 and 12
expect(isMultipleShotCenterArrow(11, 24)).toBe(true)
expect(isMultipleShotCenterArrow(12, 24)).toBe(true)
expect(isMultipleShotCenterArrow(10, 24)).toBe(false)
expect(isMultipleShotCenterArrow(13, 24)).toBe(false)
})
it('enforces single-target hit immunity per Multiple Shot volley (no multi-hit shotgunning)', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
const target = createTestCombatUnit('boss-dummy', { x: 80, y: 0 }, registry)
const targetPositions = new Map<string, { x: number; y: number }>()
targetPositions.set(target.id, { x: 80, y: 0 })
const dmgPacket: SUnitDmgPacket = {
skillId: 12,
attackKind: 'missile',
srcDam: 96, // 75% weapon penalty
flatPhysMin256: 100 * FIXED_ONE,
flatPhysMax256: 100 * FIXED_ONE,
autoHit: true,
}
// Spawn a 24-arrow volley where multiple arrows pass through the target point
const arrows = missileEngine.spawnMultipleShotVolley({
sourceSkillId: 12,
slvl: 24,
owner,
startX: 0,
startY: 0,
targetX: 80,
targetY: 0,
dmgPacket,
})
expect(arrows.length).toBe(24)
const volleyId = arrows[0]!.volleyId
expect(volleyId).toBeDefined()
for (const a of arrows) {
expect(a.volleyId).toBe(volleyId)
}
// Step missile engine until arrows reach target at x=80
let totalHitsOnTarget = 0
for (let tick = 1; tick <= 10; tick++) {
const step = missileEngine.tick(tick, [target], targetPositions)
for (const h of step.hits) {
if (h.targetId === target.id) {
totalHitsOnTarget += 1
}
}
}
// Crucial 1.13c Invariant: Despite 24 arrows in the volley, a single monster receives damage exactly ONCE!
expect(totalHitsOnTarget).toBe(1)
})
})
describe('Strafe: Target Cycling, Rapid Burst Cadence & Stationary Lock-in', () => {
it('scales arrow count min(10, 4 + floor(slvl/2)) and applies 75% weapon damage + %ED', () => {
const s1 = calculateStrafeStats(1)
expect(s1.arrowCount).toBe(4)
expect(s1.damageMultiplier).toBe(0.75)
expect(s1.srcDam).toBe(96)
expect(s1.enhancedDamagePct).toBe(0)
expect(s1.manaCost).toBe(11)
const s2 = calculateStrafeStats(2)
expect(s2.arrowCount).toBe(5)
const s10 = calculateStrafeStats(10)
expect(s10.arrowCount).toBe(9)
expect(s10.enhancedDamagePct).toBe(45)
const s12 = calculateStrafeStats(12)
expect(s12.arrowCount).toBe(10)
const s20 = calculateStrafeStats(20)
expect(s20.arrowCount).toBe(10)
expect(s20.enhancedDamagePct).toBe(95)
})
it('distributes shots sequentially across multiple targets and applies stationary lock-in state', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
const t1 = createTestCombatUnit('enemy-1', { x: 100, y: 0 }, registry)
const t2 = createTestCombatUnit('enemy-2', { x: 0, y: 100 }, registry)
const t3 = createTestCombatUnit('enemy-3', { x: -100, y: 0 }, registry)
const targets = [t1, t2, t3]
const targetPositions = new Map<string, { x: number; y: number }>()
targetPositions.set(t1.id, { x: 100, y: 0 })
targetPositions.set(t2.id, { x: 0, y: 100 })
targetPositions.set(t3.id, { x: -100, y: 0 })
const dmgPacket: SUnitDmgPacket = {
skillId: 26,
attackKind: 'missile',
srcDam: 96,
autoHit: true,
}
const arrows = missileEngine.spawnStrafeVolley({
sourceSkillId: 26,
slvl: 20, // 10 arrows
owner,
startX: 0,
startY: 0,
targets,
targetPositions,
dmgPacket,
})
expect(arrows.length).toBe(10)
// Player is locked in stationary state
expect(owner.stateBus.hasState('strafe_lockin')).toBe(true)
const lockInState = owner.stateBus.getState('strafe_lockin')
expect(lockInState?.durationFrames).toBe(24) // 10 * 2 + 4
// Verify arrows target different positions cyclically
expect(arrows[0]!.angleRad).not.toBe(arrows[1]!.angleRad)
expect(arrows[1]!.angleRad).not.toBe(arrows[2]!.angleRad)
})
})
describe('Immolation Arrow: Casting Cooldown (25 frames) & Lingering Fire Patch (75 frames)', () => {
it('returns exact 25-frame casting cooldown and 75-frame ground fire duration', () => {
expect(getSkillCooldownTicks(27)).toBe(25) // 1.0 second delay in 1.13c
const stats = calculateImmolationArrowStats(20, { fireArrow: 20 })
expect(stats.cooldownFrames).toBe(25)
expect(stats.firePatchDurationFrames).toBe(75) // 3.0 seconds
expect(stats.explosionSynergyMultiplier).toBe(2.0)
expect(stats.firePatchSynergyMultiplier).toBe(3.0)
})
it('persists ground fire patch across 75 frames and ticks damage at 5-frame intervals', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
const target = createTestCombatUnit('standing-monster', { x: 50, y: 0 }, registry)
const targetPositions = new Map<string, { x: number; y: number }>()
targetPositions.set(target.id, { x: 50, y: 0 })
const dmgPacket: SUnitDmgPacket = {
skillId: 27,
attackKind: 'missile',
elemType: 'fire',
elemMin256: 50 * FIXED_ONE,
elemMax256: 50 * FIXED_ONE,
autoHit: true,
}
// Spawn primary immolation arrow
const mainArrow = missileEngine.spawnMissile({
missileNameOrId: 'immolationarrow',
sourceSkillId: 27,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: 50,
targetY: 0,
dmgPacket,
})!
// Step until impact
let firePatchSpawned = false
for (let tick = 1; tick <= 5; tick++) {
const step = missileEngine.tick(tick, [target], targetPositions)
if (step.subMissilesSpawned.includes('immolationfire')) {
firePatchSpawned = true
}
}
expect(firePatchSpawned).toBe(true)
// Find the spawned immolationfire patch missile
const activeMissiles = missileEngine.getActiveMissiles()
const patch = activeMissiles.find(m => m.name === 'immolationfire')
expect(patch).toBeDefined()
expect(patch!.maxRangeTicks).toBe(75)
// Step through 20 ticks and count ticking damage
let tickHits = 0
for (let tick = 6; tick <= 25; tick++) {
const step = missileEngine.tick(tick, [target], targetPositions)
for (const h of step.hits) {
if (h.missileName === 'immolationfire') {
tickHits += 1
}
}
}
// Ground patch ticks every 5 ticks -> 20 ticks / 5 = 4 ticks
expect(tickHits).toBeGreaterThanOrEqual(3)
expect(patch!.expired).toBe(false)
})
})
describe('Freezing Arrow: 80 Sub-pixel Cold Splash & AoE Solid Freeze', () => {
it('splashes cold damage and applies freeze state to all enemies within 80 sub-pixels', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
const primary = createTestCombatUnit('primary-enemy', { x: 60, y: 0 }, registry)
const close1 = createTestCombatUnit('splash-enemy-1', { x: 80, y: 20 }, registry) // dist ~28px <= 80
const close2 = createTestCombatUnit('splash-enemy-2', { x: 40, y: -20 }, registry) // dist ~28px <= 80
const farEnemy = createTestCombatUnit('far-enemy', { x: 200, y: 0 }, registry) // dist 140px > 80
const allTargets = [primary, close1, close2, farEnemy]
const targetPositions = new Map<string, { x: number; y: number }>()
targetPositions.set(primary.id, { x: 60, y: 0 })
targetPositions.set(close1.id, { x: 80, y: 20 })
targetPositions.set(close2.id, { x: 40, y: -20 })
targetPositions.set(farEnemy.id, { x: 200, y: 0 })
const dmgPacket: SUnitDmgPacket = {
skillId: 31,
attackKind: 'missile',
elemType: 'cold',
elemMin256: 300 * FIXED_ONE,
elemMax256: 300 * FIXED_ONE,
autoHit: true,
}
// Spawn Freezing Arrow towards primary
missileEngine.spawnMissile({
missileNameOrId: 'freezingarrow',
sourceSkillId: 31,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: 60,
targetY: 0,
dmgPacket,
})
// Step until impact and splash detonation
for (let tick = 1; tick <= 5; tick++) {
missileEngine.tick(tick, allTargets, targetPositions)
}
// Primary and close splash targets must receive freeze state
expect(primary.stateBus.hasState('freeze')).toBe(true)
expect(close1.stateBus.hasState('freeze')).toBe(true)
expect(close2.stateBus.hasState('freeze')).toBe(true)
// Far enemy outside 80 sub-pixels must NOT be frozen or damaged
expect(farEnemy.stateBus.hasState('freeze')).toBe(false)
expect(farEnemy.statList.getHp256()).toBe(farEnemy.statList.getMaxHp256())
})
})
describe('Integration with WorldArena & Central Skill Dispatchers', () => {
it('correctly maps calculateSkillDamage, getSkillManaCost, and getSkillCooldownTicks across all 10 bow skills', () => {
// Skill 6: Magic Arrow
expect(getSkillManaCost(6, 1)).toBe(1.5)
expect(getSkillManaCost(6, 13)).toBe(0.0)
expect(getSkillCooldownTicks(6)).toBe(0)
const dmg6 = calculateSkillDamage(6, 1, 1.0, { min: 20, max: 30 })
expect(dmg6.min).toBe(21) // 20 + 1
expect(dmg6.max).toBe(31) // 30 + 1
// Skill 7: Fire Arrow
expect(getSkillManaCost(7, 1)).toBe(3.0)
expect(getSkillCooldownTicks(7)).toBe(0)
const dmg7 = calculateSkillDamage(7, 1)
expect(dmg7.min).toBe(1)
expect(dmg7.max).toBe(4)
// Skill 11: Cold Arrow
expect(getSkillManaCost(11, 1)).toBe(3.5)
expect(getSkillCooldownTicks(11)).toBe(0)
const dmg11 = calculateSkillDamage(11, 1)
expect(dmg11.min).toBe(3)
expect(dmg11.max).toBe(4)
// Skill 12: Multiple Shot
expect(getSkillManaCost(12, 1)).toBe(4)
expect(getSkillCooldownTicks(12)).toBe(0)
const dmg12 = calculateSkillDamage(12, 1, 1.0, { min: 100, max: 200 })
expect(dmg12.min).toBe(75)
expect(dmg12.max).toBe(150)
// Skill 16: Exploding Arrow
expect(getSkillManaCost(16, 1)).toBe(5)
expect(getSkillCooldownTicks(16)).toBe(0)
const dmg16 = calculateSkillDamage(16, 1)
expect(dmg16.min).toBe(2)
expect(dmg16.max).toBe(6)
// Skill 21: Ice Arrow
expect(getSkillManaCost(21, 1)).toBe(4)
expect(getSkillCooldownTicks(21)).toBe(0)
const dmg21 = calculateSkillDamage(21, 1)
expect(dmg21.min).toBe(6)
expect(dmg21.max).toBe(10)
// Skill 22: Guided Arrow
expect(getSkillManaCost(22, 1)).toBe(8)
expect(getSkillCooldownTicks(22)).toBe(0)
const dmg22 = calculateSkillDamage(22, 10, 1.0, { min: 100, max: 200 })
expect(dmg22.min).toBe(145) // +45% ED
expect(dmg22.max).toBe(290)
// Skill 26: Strafe
expect(getSkillManaCost(26, 1)).toBe(11)
expect(getSkillCooldownTicks(26)).toBe(0)
const dmg26 = calculateSkillDamage(26, 10, 1.0, { min: 100, max: 200 })
expect(dmg26.min).toBe(Math.floor(100 * 0.75 * 1.45)) // 75% * 145% ED
expect(dmg26.max).toBe(Math.floor(200 * 0.75 * 1.45))
// Skill 27: Immolation Arrow
expect(getSkillManaCost(27, 1)).toBe(6)
expect(getSkillCooldownTicks(27)).toBe(25) // Cooldown = 25 frames
const dmg27 = calculateSkillDamage(27, 1)
expect(dmg27.min).toBe(12)
expect(dmg27.max).toBe(23)
// Skill 31: Freezing Arrow
expect(getSkillManaCost(31, 1)).toBe(9)
expect(getSkillCooldownTicks(31)).toBe(0)
const dmg31 = calculateSkillDamage(31, 1)
expect(dmg31.min).toBe(40)
expect(dmg31.max).toBe(50)
})
it('triggers and steps WorldArena cast cleanly across all 10 bow skills', async () => {
const registry = await getSharedDataRegistry()
const bowSkillIds = [6, 7, 11, 12, 16, 21, 22, 26, 27, 31]
for (const id of bowSkillIds) {
const arena = new WorldArena({
registry,
classCode: 'ama',
skillId: id,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(id)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
}
})
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-006-magic-arrow.ts'
import { calculateMagicArrowStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #006] Magic Arrow (AMA) — 1.13c Parity (Issue #160)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,60 @@ describe('[Skill #006] Magic Arrow (AMA) — 1.13c Parity (Issue #160)', () => {
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateMagicArrowStats pure calculations and boundary invariants', () => {
// Level 1: 5% magic conversion, 1 flat magic dmg, +10% AR, 1.5 mana (384 fixed-point), noAmmo: true
const s1 = calculateMagicArrowStats(1)
expect(s1.conversionPct).toBe(5)
expect(s1.flatMagicDamage).toBe(1)
expect(s1.toHitBonusPct).toBe(10)
expect(s1.manaCost).toBe(1.5)
expect(s1.manaCost256).toBe(384)
expect(s1.noAmmo).toBe(true)
expect(s1.requiresAmmo).toBe(false)
// Level 10: 14% conversion, 12 flat magic dmg, +91% AR, 0.375 mana (96 units)
const s10 = calculateMagicArrowStats(10)
expect(s10.conversionPct).toBe(14)
expect(s10.flatMagicDamage).toBe(12)
expect(s10.toHitBonusPct).toBe(91)
expect(s10.manaCost).toBe(0.375)
expect(s10.manaCost256).toBe(96)
// Level 20: 24% conversion, 36 flat magic dmg, +181% AR, 0 mana
const s20 = calculateMagicArrowStats(20)
expect(s20.conversionPct).toBe(24)
expect(s20.flatMagicDamage).toBe(36)
expect(s20.toHitBonusPct).toBe(181)
expect(s20.manaCost).toBe(0)
expect(s20.manaCost256).toBe(0)
// Level 96+: capped at 100% conversion
const s96 = calculateMagicArrowStats(96)
expect(s96.conversionPct).toBe(100)
// Monotonicity checks
for (let lvl = 1; lvl < 30; lvl++) {
const cur = calculateMagicArrowStats(lvl)
const nxt = calculateMagicArrowStats(lvl + 1)
expect(nxt.conversionPct).toBeGreaterThanOrEqual(cur.conversionPct)
expect(nxt.flatMagicDamage).toBeGreaterThanOrEqual(cur.flatMagicDamage)
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
expect(nxt.manaCost).toBeLessThanOrEqual(cur.manaCost)
}
// Boundary resilience: 0, negative, NaN, fractional
const sZero = calculateMagicArrowStats(0)
expect(sZero.conversionPct).toBe(5)
expect(sZero.manaCost).toBe(1.5)
const sNeg = calculateMagicArrowStats(-10)
expect(sNeg.conversionPct).toBe(5)
const sNan = calculateMagicArrowStats(NaN)
expect(sNan.conversionPct).toBe(5)
const sFrac = calculateMagicArrowStats(5.7)
expect(sFrac.conversionPct).toBe(9)
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-007-fire-arrow.ts'
import { calculateFireArrowStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #007] Fire Arrow (AMA) — 1.13c Parity (Issue #161)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,60 @@ describe('[Skill #007] Fire Arrow (AMA) — 1.13c Parity (Issue #161)', () => {
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateFireArrowStats pure calculations and boundary invariants', () => {
// Level 1: 80% conversion, 1-4 fire dmg, +10% AR, 3.0 mana
const s1 = calculateFireArrowStats(1)
expect(s1.physToElemPct).toBe(80)
expect(s1.srcDam).toBe(80)
expect(s1.minDamage).toBe(1)
expect(s1.maxDamage).toBe(4)
expect(s1.toHitBonusPct).toBe(10)
expect(s1.manaCost).toBe(3.0)
// Level 10: 21-24 fire dmg, +91% AR, 5.25 mana
const s10 = calculateFireArrowStats(10)
expect(s10.minDamage).toBe(21)
expect(s10.maxDamage).toBe(24)
expect(s10.toHitBonusPct).toBe(91)
expect(s10.manaCost).toBe(5.25)
// Level 20: 63-70 fire dmg, +181% AR, 7.75 mana
const s20 = calculateFireArrowStats(20)
expect(s20.minDamage).toBe(63)
expect(s20.maxDamage).toBe(70)
expect(s20.toHitBonusPct).toBe(181)
expect(s20.manaCost).toBe(7.75)
// Synergy test: Exploding Arrow +12%/level
const s20Syn = calculateFireArrowStats(20, { explodingArrow: 20 })
expect(s20Syn.synergyBonusPct).toBe(240)
expect(s20Syn.synergyMultiplier).toBe(3.4)
expect(s20Syn.minDamage).toBe(Math.floor(63 * 3.4))
expect(s20Syn.maxDamage).toBe(Math.floor(70 * 3.4))
// Monotonicity checks
for (let lvl = 1; lvl < 30; lvl++) {
const cur = calculateFireArrowStats(lvl)
const nxt = calculateFireArrowStats(lvl + 1)
expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage)
expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage)
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
}
// Boundary resilience
const sZero = calculateFireArrowStats(0)
expect(sZero.minDamage).toBe(1)
expect(sZero.manaCost).toBe(3.0)
const sNeg = calculateFireArrowStats(-5)
expect(sNeg.minDamage).toBe(1)
const sNan = calculateFireArrowStats(NaN)
expect(sNan.minDamage).toBe(1)
const sFrac = calculateFireArrowStats(4.8)
expect(sFrac.minDamage).toBe(calculateFireArrowStats(4).minDamage)
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-011-cold-arrow.ts'
import { calculateColdArrowStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #011] Cold Arrow (AMA) — 1.13c Parity (Issue #165)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,64 @@ describe('[Skill #011] Cold Arrow (AMA) — 1.13c Parity (Issue #165)', () => {
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateColdArrowStats pure calculations and boundary invariants', () => {
// Level 1: 80% conversion, 3-4 cold dmg, 50 frames chill duration, +10% AR, 3.5 mana
const s1 = calculateColdArrowStats(1)
expect(s1.physToElemPct).toBe(80)
expect(s1.srcDam).toBe(80)
expect(s1.minDamage).toBe(3)
expect(s1.maxDamage).toBe(4)
expect(s1.durationFrames).toBe(50)
expect(s1.toHitBonusPct).toBe(10)
expect(s1.manaCost).toBe(3.5)
// Level 10: 22-23 cold dmg, 140 frames chill duration, +91% AR, 5.75 mana
const s10 = calculateColdArrowStats(10)
expect(s10.minDamage).toBe(22)
expect(s10.maxDamage).toBe(23)
expect(s10.durationFrames).toBe(140)
expect(s10.toHitBonusPct).toBe(91)
expect(s10.manaCost).toBe(5.75)
// Level 20: 53-56 cold dmg, 240 frames chill duration, +181% AR, 8.25 mana
const s20 = calculateColdArrowStats(20)
expect(s20.minDamage).toBe(53)
expect(s20.maxDamage).toBe(56)
expect(s20.durationFrames).toBe(240)
expect(s20.toHitBonusPct).toBe(181)
expect(s20.manaCost).toBe(8.25)
// Synergy test: Ice Arrow +12%/level
const s20Syn = calculateColdArrowStats(20, { iceArrow: 20 })
expect(s20Syn.synergyBonusPct).toBe(240)
expect(s20Syn.synergyMultiplier).toBe(3.4)
expect(s20Syn.minDamage).toBe(Math.floor(53 * 3.4))
expect(s20Syn.maxDamage).toBe(Math.floor(56 * 3.4))
// Monotonicity checks
for (let lvl = 1; lvl < 30; lvl++) {
const cur = calculateColdArrowStats(lvl)
const nxt = calculateColdArrowStats(lvl + 1)
expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage)
expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage)
expect(nxt.durationFrames).toBeGreaterThan(cur.durationFrames)
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
}
// Boundary resilience
const sZero = calculateColdArrowStats(0)
expect(sZero.minDamage).toBe(3)
expect(sZero.manaCost).toBe(3.5)
const sNeg = calculateColdArrowStats(-1)
expect(sNeg.minDamage).toBe(3)
const sNan = calculateColdArrowStats(NaN)
expect(sNan.minDamage).toBe(3)
const sFrac = calculateColdArrowStats(7.2)
expect(sFrac.minDamage).toBe(calculateColdArrowStats(7).minDamage)
})
})

View File

@ -7,6 +7,11 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-012-multiple-shot.ts'
import {
calculateMultipleShotStats,
generateMultipleShotAngles,
isMultipleShotCenterArrow,
} from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #012] Multiple Shot (AMA) — 1.13c Parity (Issue #166)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +111,60 @@ describe('[Skill #012] Multiple Shot (AMA) — 1.13c Parity (Issue #166)', () =>
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateMultipleShotStats pure calculations and kinematics invariants', () => {
// Level 1: 3 arrows, 75% weapon damage (srcdam=96), 4 mana
const s1 = calculateMultipleShotStats(1)
expect(s1.arrowCount).toBe(3)
expect(s1.damageMultiplier).toBe(0.75)
expect(s1.srcDam).toBe(96)
expect(s1.centerProcArrows).toBe(2)
expect(s1.onlyCenterArrowsProc).toBe(true)
expect(s1.singleTargetImmunity).toBe(true)
expect(s1.manaCost).toBe(4)
// Level 10: 12 arrows, 13 mana
const s10 = calculateMultipleShotStats(10)
expect(s10.arrowCount).toBe(12)
expect(s10.manaCost).toBe(13)
// Level 20: 22 arrows, 23 mana
const s20 = calculateMultipleShotStats(20)
expect(s20.arrowCount).toBe(22)
expect(s20.manaCost).toBe(23)
// Level 22+: capped at 24 arrows
const s22 = calculateMultipleShotStats(22)
expect(s22.arrowCount).toBe(24)
const s30 = calculateMultipleShotStats(30)
expect(s30.arrowCount).toBe(24)
// Proc flag: only center 2 arrows proc
// For 4 arrows (0, 1, 2, 3): mid1=1, mid2=2
expect(isMultipleShotCenterArrow(0, 4)).toBe(false)
expect(isMultipleShotCenterArrow(1, 4)).toBe(true)
expect(isMultipleShotCenterArrow(2, 4)).toBe(true)
expect(isMultipleShotCenterArrow(3, 4)).toBe(false)
// Symmetrical fan angles test
const angles = generateMultipleShotAngles(0, 5, Math.PI / 4)
expect(angles.length).toBe(5)
expect(angles[0]).toBeCloseTo(-Math.PI / 8)
expect(angles[2]).toBeCloseTo(0)
expect(angles[4]).toBeCloseTo(Math.PI / 8)
// Boundary resilience
const sZero = calculateMultipleShotStats(0)
expect(sZero.arrowCount).toBe(3)
expect(sZero.manaCost).toBe(4)
const sNeg = calculateMultipleShotStats(-5)
expect(sNeg.arrowCount).toBe(3)
const sNan = calculateMultipleShotStats(NaN)
expect(sNan.arrowCount).toBe(3)
const sFrac = calculateMultipleShotStats(3.9)
expect(sFrac.arrowCount).toBe(5)
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-016-exploding-arrow.ts'
import { calculateExplodingArrowStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #016] Exploding Arrow (AMA) — 1.13c Parity (Issue #170)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,59 @@ describe('[Skill #016] Exploding Arrow (AMA) — 1.13c Parity (Issue #170)', ()
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateExplodingArrowStats pure calculations and AoE invariants', () => {
// Level 1: 2-6 blast fire dmg, 80 sub-pixel AoE, +20% AR, 5.0 mana
const s1 = calculateExplodingArrowStats(1)
expect(s1.minBlastDamage).toBe(2)
expect(s1.maxBlastDamage).toBe(6)
expect(s1.radiusPx).toBe(80)
expect(s1.toHitBonusPct).toBe(20)
expect(s1.manaCost).toBe(5.0)
// Level 10: 51-57 blast fire dmg, +101% AR, 9.5 mana
const s10 = calculateExplodingArrowStats(10)
expect(s10.minBlastDamage).toBe(51)
expect(s10.maxBlastDamage).toBe(57)
expect(s10.toHitBonusPct).toBe(101)
expect(s10.manaCost).toBe(9.5)
// Level 20: 129-149 blast fire dmg, +191% AR, 14.5 mana
const s20 = calculateExplodingArrowStats(20)
expect(s20.minBlastDamage).toBe(129)
expect(s20.maxBlastDamage).toBe(149)
expect(s20.toHitBonusPct).toBe(191)
expect(s20.manaCost).toBe(14.5)
// Synergy test: Fire Arrow +12%/level
const s20Syn = calculateExplodingArrowStats(20, { fireArrow: 20 })
expect(s20Syn.synergyBonusPct).toBe(240)
expect(s20Syn.synergyMultiplier).toBe(3.4)
expect(s20Syn.minBlastDamage).toBe(Math.floor(129 * 3.4))
expect(s20Syn.maxBlastDamage).toBe(Math.floor(149 * 3.4))
// Monotonicity checks
for (let lvl = 1; lvl < 30; lvl++) {
const cur = calculateExplodingArrowStats(lvl)
const nxt = calculateExplodingArrowStats(lvl + 1)
expect(nxt.minBlastDamage).toBeGreaterThanOrEqual(cur.minBlastDamage)
expect(nxt.maxBlastDamage).toBeGreaterThanOrEqual(cur.maxBlastDamage)
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
}
// Boundary resilience
const sZero = calculateExplodingArrowStats(0)
expect(sZero.minBlastDamage).toBe(2)
expect(sZero.manaCost).toBe(5.0)
const sNeg = calculateExplodingArrowStats(-3)
expect(sNeg.minBlastDamage).toBe(2)
const sNan = calculateExplodingArrowStats(NaN)
expect(sNan.minBlastDamage).toBe(2)
const sFrac = calculateExplodingArrowStats(8.6)
expect(sFrac.minBlastDamage).toBe(calculateExplodingArrowStats(8).minBlastDamage)
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-021-ice-arrow.ts'
import { calculateIceArrowStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #021] Ice Arrow (AMA) — 1.13c Parity (Issue #175)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,63 @@ describe('[Skill #021] Ice Arrow (AMA) — 1.13c Parity (Issue #175)', () => {
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateIceArrowStats pure calculations and freeze invariants', () => {
// Level 1: 6-10 cold dmg, 50 frames freeze duration, +20% AR, 4.0 mana
const s1 = calculateIceArrowStats(1)
expect(s1.minDamage).toBe(6)
expect(s1.maxDamage).toBe(10)
expect(s1.freezeFrames).toBe(50)
expect(s1.toHitBonusPct).toBe(20)
expect(s1.manaCost).toBe(4.0)
// Level 10: 72-78 cold dmg, 95 frames freeze duration, +101% AR, 6.25 mana
const s10 = calculateIceArrowStats(10)
expect(s10.minDamage).toBe(72)
expect(s10.maxDamage).toBe(78)
expect(s10.freezeFrames).toBe(95)
expect(s10.toHitBonusPct).toBe(101)
expect(s10.manaCost).toBe(6.25)
// Level 20: 216-232 cold dmg, 145 frames freeze duration, +191% AR, 8.75 mana
const s20 = calculateIceArrowStats(20)
expect(s20.minDamage).toBe(216)
expect(s20.maxDamage).toBe(232)
expect(s20.freezeFrames).toBe(145)
expect(s20.toHitBonusPct).toBe(191)
expect(s20.manaCost).toBe(8.75)
// Synergies test: Cold Arrow +8% damage/lvl, Freezing Arrow +5% freeze length/lvl
const s20Syn = calculateIceArrowStats(20, { coldArrow: 20, freezingArrow: 20 })
expect(s20Syn.dmgSynergyMultiplier).toBe(2.6) // 1 + 20 * 0.08
expect(s20Syn.lenSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05
expect(s20Syn.minDamage).toBe(Math.floor(216 * 2.6))
expect(s20Syn.maxDamage).toBe(Math.floor(232 * 2.6))
expect(s20Syn.freezeFrames).toBe(Math.floor(145 * 2.0))
// Monotonicity checks
for (let lvl = 1; lvl < 30; lvl++) {
const cur = calculateIceArrowStats(lvl)
const nxt = calculateIceArrowStats(lvl + 1)
expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage)
expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage)
expect(nxt.freezeFrames).toBeGreaterThan(cur.freezeFrames)
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
}
// Boundary resilience
const sZero = calculateIceArrowStats(0)
expect(sZero.minDamage).toBe(6)
expect(sZero.manaCost).toBe(4.0)
const sNeg = calculateIceArrowStats(-2)
expect(sNeg.minDamage).toBe(6)
const sNan = calculateIceArrowStats(NaN)
expect(sNan.minDamage).toBe(6)
const sFrac = calculateIceArrowStats(9.4)
expect(sFrac.minDamage).toBe(calculateIceArrowStats(9).minDamage)
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-022-guided-arrow.ts'
import { calculateGuidedArrowStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #022] Guided Arrow (AMA) — 1.13c Parity (Issue #176)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,57 @@ describe('[Skill #022] Guided Arrow (AMA) — 1.13c Parity (Issue #176)', () =>
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateGuidedArrowStats pure calculations and zero-pierce invariant', () => {
// Level 1: +0% ED, alwaysHits = true, canPierce = false, pierceChancePct = 0, 8.0 mana
const s1 = calculateGuidedArrowStats(1)
expect(s1.enhancedDamagePct).toBe(0)
expect(s1.alwaysHits).toBe(true)
expect(s1.autoHit).toBe(true)
expect(s1.canPierce).toBe(false)
expect(s1.pierceChancePct).toBe(0)
expect(s1.manaCost).toBe(8.0)
// Level 10: +45% ED, 5.75 mana
const s10 = calculateGuidedArrowStats(10)
expect(s10.enhancedDamagePct).toBe(45)
expect(s10.alwaysHits).toBe(true)
expect(s10.canPierce).toBe(false)
expect(s10.manaCost).toBe(5.75)
// Level 20: +95% ED, 3.25 mana
const s20 = calculateGuidedArrowStats(20)
expect(s20.enhancedDamagePct).toBe(95)
expect(s20.alwaysHits).toBe(true)
expect(s20.canPierce).toBe(false)
expect(s20.manaCost).toBe(3.25)
// High level clamp: mana floor is 1.75
const s30 = calculateGuidedArrowStats(30)
expect(s30.manaCost).toBe(1.75)
const s50 = calculateGuidedArrowStats(50)
expect(s50.manaCost).toBe(1.75)
// Strict zero-pierce invariant at all levels
for (let lvl = 1; lvl <= 40; lvl++) {
const s = calculateGuidedArrowStats(lvl)
expect(s.canPierce).toBe(false)
expect(s.pierceChancePct).toBe(0)
expect(s.alwaysHits).toBe(true)
}
// Boundary resilience
const sZero = calculateGuidedArrowStats(0)
expect(sZero.enhancedDamagePct).toBe(0)
expect(sZero.manaCost).toBe(8.0)
const sNeg = calculateGuidedArrowStats(-10)
expect(sNeg.enhancedDamagePct).toBe(0)
const sNan = calculateGuidedArrowStats(NaN)
expect(sNan.enhancedDamagePct).toBe(0)
const sFrac = calculateGuidedArrowStats(6.5)
expect(sFrac.enhancedDamagePct).toBe(calculateGuidedArrowStats(6).enhancedDamagePct)
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-026-strafe.ts'
import { calculateStrafeStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #026] Strafe (AMA) — 1.13c Parity (Issue #180)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,53 @@ describe('[Skill #026] Strafe (AMA) — 1.13c Parity (Issue #180)', () => {
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateStrafeStats pure calculations and burst invariants', () => {
// Level 1: 4 arrows, 75% weapon damage (srcdam=96), +0% ED, stationary lock-in, 11 mana
const s1 = calculateStrafeStats(1)
expect(s1.arrowCount).toBe(4)
expect(s1.damageMultiplier).toBe(0.75)
expect(s1.srcDam).toBe(96)
expect(s1.enhancedDamagePct).toBe(0)
expect(s1.stationaryLockIn).toBe(true)
expect(s1.lockInFrames).toBe(12) // 4 * 2 + 4
expect(s1.manaCost).toBe(11)
// Level 2: 5 arrows
const s2 = calculateStrafeStats(2)
expect(s2.arrowCount).toBe(5)
// Level 10: 9 arrows, +45% ED, 11 mana
const s10 = calculateStrafeStats(10)
expect(s10.arrowCount).toBe(9)
expect(s10.enhancedDamagePct).toBe(45)
expect(s10.manaCost).toBe(11)
// Level 12+: capped at 10 arrows
const s12 = calculateStrafeStats(12)
expect(s12.arrowCount).toBe(10)
const s20 = calculateStrafeStats(20)
expect(s20.arrowCount).toBe(10)
expect(s20.enhancedDamagePct).toBe(95)
expect(s20.manaCost).toBe(11)
// Fixed mana cost invariant
for (let lvl = 1; lvl <= 30; lvl++) {
expect(calculateStrafeStats(lvl).manaCost).toBe(11)
}
// Boundary resilience
const sZero = calculateStrafeStats(0)
expect(sZero.arrowCount).toBe(4)
expect(sZero.manaCost).toBe(11)
const sNeg = calculateStrafeStats(-5)
expect(sNeg.arrowCount).toBe(4)
const sNan = calculateStrafeStats(NaN)
expect(sNan.arrowCount).toBe(4)
const sFrac = calculateStrafeStats(7.9)
expect(sFrac.arrowCount).toBe(calculateStrafeStats(7).arrowCount)
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-027-immolation-arrow.ts'
import { calculateImmolationArrowStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #027] Immolation Arrow (AMA) — 1.13c Parity (Issue #181)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,66 @@ describe('[Skill #027] Immolation Arrow (AMA) — 1.13c Parity (Issue #181)', ()
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateImmolationArrowStats pure calculations, cooldown, and fire patch invariants', () => {
// Level 1: 12-23 explosion fire dmg, 75 frames ground fire, 25 frames delay, +30% AR, 6.0 mana
const s1 = calculateImmolationArrowStats(1)
expect(s1.minExplosionDamage).toBe(12)
expect(s1.maxExplosionDamage).toBe(23)
expect(s1.firePatchDurationFrames).toBe(75)
expect(s1.cooldownFrames).toBe(25)
expect(s1.toHitBonusPct).toBe(30)
expect(s1.manaCost).toBe(6.0)
// Level 10: 142-153 explosion fire dmg, +111% AR, 10.5 mana
const s10 = calculateImmolationArrowStats(10)
expect(s10.minExplosionDamage).toBe(142)
expect(s10.maxExplosionDamage).toBe(153)
expect(s10.firePatchDurationFrames).toBe(75)
expect(s10.cooldownFrames).toBe(25)
expect(s10.toHitBonusPct).toBe(111)
expect(s10.manaCost).toBe(10.5)
// Level 20: 416-427 explosion fire dmg, +201% AR, 15.5 mana
const s20 = calculateImmolationArrowStats(20)
expect(s20.minExplosionDamage).toBe(416)
expect(s20.maxExplosionDamage).toBe(427)
expect(s20.firePatchDurationFrames).toBe(75)
expect(s20.cooldownFrames).toBe(25)
expect(s20.toHitBonusPct).toBe(201)
expect(s20.manaCost).toBe(15.5)
// Synergies: Fire Arrow (+5% explosion dmg, +10% patch dmg per level)
const s20Syn = calculateImmolationArrowStats(20, { fireArrow: 20 })
expect(s20Syn.explosionSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05
expect(s20Syn.firePatchSynergyMultiplier).toBe(3.0) // 1 + 20 * 0.10
expect(s20Syn.minExplosionDamage).toBe(Math.floor(416 * 2.0))
expect(s20Syn.maxExplosionDamage).toBe(Math.floor(427 * 2.0))
// Monotonicity checks
for (let lvl = 1; lvl < 30; lvl++) {
const cur = calculateImmolationArrowStats(lvl)
const nxt = calculateImmolationArrowStats(lvl + 1)
expect(nxt.minExplosionDamage).toBeGreaterThanOrEqual(cur.minExplosionDamage)
expect(nxt.maxExplosionDamage).toBeGreaterThanOrEqual(cur.maxExplosionDamage)
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
expect(nxt.cooldownFrames).toBe(25)
expect(nxt.firePatchDurationFrames).toBe(75)
}
// Boundary resilience
const sZero = calculateImmolationArrowStats(0)
expect(sZero.minExplosionDamage).toBe(12)
expect(sZero.manaCost).toBe(6.0)
const sNeg = calculateImmolationArrowStats(-4)
expect(sNeg.minExplosionDamage).toBe(12)
const sNan = calculateImmolationArrowStats(NaN)
expect(sNan.minExplosionDamage).toBe(12)
const sFrac = calculateImmolationArrowStats(11.3)
expect(sFrac.minExplosionDamage).toBe(calculateImmolationArrowStats(11).minExplosionDamage)
})
})

View File

@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/ama/skill-031-freezing-arrow.ts'
import { calculateFreezingArrowStats } from '../../../src/game/skills/amazon-bow.ts'
describe('[Skill #031] Freezing Arrow (AMA) — 1.13c Parity (Issue #185)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
@ -106,4 +107,64 @@ describe('[Skill #031] Freezing Arrow (AMA) — 1.13c Parity (Issue #185)', () =
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('validates 1.13c calculateFreezingArrowStats pure calculations, splash, and freeze invariants', () => {
// Level 1: 40-50 cold dmg, 80 sub-pixel AoE, 50 frames freeze duration, +40% AR, 9.0 mana
const s1 = calculateFreezingArrowStats(1)
expect(s1.minColdDamage).toBe(40)
expect(s1.maxColdDamage).toBe(50)
expect(s1.radiusPx).toBe(80)
expect(s1.freezeFrames).toBe(50)
expect(s1.toHitBonusPct).toBe(40)
expect(s1.manaCost).toBe(9.0)
// Level 10: 140-150 cold dmg, +121% AR, 13.5 mana
const s10 = calculateFreezingArrowStats(10)
expect(s10.minColdDamage).toBe(140)
expect(s10.maxColdDamage).toBe(150)
expect(s10.freezeFrames).toBe(50)
expect(s10.toHitBonusPct).toBe(121)
expect(s10.manaCost).toBe(13.5)
// Level 20: 310-320 cold dmg, +211% AR, 18.5 mana
const s20 = calculateFreezingArrowStats(20)
expect(s20.minColdDamage).toBe(310)
expect(s20.maxColdDamage).toBe(320)
expect(s20.freezeFrames).toBe(50)
expect(s20.toHitBonusPct).toBe(211)
expect(s20.manaCost).toBe(18.5)
// Synergies: Cold Arrow (+12% cold damage/lvl), Ice Arrow (+5% freeze length/lvl)
const s20Syn = calculateFreezingArrowStats(20, { coldArrow: 20, iceArrow: 20 })
expect(s20Syn.coldSynergyMultiplier).toBe(3.4) // 1 + 20 * 0.12
expect(s20Syn.lenSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05
expect(s20Syn.minColdDamage).toBe(Math.floor(310 * 3.4))
expect(s20Syn.maxColdDamage).toBe(Math.floor(320 * 3.4))
expect(s20Syn.freezeFrames).toBe(Math.floor(50 * 2.0))
// Monotonicity checks
for (let lvl = 1; lvl < 30; lvl++) {
const cur = calculateFreezingArrowStats(lvl)
const nxt = calculateFreezingArrowStats(lvl + 1)
expect(nxt.minColdDamage).toBeGreaterThanOrEqual(cur.minColdDamage)
expect(nxt.maxColdDamage).toBeGreaterThanOrEqual(cur.maxColdDamage)
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
expect(nxt.radiusPx).toBe(80)
}
// Boundary resilience
const sZero = calculateFreezingArrowStats(0)
expect(sZero.minColdDamage).toBe(40)
expect(sZero.manaCost).toBe(9.0)
const sNeg = calculateFreezingArrowStats(-7)
expect(sNeg.minColdDamage).toBe(40)
const sNan = calculateFreezingArrowStats(NaN)
expect(sNan.minColdDamage).toBe(40)
const sFrac = calculateFreezingArrowStats(15.8)
expect(sFrac.minColdDamage).toBe(calculateFreezingArrowStats(15).minColdDamage)
})
})