feat(ama): implement Milestone M12 Amazon Bow and Crossbow skills tree (#459)
This commit is contained in:
parent
e3f03b255e
commit
88c57f7ed6
|
|
@ -323,6 +323,7 @@ export interface SUnitDmgPacket {
|
||||||
readonly elemMin256?: number
|
readonly elemMin256?: number
|
||||||
readonly elemMax256?: number
|
readonly elemMax256?: number
|
||||||
readonly elemDurationFrames?: number
|
readonly elemDurationFrames?: number
|
||||||
|
readonly slvl?: number
|
||||||
readonly physToElemPct?: number // e.g. Berserk 100% magic, Magic Arrow / Fire Arrow %
|
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 concentrationBonusPct?: number // active Concentration aura %ED for Blessed Hammer (50% efficiency)
|
||||||
readonly convictionPierce?: number
|
readonly convictionPierce?: number
|
||||||
|
|
@ -460,7 +461,15 @@ export function executeSUnitDmg(
|
||||||
// 3. Weapon `srcdam` (128 = 100%) + Physical Damage calculation
|
// 3. Weapon `srcdam` (128 = 100%) + Physical Damage calculation
|
||||||
const hasFlatPhys = (packet.flatPhysMin256 !== undefined && packet.flatPhysMin256 > 0) || (packet.flatPhysMax256 !== undefined && packet.flatPhysMax256 > 0)
|
const hasFlatPhys = (packet.flatPhysMin256 !== undefined && packet.flatPhysMin256 > 0) || (packet.flatPhysMax256 !== undefined && packet.flatPhysMax256 > 0)
|
||||||
const isSpellWithoutFlatPhys = attackKind === 'spell' && !hasFlatPhys
|
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
|
let rawPhys256 = 0
|
||||||
if (!isSpellWithoutFlatPhys && (srcDam > 0 || hasFlatPhys)) {
|
if (!isSpellWithoutFlatPhys && (srcDam > 0 || hasFlatPhys)) {
|
||||||
const weapMin256 = (attacker.weaponMinPhys ?? 25) * FIXED_ONE
|
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)
|
// 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)
|
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) {
|
if (convPct > 0 && rawPhys256 > 0) {
|
||||||
const converted256 = Math.trunc((rawPhys256 * convPct) / 100)
|
const converted256 = Math.trunc((rawPhys256 * convPct) / 100)
|
||||||
rawPhys256 -= converted256
|
rawPhys256 -= converted256
|
||||||
|
|
@ -525,7 +542,14 @@ export function executeSUnitDmg(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field / Enchant rules
|
// 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
|
// Added elemental damage from Paladin offensive auras (Holy Fire, Holy Freeze, Holy Shock) on physical attacks
|
||||||
if (attackKind !== 'spell' && packet.skillId !== 97) {
|
if (attackKind !== 'spell' && packet.skillId !== 97) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ import { getBlizzardDropOffset } from './blizzard-table.ts'
|
||||||
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
|
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
|
||||||
import { calculateMeteorResidualFireDamage } from '../skills.ts'
|
import { calculateMeteorResidualFireDamage } from '../skills.ts'
|
||||||
import { compute5BandScaling, computeDiminishingReturns } from './calc-ast.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
|
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||||
|
|
||||||
|
|
@ -647,6 +655,8 @@ export interface ActiveMissile {
|
||||||
originX?: number | undefined
|
originX?: number | undefined
|
||||||
originY?: number | undefined
|
originY?: number | undefined
|
||||||
spiralTheta?: number | undefined
|
spiralTheta?: number | undefined
|
||||||
|
readonly volleyId?: string | undefined
|
||||||
|
readonly canTriggerProcs?: boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MissileTerrainCollision {
|
export interface MissileTerrainCollision {
|
||||||
|
|
@ -668,6 +678,7 @@ export class MissileEngine {
|
||||||
readonly registry: D2DataRegistry
|
readonly registry: D2DataRegistry
|
||||||
private readonly missiles: ActiveMissile[] = []
|
private readonly missiles: ActiveMissile[] = []
|
||||||
private readonly nextDelayExpireByTarget = new Map<string, number>()
|
private readonly nextDelayExpireByTarget = new Map<string, number>()
|
||||||
|
private readonly volleyHits = new Set<string>()
|
||||||
private uidCounter = 1
|
private uidCounter = 1
|
||||||
terrain?: MissileTerrainCollision | undefined
|
terrain?: MissileTerrainCollision | undefined
|
||||||
|
|
||||||
|
|
@ -704,6 +715,8 @@ export class MissileEngine {
|
||||||
vx?: number
|
vx?: number
|
||||||
vy?: number
|
vy?: number
|
||||||
angleRad?: number
|
angleRad?: number
|
||||||
|
volleyId?: string
|
||||||
|
canTriggerProcs?: boolean
|
||||||
}): ActiveMissile | null {
|
}): ActiveMissile | null {
|
||||||
const rec =
|
const rec =
|
||||||
typeof params.missileNameOrId === 'number'
|
typeof params.missileNameOrId === 'number'
|
||||||
|
|
@ -880,8 +893,18 @@ export class MissileEngine {
|
||||||
maxRangeTicks,
|
maxRangeTicks,
|
||||||
nextHit: effectiveRec.nextHit,
|
nextHit: effectiveRec.nextHit,
|
||||||
nextDelayFrames: effectiveRec.nextDelay,
|
nextDelayFrames: effectiveRec.nextDelay,
|
||||||
canPierce: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear',
|
canPierce:
|
||||||
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0),
|
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,
|
pierceCount: 0,
|
||||||
hitTargetIds: new Set<string>(),
|
hitTargetIds: new Set<string>(),
|
||||||
dmgPacket: params.dmgPacket,
|
dmgPacket: params.dmgPacket,
|
||||||
|
|
@ -889,6 +912,8 @@ export class MissileEngine {
|
||||||
lastHitTargetId: params.lastHitTargetId,
|
lastHitTargetId: params.lastHitTargetId,
|
||||||
expired: false,
|
expired: false,
|
||||||
isTurret,
|
isTurret,
|
||||||
|
...(params.volleyId !== undefined ? { volleyId: params.volleyId } : {}),
|
||||||
|
...(params.canTriggerProcs !== undefined ? { canTriggerProcs: params.canTriggerProcs } : {}),
|
||||||
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
|
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
|
||||||
...(params.fireCadenceTicks !== undefined
|
...(params.fireCadenceTicks !== undefined
|
||||||
? { fireCadenceTicks: params.fireCadenceTicks }
|
? { fireCadenceTicks: params.fireCadenceTicks }
|
||||||
|
|
@ -1093,7 +1118,10 @@ export class MissileEngine {
|
||||||
// Authentic 1.13c Homing mechanics (Bone Spirit / Guided Arrow, pSrvDoFunc = 7):
|
// 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
|
// Continuously tracks and steers toward closest active hostile target in 2:1 isometric perspective
|
||||||
if (
|
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')
|
!m.name.includes('explode')
|
||||||
) {
|
) {
|
||||||
let closestTarget: { x: number; y: number } | null = null
|
let closestTarget: { x: number; y: number } | null = null
|
||||||
|
|
@ -1115,9 +1143,17 @@ export class MissileEngine {
|
||||||
if (closestTarget) {
|
if (closestTarget) {
|
||||||
const speed = m.record.vel ? m.record.vel : 12
|
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)
|
const targetAngle = Math.atan2((closestTarget.y - m.y) / ISO_GROUND_ASPECT_RATIO, closestTarget.x - m.x)
|
||||||
m.angleRad = targetAngle
|
let diff = targetAngle - m.angleRad
|
||||||
m.vx = Math.cos(targetAngle) * speed
|
while (diff < -Math.PI) diff += Math.PI * 2
|
||||||
m.vy = Math.sin(targetAngle) * speed * ISO_GROUND_ASPECT_RATIO
|
while (diff > Math.PI) diff -= Math.PI * 2
|
||||||
|
const maxTurn = 0.45
|
||||||
|
if (Math.abs(diff) <= maxTurn) {
|
||||||
|
m.angleRad = targetAngle
|
||||||
|
} 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
|
// Check collision against alive enemy targets
|
||||||
if (
|
if (
|
||||||
(m.name === 'meteorfire' && m.ageTicks % 5 === 0) ||
|
(m.name === 'meteorfire' && m.ageTicks % 5 === 0) ||
|
||||||
|
(m.name === 'immolationfire' && m.ageTicks % 5 === 0) ||
|
||||||
m.name === 'firewall' ||
|
m.name === 'firewall' ||
|
||||||
m.name === 'blaze' ||
|
m.name === 'blaze' ||
|
||||||
m.name === 'groundfirebig' ||
|
m.name === 'groundfirebig' ||
|
||||||
|
|
@ -1317,6 +1354,7 @@ export class MissileEngine {
|
||||||
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
||||||
const isGroundPatch =
|
const isGroundPatch =
|
||||||
m.name === 'meteorfire' ||
|
m.name === 'meteorfire' ||
|
||||||
|
m.name === 'immolationfire' ||
|
||||||
m.name === 'firewall' ||
|
m.name === 'firewall' ||
|
||||||
m.name === 'blaze' ||
|
m.name === 'blaze' ||
|
||||||
m.name === 'groundfirebig'
|
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) / ISO_GROUND_ASPECT_RATIO)
|
||||||
: Math.hypot(pos.x - m.x, pos.y - m.y)
|
: Math.hypot(pos.x - m.x, pos.y - m.y)
|
||||||
if (dist <= hitRadius) {
|
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`
|
// Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0`
|
||||||
if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) {
|
if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) {
|
||||||
continue
|
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 (
|
if (
|
||||||
m.record.pSrvHitFunc === 1 ||
|
m.record.pSrvHitFunc === 1 ||
|
||||||
m.record.pSrvHitFunc === 4 ||
|
m.record.pSrvHitFunc === 4 ||
|
||||||
m.record.pSrvHitFunc === 9 ||
|
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) {
|
for (const other of targets) {
|
||||||
if (other.id === target.id || other.statList.getHp256() <= 0) continue
|
if (other.id === target.id || other.statList.getHp256() <= 0) continue
|
||||||
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
|
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
|
||||||
|
|
@ -1420,6 +1578,18 @@ export class MissileEngine {
|
||||||
damage: splashOut.totalDamage,
|
damage: splashOut.totalDamage,
|
||||||
immune: splashOut.immuneToElem || splashOut.immuneToPhys,
|
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
|
// Glacial Spike splash targets also receive solid freeze
|
||||||
if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
|
if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
|
||||||
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
|
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
|
||||||
|
|
@ -1524,6 +1694,7 @@ export class MissileEngine {
|
||||||
!m.name.endsWith('explode')
|
!m.name.endsWith('explode')
|
||||||
if (
|
if (
|
||||||
m.name === 'meteorfire' ||
|
m.name === 'meteorfire' ||
|
||||||
|
m.name === 'immolationfire' ||
|
||||||
m.name === 'firewall' ||
|
m.name === 'firewall' ||
|
||||||
m.name === 'blaze' ||
|
m.name === 'blaze' ||
|
||||||
m.name === 'groundfirebig' ||
|
m.name === 'groundfirebig' ||
|
||||||
|
|
@ -1637,6 +1808,63 @@ export class MissileEngine {
|
||||||
dmgPacket: m.dmgPacket,
|
dmgPacket: m.dmgPacket,
|
||||||
})
|
})
|
||||||
subMissilesSpawned.push(expName)
|
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
|
m.expired = true
|
||||||
break
|
break
|
||||||
|
|
@ -1845,6 +2073,132 @@ export class MissileEngine {
|
||||||
return spawned
|
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.
|
* 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.
|
* Cuts 25% current HP with Normal (1 HP), Nightmare (33% max HP), and Hell (50% max HP) pre-check clamps.
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,54 @@ export {
|
||||||
METEOR_FIRE_SUBTILE_OFFSETS,
|
METEOR_FIRE_SUBTILE_OFFSETS,
|
||||||
type MeteorFireSubtileOffset,
|
type MeteorFireSubtileOffset,
|
||||||
} from './engine/meteor-table.ts'
|
} 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. */
|
/** One castable skill, read from a table row. */
|
||||||
export interface SkillDef {
|
export interface SkillDef {
|
||||||
|
|
@ -5549,6 +5597,36 @@ export function isBatch1Skill(skillId: number): boolean {
|
||||||
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
|
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
|
||||||
*/
|
*/
|
||||||
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
|
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) {
|
if (skillId === 38) {
|
||||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
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)
|
// 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 {
|
export function getSkillCooldownTicks(skillId: number): number {
|
||||||
if (
|
if (
|
||||||
|
skillId === 6 ||
|
||||||
|
skillId === 7 ||
|
||||||
|
skillId === 11 ||
|
||||||
|
skillId === 12 ||
|
||||||
|
skillId === 16 ||
|
||||||
|
skillId === 21 ||
|
||||||
|
skillId === 22 ||
|
||||||
|
skillId === 26 ||
|
||||||
|
skillId === 31 ||
|
||||||
skillId === 38 ||
|
skillId === 38 ||
|
||||||
skillId === 40 ||
|
skillId === 40 ||
|
||||||
skillId === 41 ||
|
skillId === 41 ||
|
||||||
|
|
@ -5705,6 +5792,9 @@ export function getSkillCooldownTicks(skillId: number): number {
|
||||||
) {
|
) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
if (skillId === 27) {
|
||||||
|
return 25 // Immolation Arrow: 25 frames (1.0s)
|
||||||
|
}
|
||||||
if (skillId === 51) {
|
if (skillId === 51) {
|
||||||
return 35
|
return 35
|
||||||
}
|
}
|
||||||
|
|
@ -5753,6 +5843,68 @@ export function calculateSkillDamage(
|
||||||
},
|
},
|
||||||
weaponDamage?: { min: number; max: number }
|
weaponDamage?: { min: number; max: number }
|
||||||
): { 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') {
|
if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') {
|
||||||
return calculateChargedBoltDamage(slvl, synergies as any)
|
return calculateChargedBoltDamage(slvl, synergies as any)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateMagicArrowStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateFireArrowStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateColdArrowStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,9 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
calculateMultipleShotStats,
|
||||||
|
generateMultipleShotAngles,
|
||||||
|
isMultipleShotCenterArrow,
|
||||||
|
} from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateExplodingArrowStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateIceArrowStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateGuidedArrowStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateStrafeStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateImmolationArrowStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { calculateFreezingArrowStats } from '../../amazon-bow.ts'
|
||||||
export default skillModule
|
export default skillModule
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,16 @@ export function evaluateSkillCore113c(ctx: SkillEvalContext): SkillEvalResult {
|
||||||
let elemHitShift = skill.hitShift
|
let elemHitShift = skill.hitShift
|
||||||
let elemType = skill.eType
|
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) {
|
if (baseEMin === 0 && baseEMax === 0 && primaryMissileName) {
|
||||||
const msl = registry.getMissileByName(primaryMissileName)
|
const msl = registry.getMissileByName(primaryMissileName)
|
||||||
if (msl && (msl.eMin > 0 || msl.eMax > 0)) {
|
if (msl && (msl.eMin > 0 || msl.eMax > 0)) {
|
||||||
|
|
@ -980,25 +989,59 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
const piercePct =
|
const piercePct =
|
||||||
caster.statList.getEffectiveSkillLevel(33) > 0
|
skill.id === 22
|
||||||
? Math.min(95, 25 + caster.statList.getEffectiveSkillLevel(33) * 3)
|
? 0
|
||||||
: 0
|
: caster.statList.getEffectiveSkillLevel(33) > 0
|
||||||
|
? Math.min(95, 25 + caster.statList.getEffectiveSkillLevel(33) * 3)
|
||||||
|
: 0
|
||||||
|
|
||||||
for (let i = 0; i < projectileCount; i += 1) {
|
if (skill.id === 12) {
|
||||||
const spreadOffset = (i - (projectileCount - 1) / 2) * 14
|
const vol = missileEngine.spawnMultipleShotVolley({
|
||||||
const m = missileEngine.spawnMissile({
|
sourceSkillId: 12,
|
||||||
missileNameOrId: evalResult.missileA,
|
|
||||||
sourceSkillId: skill.id,
|
|
||||||
slvl: evalResult.slvl,
|
slvl: evalResult.slvl,
|
||||||
owner: caster,
|
owner: caster,
|
||||||
startX: 0,
|
startX: 0,
|
||||||
startY: 0,
|
startY: 0,
|
||||||
targetX: targetX + spreadOffset,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
dmgPacket,
|
dmgPacket,
|
||||||
pierceChancePct: piercePct,
|
pierceChancePct: piercePct,
|
||||||
|
missileName: evalResult.missileA || 'arrow',
|
||||||
})
|
})
|
||||||
if (m) missilesSpawned.push(m.name)
|
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({
|
||||||
|
missileNameOrId: evalResult.missileA,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
owner: caster,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX: targetX + spreadOffset,
|
||||||
|
targetY,
|
||||||
|
dmgPacket,
|
||||||
|
pierceChancePct: piercePct,
|
||||||
|
})
|
||||||
|
if (m) missilesSpawned.push(m.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance missile engine so immediate or close-range missiles land and record damage
|
// Advance missile engine so immediate or close-range missiles land and record damage
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-006-magic-arrow.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-007-fire-arrow.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-011-cold-arrow.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-012-multiple-shot.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-016-exploding-arrow.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-021-ice-arrow.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-022-guided-arrow.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-026-strafe.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-027-immolation-arrow.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-031-freezing-arrow.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)', () => {
|
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 () => {
|
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(dbg.actionFrameTriggered).toBe(true)
|
||||||
expect(outcome?.executed).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue