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 elemMax256?: number
|
||||
readonly elemDurationFrames?: number
|
||||
readonly slvl?: number
|
||||
readonly physToElemPct?: number // e.g. Berserk 100% magic, Magic Arrow / Fire Arrow %
|
||||
readonly concentrationBonusPct?: number // active Concentration aura %ED for Blessed Hammer (50% efficiency)
|
||||
readonly convictionPierce?: number
|
||||
|
|
@ -460,7 +461,15 @@ export function executeSUnitDmg(
|
|||
// 3. Weapon `srcdam` (128 = 100%) + Physical Damage calculation
|
||||
const hasFlatPhys = (packet.flatPhysMin256 !== undefined && packet.flatPhysMin256 > 0) || (packet.flatPhysMax256 !== undefined && packet.flatPhysMax256 > 0)
|
||||
const isSpellWithoutFlatPhys = attackKind === 'spell' && !hasFlatPhys
|
||||
const srcDam = isSpellWithoutFlatPhys ? 0 : (packet.srcDam ?? (attackKind !== 'spell' ? 128 : 0))
|
||||
const srcDam =
|
||||
isSpellWithoutFlatPhys
|
||||
? 0
|
||||
: (packet.srcDam ??
|
||||
(packet.skillId === 12 || packet.skillId === 26
|
||||
? 96
|
||||
: attackKind !== 'spell'
|
||||
? 128
|
||||
: 0))
|
||||
let rawPhys256 = 0
|
||||
if (!isSpellWithoutFlatPhys && (srcDam > 0 || hasFlatPhys)) {
|
||||
const weapMin256 = (attacker.weaponMinPhys ?? 25) * FIXED_ONE
|
||||
|
|
@ -517,7 +526,15 @@ export function executeSUnitDmg(
|
|||
|
||||
// 4. Physical-to-Elemental/Magic Conversion (e.g. Berserk 100% Magic, Magic Arrow, Fire Arrow, Cold Arrow)
|
||||
let rawElem256 = Math.trunc(((packet.elemMin256 ?? 0) + (packet.elemMax256 ?? 0)) / 2)
|
||||
const convPct = Math.max(0, Math.min(100, packet.physToElemPct ?? (packet.skillId === 152 ? 100 : 0)))
|
||||
const defaultConvPct =
|
||||
packet.skillId === 152
|
||||
? 100
|
||||
: packet.skillId === 7 || packet.skillId === 11
|
||||
? 80
|
||||
: packet.skillId === 6
|
||||
? Math.min(100, 5 + Math.max(0, (packet.slvl ?? attacker.statList.getEffectiveSkillLevel(6) ?? 1) - 1))
|
||||
: 0
|
||||
const convPct = Math.max(0, Math.min(100, packet.physToElemPct ?? defaultConvPct))
|
||||
if (convPct > 0 && rawPhys256 > 0) {
|
||||
const converted256 = Math.trunc((rawPhys256 * convPct) / 100)
|
||||
rawPhys256 -= converted256
|
||||
|
|
@ -525,7 +542,14 @@ export function executeSUnitDmg(
|
|||
}
|
||||
|
||||
// 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field / Enchant rules
|
||||
let elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase()
|
||||
let elemType = (
|
||||
packet.elemType ??
|
||||
(packet.skillId === 6 || packet.skillId === 152 || packet.skillId === 112
|
||||
? 'mag'
|
||||
: packet.skillId === 11 || packet.skillId === 21 || packet.skillId === 31
|
||||
? 'cold'
|
||||
: 'fire')
|
||||
).toLowerCase()
|
||||
|
||||
// Added elemental damage from Paladin offensive auras (Holy Fire, Holy Freeze, Holy Shock) on physical attacks
|
||||
if (attackKind !== 'spell' && packet.skillId !== 97) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ import { getBlizzardDropOffset } from './blizzard-table.ts'
|
|||
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
|
||||
import { calculateMeteorResidualFireDamage } from '../skills.ts'
|
||||
import { compute5BandScaling, computeDiminishingReturns } from './calc-ast.ts'
|
||||
import {
|
||||
calculateMultipleShotStats,
|
||||
calculateStrafeStats,
|
||||
calculateIceArrowStats,
|
||||
calculateFreezingArrowStats,
|
||||
generateMultipleShotAngles,
|
||||
isMultipleShotCenterArrow,
|
||||
} from '../skills/amazon-bow.ts'
|
||||
|
||||
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||
|
||||
|
|
@ -647,6 +655,8 @@ export interface ActiveMissile {
|
|||
originX?: number | undefined
|
||||
originY?: number | undefined
|
||||
spiralTheta?: number | undefined
|
||||
readonly volleyId?: string | undefined
|
||||
readonly canTriggerProcs?: boolean | undefined
|
||||
}
|
||||
|
||||
export interface MissileTerrainCollision {
|
||||
|
|
@ -668,6 +678,7 @@ export class MissileEngine {
|
|||
readonly registry: D2DataRegistry
|
||||
private readonly missiles: ActiveMissile[] = []
|
||||
private readonly nextDelayExpireByTarget = new Map<string, number>()
|
||||
private readonly volleyHits = new Set<string>()
|
||||
private uidCounter = 1
|
||||
terrain?: MissileTerrainCollision | undefined
|
||||
|
||||
|
|
@ -704,6 +715,8 @@ export class MissileEngine {
|
|||
vx?: number
|
||||
vy?: number
|
||||
angleRad?: number
|
||||
volleyId?: string
|
||||
canTriggerProcs?: boolean
|
||||
}): ActiveMissile | null {
|
||||
const rec =
|
||||
typeof params.missileNameOrId === 'number'
|
||||
|
|
@ -880,8 +893,18 @@ export class MissileEngine {
|
|||
maxRangeTicks,
|
||||
nextHit: effectiveRec.nextHit,
|
||||
nextDelayFrames: effectiveRec.nextDelay,
|
||||
canPierce: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear',
|
||||
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0),
|
||||
canPierce:
|
||||
params.sourceSkillId === 22 ||
|
||||
effectiveRec.name.toLowerCase() === 'guidedarrow' ||
|
||||
effectiveRec.id === 86
|
||||
? false
|
||||
: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear',
|
||||
pierceChancePct:
|
||||
params.sourceSkillId === 22 ||
|
||||
effectiveRec.name.toLowerCase() === 'guidedarrow' ||
|
||||
effectiveRec.id === 86
|
||||
? 0
|
||||
: (params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0)),
|
||||
pierceCount: 0,
|
||||
hitTargetIds: new Set<string>(),
|
||||
dmgPacket: params.dmgPacket,
|
||||
|
|
@ -889,6 +912,8 @@ export class MissileEngine {
|
|||
lastHitTargetId: params.lastHitTargetId,
|
||||
expired: false,
|
||||
isTurret,
|
||||
...(params.volleyId !== undefined ? { volleyId: params.volleyId } : {}),
|
||||
...(params.canTriggerProcs !== undefined ? { canTriggerProcs: params.canTriggerProcs } : {}),
|
||||
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
|
||||
...(params.fireCadenceTicks !== undefined
|
||||
? { fireCadenceTicks: params.fireCadenceTicks }
|
||||
|
|
@ -1093,7 +1118,10 @@ export class MissileEngine {
|
|||
// Authentic 1.13c Homing mechanics (Bone Spirit / Guided Arrow, pSrvDoFunc = 7):
|
||||
// Continuously tracks and steers toward closest active hostile target in 2:1 isometric perspective
|
||||
if (
|
||||
(m.name.toLowerCase() === 'bonespirit' || m.record.pSrvDoFunc === 7) &&
|
||||
(m.name.toLowerCase() === 'bonespirit' ||
|
||||
m.name.toLowerCase() === 'guidedarrow' ||
|
||||
m.sourceSkillId === 22 ||
|
||||
m.record.pSrvDoFunc === 7) &&
|
||||
!m.name.includes('explode')
|
||||
) {
|
||||
let closestTarget: { x: number; y: number } | null = null
|
||||
|
|
@ -1115,9 +1143,17 @@ export class MissileEngine {
|
|||
if (closestTarget) {
|
||||
const speed = m.record.vel ? m.record.vel : 12
|
||||
const targetAngle = Math.atan2((closestTarget.y - m.y) / ISO_GROUND_ASPECT_RATIO, closestTarget.x - m.x)
|
||||
let diff = targetAngle - m.angleRad
|
||||
while (diff < -Math.PI) diff += Math.PI * 2
|
||||
while (diff > Math.PI) diff -= Math.PI * 2
|
||||
const maxTurn = 0.45
|
||||
if (Math.abs(diff) <= maxTurn) {
|
||||
m.angleRad = targetAngle
|
||||
m.vx = Math.cos(targetAngle) * speed
|
||||
m.vy = Math.sin(targetAngle) * speed * ISO_GROUND_ASPECT_RATIO
|
||||
} else {
|
||||
m.angleRad += Math.sign(diff) * maxTurn
|
||||
}
|
||||
m.vx = Math.cos(m.angleRad) * speed
|
||||
m.vy = Math.sin(m.angleRad) * speed * ISO_GROUND_ASPECT_RATIO
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1258,6 +1294,7 @@ export class MissileEngine {
|
|||
// Check collision against alive enemy targets
|
||||
if (
|
||||
(m.name === 'meteorfire' && m.ageTicks % 5 === 0) ||
|
||||
(m.name === 'immolationfire' && m.ageTicks % 5 === 0) ||
|
||||
m.name === 'firewall' ||
|
||||
m.name === 'blaze' ||
|
||||
m.name === 'groundfirebig' ||
|
||||
|
|
@ -1317,6 +1354,7 @@ export class MissileEngine {
|
|||
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
||||
const isGroundPatch =
|
||||
m.name === 'meteorfire' ||
|
||||
m.name === 'immolationfire' ||
|
||||
m.name === 'firewall' ||
|
||||
m.name === 'blaze' ||
|
||||
m.name === 'groundfirebig'
|
||||
|
|
@ -1324,6 +1362,21 @@ export class MissileEngine {
|
|||
? Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO)
|
||||
: Math.hypot(pos.x - m.x, pos.y - m.y)
|
||||
if (dist <= hitRadius) {
|
||||
// Multiple Shot volley multi-hit immunity
|
||||
if (m.volleyId) {
|
||||
const vKey = `${m.volleyId}:${target.id}`
|
||||
if (this.volleyHits.has(vKey)) {
|
||||
continue
|
||||
}
|
||||
this.volleyHits.add(vKey)
|
||||
}
|
||||
|
||||
// Guided Arrow strict zero-pierce enforcement
|
||||
if (m.sourceSkillId === 22 || m.name.toLowerCase() === 'guidedarrow' || m.missileId === 86) {
|
||||
;(m as any).canPierce = false
|
||||
;(m as any).pierceChancePct = 0
|
||||
}
|
||||
|
||||
// Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0`
|
||||
if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) {
|
||||
continue
|
||||
|
|
@ -1401,14 +1454,119 @@ export class MissileEngine {
|
|||
}
|
||||
}
|
||||
|
||||
// `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`)
|
||||
// Ice Arrow (Skill 21 / missile icearrow): freezes primary target solid
|
||||
if (m.name === 'icearrow' || m.sourceSkillId === 21) {
|
||||
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
|
||||
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateIceArrowStats(m.slvl).freezeFrames
|
||||
target.stateBus.applyState({
|
||||
stateNameOrId: 'freeze',
|
||||
sourceSkillId: 21,
|
||||
slvl: m.slvl,
|
||||
durationFrames: frzFrames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Freezing Arrow (Skill 31 / missile freezingarrow): freezes primary target solid
|
||||
if (m.name === 'freezingarrow' || m.sourceSkillId === 31) {
|
||||
if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) {
|
||||
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateFreezingArrowStats(m.slvl).freezeFrames
|
||||
target.stateBus.applyState({
|
||||
stateNameOrId: 'freeze',
|
||||
sourceSkillId: 31,
|
||||
slvl: m.slvl,
|
||||
durationFrames: frzFrames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-missiles on hit for Amazon Bow skills (explodingarrow, icearrow, immolationarrow, freezingarrow)
|
||||
if (m.name === 'explodingarrow' || m.sourceSkillId === 16) {
|
||||
const expName = m.record.explosionMissile || 'explodingarrowexp'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
}
|
||||
|
||||
if (m.name === 'immolationarrow' || m.sourceSkillId === 27) {
|
||||
const patchName = m.record.hitSubMissile1 || 'immolationfire'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: patchName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
maxRangeTicks: 75,
|
||||
})
|
||||
subMissilesSpawned.push(patchName)
|
||||
}
|
||||
|
||||
if (m.name === 'freezingarrow' || m.sourceSkillId === 31) {
|
||||
const expName = m.record.explosionMissile || 'freezingarrowexp'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
}
|
||||
|
||||
if (m.name === 'icearrow' || m.sourceSkillId === 21) {
|
||||
const expName = m.record.explosionMissile || 'icearrowexplode'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
}
|
||||
|
||||
// `pSrvHitFunc = 1` or `4` or `9` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Immolation Arrow`, `Glacial Spike`)
|
||||
if (
|
||||
m.record.pSrvHitFunc === 1 ||
|
||||
m.record.pSrvHitFunc === 4 ||
|
||||
m.record.pSrvHitFunc === 9 ||
|
||||
m.record.pSrvHitFunc === 13
|
||||
m.record.pSrvHitFunc === 13 ||
|
||||
m.sourceSkillId === 16 ||
|
||||
m.sourceSkillId === 27 ||
|
||||
m.sourceSkillId === 31 ||
|
||||
m.name === 'explodingarrow' ||
|
||||
m.name === 'immolationarrow' ||
|
||||
m.name === 'freezingarrow'
|
||||
) {
|
||||
const splashRadius = (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) ? 64 : 85
|
||||
const isBowAoE =
|
||||
m.sourceSkillId === 16 ||
|
||||
m.sourceSkillId === 27 ||
|
||||
m.sourceSkillId === 31 ||
|
||||
m.name === 'explodingarrow' ||
|
||||
m.name === 'immolationarrow' ||
|
||||
m.name === 'freezingarrow'
|
||||
const splashRadius = isBowAoE ? 80 : (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55 ? 64 : 85)
|
||||
for (const other of targets) {
|
||||
if (other.id === target.id || other.statList.getHp256() <= 0) continue
|
||||
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
|
||||
|
|
@ -1420,6 +1578,18 @@ export class MissileEngine {
|
|||
damage: splashOut.totalDamage,
|
||||
immune: splashOut.immuneToElem || splashOut.immuneToPhys,
|
||||
})
|
||||
// Freezing Arrow splash targets also receive solid freeze
|
||||
if (m.name === 'freezingarrow' || m.sourceSkillId === 31) {
|
||||
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
|
||||
const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateFreezingArrowStats(m.slvl).freezeFrames
|
||||
other.stateBus.applyState({
|
||||
stateNameOrId: 'freeze',
|
||||
sourceSkillId: 31,
|
||||
slvl: m.slvl,
|
||||
durationFrames: frzFrames,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Glacial Spike splash targets also receive solid freeze
|
||||
if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) {
|
||||
if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) {
|
||||
|
|
@ -1524,6 +1694,7 @@ export class MissileEngine {
|
|||
!m.name.endsWith('explode')
|
||||
if (
|
||||
m.name === 'meteorfire' ||
|
||||
m.name === 'immolationfire' ||
|
||||
m.name === 'firewall' ||
|
||||
m.name === 'blaze' ||
|
||||
m.name === 'groundfirebig' ||
|
||||
|
|
@ -1637,6 +1808,63 @@ export class MissileEngine {
|
|||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
} else if (m.name === 'explodingarrow' || m.sourceSkillId === 16) {
|
||||
const expName = m.record.explosionMissile || 'explodingarrowexp'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
} else if (m.name === 'icearrow' || m.sourceSkillId === 21) {
|
||||
const expName = m.record.explosionMissile || 'icearrowexplode'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
} else if (m.name === 'immolationarrow' || m.sourceSkillId === 27) {
|
||||
const patchName = m.record.hitSubMissile1 || 'immolationfire'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: patchName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
maxRangeTicks: 75,
|
||||
})
|
||||
subMissilesSpawned.push(patchName)
|
||||
} else if (m.name === 'freezingarrow' || m.sourceSkillId === 31) {
|
||||
const expName = m.record.explosionMissile || 'freezingarrowexp'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
}
|
||||
m.expired = true
|
||||
break
|
||||
|
|
@ -1845,6 +2073,132 @@ export class MissileEngine {
|
|||
return spawned
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns authentic 1.13c Multiple Shot volley (Skill 12):
|
||||
* - Arrow count: min(24, 2 + slvl)
|
||||
* - Fan arc spread (~45-50 degrees) symmetric around aim vector
|
||||
* - 75% weapon damage (srcDam = 96)
|
||||
* - Center 2 arrows flagged to trigger procs; outer arrows cannot
|
||||
* - Shared volleyId enforces single-target hit immunity
|
||||
*/
|
||||
spawnMultipleShotVolley(params: {
|
||||
sourceSkillId?: number
|
||||
slvl: number
|
||||
owner: CombatUnitContext
|
||||
startX: number
|
||||
startY: number
|
||||
targetX: number
|
||||
targetY: number
|
||||
dmgPacket: SUnitDmgPacket
|
||||
pierceChancePct?: number
|
||||
missileName?: string
|
||||
}): ActiveMissile[] {
|
||||
const stats = calculateMultipleShotStats(params.slvl)
|
||||
const arrowCount = stats.arrowCount
|
||||
const dx = params.targetX - params.startX
|
||||
const dy = (params.targetY - params.startY) / ISO_GROUND_ASPECT_RATIO
|
||||
const aimAngleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
||||
const angles = generateMultipleShotAngles(aimAngleRad, arrowCount, (50 * Math.PI) / 180)
|
||||
const volleyId = `ms-volley-${String(this.uidCounter++)}`
|
||||
const missiles: ActiveMissile[] = []
|
||||
const missileName = params.missileName || 'arrow'
|
||||
|
||||
for (let i = 0; i < arrowCount; i += 1) {
|
||||
const angle = angles[i]!
|
||||
const canTriggerProcs = isMultipleShotCenterArrow(i, arrowCount)
|
||||
const m = this.spawnMissile({
|
||||
missileNameOrId: missileName,
|
||||
sourceSkillId: params.sourceSkillId ?? 12,
|
||||
slvl: params.slvl,
|
||||
owner: params.owner,
|
||||
startX: params.startX,
|
||||
startY: params.startY,
|
||||
targetX: params.startX + Math.cos(angle) * 200,
|
||||
targetY: params.startY + Math.sin(angle) * 200 * ISO_GROUND_ASPECT_RATIO,
|
||||
angleRad: angle,
|
||||
dmgPacket: {
|
||||
...params.dmgPacket,
|
||||
srcDam: 96,
|
||||
},
|
||||
...(params.pierceChancePct !== undefined ? { pierceChancePct: params.pierceChancePct } : {}),
|
||||
volleyId,
|
||||
canTriggerProcs,
|
||||
})
|
||||
if (m) missiles.push(m)
|
||||
}
|
||||
return missiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns authentic 1.13c Strafe sequence (Skill 26):
|
||||
* - Arrow count: min(10, 4 + floor(slvl/2))
|
||||
* - Cycles target acquisition among alive nearby enemies
|
||||
* - 75% weapon damage (srcDam = 96) + Enhanced Damage bonus (+5%/lvl)
|
||||
* - Locks caster in stationary state for lockInFrames
|
||||
*/
|
||||
spawnStrafeVolley(params: {
|
||||
sourceSkillId?: number
|
||||
slvl: number
|
||||
owner: CombatUnitContext
|
||||
startX: number
|
||||
startY: number
|
||||
targets?: readonly CombatUnitContext[]
|
||||
targetPositions?: ReadonlyMap<string, { x: number; y: number }>
|
||||
targetX?: number
|
||||
targetY?: number
|
||||
dmgPacket: SUnitDmgPacket
|
||||
pierceChancePct?: number
|
||||
missileName?: string
|
||||
}): ActiveMissile[] {
|
||||
const stats = calculateStrafeStats(params.slvl)
|
||||
const arrowCount = stats.arrowCount
|
||||
const aliveTargets = params.targets ? params.targets.filter(t => t.statList.getHp256() > 0) : []
|
||||
const missiles: ActiveMissile[] = []
|
||||
const missileName = params.missileName || 'arrow'
|
||||
|
||||
// Strafe stationary lock-in state on player
|
||||
params.owner.stateBus.applyState({
|
||||
stateNameOrId: 'strafe_lockin',
|
||||
sourceSkillId: 26,
|
||||
slvl: params.slvl,
|
||||
durationFrames: stats.lockInFrames,
|
||||
})
|
||||
|
||||
for (let i = 0; i < arrowCount; i += 1) {
|
||||
let tX = params.targetX ?? params.startX + 200
|
||||
let tY = params.targetY ?? params.startY
|
||||
if (aliveTargets.length > 0) {
|
||||
const assignedTarget = aliveTargets[i % aliveTargets.length]!
|
||||
const pos = params.targetPositions?.get(assignedTarget.id) ?? { x: tX, y: tY }
|
||||
tX = pos.x
|
||||
tY = pos.y
|
||||
}
|
||||
const dx = tX - params.startX
|
||||
const dy = (tY - params.startY) / ISO_GROUND_ASPECT_RATIO
|
||||
const angle = dx === 0 && dy === 0 ? 0 : Math.atan2(dy, dx)
|
||||
const m = this.spawnMissile({
|
||||
missileNameOrId: missileName,
|
||||
sourceSkillId: params.sourceSkillId ?? 26,
|
||||
slvl: params.slvl,
|
||||
owner: params.owner,
|
||||
startX: params.startX,
|
||||
startY: params.startY,
|
||||
targetX: tX,
|
||||
targetY: tY,
|
||||
angleRad: angle,
|
||||
dmgPacket: {
|
||||
...params.dmgPacket,
|
||||
srcDam: 96,
|
||||
physDamagePct: (params.dmgPacket.physDamagePct ?? 0) + stats.enhancedDamagePct,
|
||||
},
|
||||
...(params.pierceChancePct !== undefined ? { pierceChancePct: params.pierceChancePct } : {}),
|
||||
canTriggerProcs: true,
|
||||
})
|
||||
if (m) missiles.push(m)
|
||||
}
|
||||
return missiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes authentic 1.13c instantaneous 360° Static Field discharge with difficulty floors and LOS.
|
||||
* Cuts 25% current HP with Normal (1 HP), Nightmare (33% max HP), and Hell (50% max HP) pre-check clamps.
|
||||
|
|
|
|||
|
|
@ -285,6 +285,54 @@ export {
|
|||
METEOR_FIRE_SUBTILE_OFFSETS,
|
||||
type MeteorFireSubtileOffset,
|
||||
} from './engine/meteor-table.ts'
|
||||
import {
|
||||
calculateMagicArrowStats,
|
||||
calculateFireArrowStats,
|
||||
calculateColdArrowStats,
|
||||
calculateMultipleShotStats,
|
||||
calculateExplodingArrowStats,
|
||||
calculateIceArrowStats,
|
||||
calculateGuidedArrowStats,
|
||||
calculateStrafeStats,
|
||||
calculateImmolationArrowStats,
|
||||
calculateFreezingArrowStats,
|
||||
generateMultipleShotAngles,
|
||||
isMultipleShotCenterArrow,
|
||||
type MagicArrowStats,
|
||||
type FireArrowStats,
|
||||
type ColdArrowStats,
|
||||
type MultipleShotStats,
|
||||
type ExplodingArrowStats,
|
||||
type IceArrowStats,
|
||||
type GuidedArrowStats,
|
||||
type StrafeStats,
|
||||
type ImmolationArrowStats,
|
||||
type FreezingArrowStats,
|
||||
} from './skills/amazon-bow.ts'
|
||||
export {
|
||||
calculateMagicArrowStats,
|
||||
calculateFireArrowStats,
|
||||
calculateColdArrowStats,
|
||||
calculateMultipleShotStats,
|
||||
calculateExplodingArrowStats,
|
||||
calculateIceArrowStats,
|
||||
calculateGuidedArrowStats,
|
||||
calculateStrafeStats,
|
||||
calculateImmolationArrowStats,
|
||||
calculateFreezingArrowStats,
|
||||
generateMultipleShotAngles,
|
||||
isMultipleShotCenterArrow,
|
||||
type MagicArrowStats,
|
||||
type FireArrowStats,
|
||||
type ColdArrowStats,
|
||||
type MultipleShotStats,
|
||||
type ExplodingArrowStats,
|
||||
type IceArrowStats,
|
||||
type GuidedArrowStats,
|
||||
type StrafeStats,
|
||||
type ImmolationArrowStats,
|
||||
type FreezingArrowStats,
|
||||
} from './skills/amazon-bow.ts'
|
||||
|
||||
/** One castable skill, read from a table row. */
|
||||
export interface SkillDef {
|
||||
|
|
@ -5549,6 +5597,36 @@ export function isBatch1Skill(skillId: number): boolean {
|
|||
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
|
||||
*/
|
||||
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
|
||||
if (skillId === 6) {
|
||||
return calculateMagicArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 7) {
|
||||
return calculateFireArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 11) {
|
||||
return calculateColdArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 12) {
|
||||
return calculateMultipleShotStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 16) {
|
||||
return calculateExplodingArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 21) {
|
||||
return calculateIceArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 22) {
|
||||
return calculateGuidedArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 26) {
|
||||
return calculateStrafeStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 27) {
|
||||
return calculateImmolationArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 31) {
|
||||
return calculateFreezingArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 38) {
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
// 1.13c Skills.txt: mana = 24, lvlmana = 4, manashift = 5 (factor = 0.125)
|
||||
|
|
@ -5682,6 +5760,15 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
*/
|
||||
export function getSkillCooldownTicks(skillId: number): number {
|
||||
if (
|
||||
skillId === 6 ||
|
||||
skillId === 7 ||
|
||||
skillId === 11 ||
|
||||
skillId === 12 ||
|
||||
skillId === 16 ||
|
||||
skillId === 21 ||
|
||||
skillId === 22 ||
|
||||
skillId === 26 ||
|
||||
skillId === 31 ||
|
||||
skillId === 38 ||
|
||||
skillId === 40 ||
|
||||
skillId === 41 ||
|
||||
|
|
@ -5705,6 +5792,9 @@ export function getSkillCooldownTicks(skillId: number): number {
|
|||
) {
|
||||
return 0
|
||||
}
|
||||
if (skillId === 27) {
|
||||
return 25 // Immolation Arrow: 25 frames (1.0s)
|
||||
}
|
||||
if (skillId === 51) {
|
||||
return 35
|
||||
}
|
||||
|
|
@ -5753,6 +5843,68 @@ export function calculateSkillDamage(
|
|||
},
|
||||
weaponDamage?: { min: number; max: number }
|
||||
): { min: number; max: number } {
|
||||
if (skillId === 6 || skillId === '6' || skillId === 'magicarrow' || skillId === 'magic arrow') {
|
||||
const stats = calculateMagicArrowStats(slvl)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.flatMagicDamage, max: baseMax + stats.flatMagicDamage }
|
||||
}
|
||||
if (skillId === 7 || skillId === '7' || skillId === 'firearrow' || skillId === 'fire arrow') {
|
||||
const stats = calculateFireArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (skillId === 11 || skillId === '11' || skillId === 'coldarrow' || skillId === 'cold arrow') {
|
||||
const stats = calculateColdArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (skillId === 12 || skillId === '12' || skillId === 'multipleshot' || skillId === 'multiple shot') {
|
||||
const stats = calculateMultipleShotStats(slvl)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: Math.floor(baseMin * stats.damageMultiplier), max: Math.floor(baseMax * stats.damageMultiplier) }
|
||||
}
|
||||
if (skillId === 16 || skillId === '16' || skillId === 'explodingarrow' || skillId === 'exploding arrow') {
|
||||
const stats = calculateExplodingArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minBlastDamage, max: baseMax + stats.maxBlastDamage }
|
||||
}
|
||||
if (skillId === 21 || skillId === '21' || skillId === 'icearrow' || skillId === 'ice arrow') {
|
||||
const stats = calculateIceArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (skillId === 22 || skillId === '22' || skillId === 'guidedarrow' || skillId === 'guided arrow') {
|
||||
const stats = calculateGuidedArrowStats(slvl)
|
||||
const ed = 1 + stats.enhancedDamagePct / 100
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
|
||||
}
|
||||
if (skillId === 26 || skillId === '26' || skillId === 'strafe') {
|
||||
const stats = calculateStrafeStats(slvl)
|
||||
const mult = stats.damageMultiplier * (1 + stats.enhancedDamagePct / 100)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: Math.floor(baseMin * mult), max: Math.floor(baseMax * mult) }
|
||||
}
|
||||
if (skillId === 27 || skillId === '27' || skillId === 'immolationarrow' || skillId === 'immolation arrow') {
|
||||
const stats = calculateImmolationArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minExplosionDamage, max: baseMax + stats.maxExplosionDamage }
|
||||
}
|
||||
if (skillId === 31 || skillId === '31' || skillId === 'freezingarrow' || skillId === 'freezing arrow') {
|
||||
const stats = calculateFreezingArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minColdDamage, max: baseMax + stats.maxColdDamage }
|
||||
}
|
||||
if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') {
|
||||
return calculateChargedBoltDamage(slvl, synergies as any)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateFireArrowStats } from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateColdArrowStats } from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,9 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export {
|
||||
calculateMultipleShotStats,
|
||||
generateMultipleShotAngles,
|
||||
isMultipleShotCenterArrow,
|
||||
} from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateExplodingArrowStats } from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateIceArrowStats } from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateGuidedArrowStats } from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateStrafeStats } from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateImmolationArrowStats } from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateFreezingArrowStats } from '../../amazon-bow.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -154,7 +154,16 @@ export function evaluateSkillCore113c(ctx: SkillEvalContext): SkillEvalResult {
|
|||
let elemHitShift = skill.hitShift
|
||||
let elemType = skill.eType
|
||||
|
||||
const primaryMissileName = skill.srvMissile || skill.srvMissileA || skill.cltMissile || skill.cltMissileA
|
||||
const primaryMissileName =
|
||||
skill.srvMissile ||
|
||||
skill.srvMissileA ||
|
||||
skill.cltMissile ||
|
||||
skill.cltMissileA ||
|
||||
(skill.id === 22
|
||||
? 'guidedarrow'
|
||||
: skill.id === 12 || skill.id === 26
|
||||
? 'arrow'
|
||||
: '')
|
||||
if (baseEMin === 0 && baseEMax === 0 && primaryMissileName) {
|
||||
const msl = registry.getMissileByName(primaryMissileName)
|
||||
if (msl && (msl.eMin > 0 || msl.eMax > 0)) {
|
||||
|
|
@ -980,10 +989,43 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
|
|||
}
|
||||
|
||||
const piercePct =
|
||||
caster.statList.getEffectiveSkillLevel(33) > 0
|
||||
skill.id === 22
|
||||
? 0
|
||||
: caster.statList.getEffectiveSkillLevel(33) > 0
|
||||
? Math.min(95, 25 + caster.statList.getEffectiveSkillLevel(33) * 3)
|
||||
: 0
|
||||
|
||||
if (skill.id === 12) {
|
||||
const vol = missileEngine.spawnMultipleShotVolley({
|
||||
sourceSkillId: 12,
|
||||
slvl: evalResult.slvl,
|
||||
owner: caster,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
targetX,
|
||||
targetY,
|
||||
dmgPacket,
|
||||
pierceChancePct: piercePct,
|
||||
missileName: evalResult.missileA || 'arrow',
|
||||
})
|
||||
missilesSpawned.push(...vol.map(m => m.name))
|
||||
} else if (skill.id === 26) {
|
||||
const st = missileEngine.spawnStrafeVolley({
|
||||
sourceSkillId: 26,
|
||||
slvl: evalResult.slvl,
|
||||
owner: caster,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
targets,
|
||||
targetPositions: targetPositions ?? new Map(),
|
||||
targetX,
|
||||
targetY,
|
||||
dmgPacket,
|
||||
pierceChancePct: piercePct,
|
||||
missileName: evalResult.missileA || 'arrow',
|
||||
})
|
||||
missilesSpawned.push(...st.map(m => m.name))
|
||||
} else {
|
||||
for (let i = 0; i < projectileCount; i += 1) {
|
||||
const spreadOffset = (i - (projectileCount - 1) / 2) * 14
|
||||
const m = missileEngine.spawnMissile({
|
||||
|
|
@ -1000,6 +1042,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
|
|||
})
|
||||
if (m) missilesSpawned.push(m.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Advance missile engine so immediate or close-range missiles land and record damage
|
||||
const stepRes = missileEngine.tick(currentTick, targets, targetPositions)
|
||||
|
|
|
|||
|
|
@ -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 { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-006-magic-arrow.ts'
|
||||
import { calculateMagicArrowStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #006] Magic Arrow (AMA) — 1.13c Parity (Issue #160)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,60 @@ describe('[Skill #006] Magic Arrow (AMA) — 1.13c Parity (Issue #160)', () => {
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateMagicArrowStats pure calculations and boundary invariants', () => {
|
||||
// Level 1: 5% magic conversion, 1 flat magic dmg, +10% AR, 1.5 mana (384 fixed-point), noAmmo: true
|
||||
const s1 = calculateMagicArrowStats(1)
|
||||
expect(s1.conversionPct).toBe(5)
|
||||
expect(s1.flatMagicDamage).toBe(1)
|
||||
expect(s1.toHitBonusPct).toBe(10)
|
||||
expect(s1.manaCost).toBe(1.5)
|
||||
expect(s1.manaCost256).toBe(384)
|
||||
expect(s1.noAmmo).toBe(true)
|
||||
expect(s1.requiresAmmo).toBe(false)
|
||||
|
||||
// Level 10: 14% conversion, 12 flat magic dmg, +91% AR, 0.375 mana (96 units)
|
||||
const s10 = calculateMagicArrowStats(10)
|
||||
expect(s10.conversionPct).toBe(14)
|
||||
expect(s10.flatMagicDamage).toBe(12)
|
||||
expect(s10.toHitBonusPct).toBe(91)
|
||||
expect(s10.manaCost).toBe(0.375)
|
||||
expect(s10.manaCost256).toBe(96)
|
||||
|
||||
// Level 20: 24% conversion, 36 flat magic dmg, +181% AR, 0 mana
|
||||
const s20 = calculateMagicArrowStats(20)
|
||||
expect(s20.conversionPct).toBe(24)
|
||||
expect(s20.flatMagicDamage).toBe(36)
|
||||
expect(s20.toHitBonusPct).toBe(181)
|
||||
expect(s20.manaCost).toBe(0)
|
||||
expect(s20.manaCost256).toBe(0)
|
||||
|
||||
// Level 96+: capped at 100% conversion
|
||||
const s96 = calculateMagicArrowStats(96)
|
||||
expect(s96.conversionPct).toBe(100)
|
||||
|
||||
// Monotonicity checks
|
||||
for (let lvl = 1; lvl < 30; lvl++) {
|
||||
const cur = calculateMagicArrowStats(lvl)
|
||||
const nxt = calculateMagicArrowStats(lvl + 1)
|
||||
expect(nxt.conversionPct).toBeGreaterThanOrEqual(cur.conversionPct)
|
||||
expect(nxt.flatMagicDamage).toBeGreaterThanOrEqual(cur.flatMagicDamage)
|
||||
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
|
||||
expect(nxt.manaCost).toBeLessThanOrEqual(cur.manaCost)
|
||||
}
|
||||
|
||||
// Boundary resilience: 0, negative, NaN, fractional
|
||||
const sZero = calculateMagicArrowStats(0)
|
||||
expect(sZero.conversionPct).toBe(5)
|
||||
expect(sZero.manaCost).toBe(1.5)
|
||||
|
||||
const sNeg = calculateMagicArrowStats(-10)
|
||||
expect(sNeg.conversionPct).toBe(5)
|
||||
|
||||
const sNan = calculateMagicArrowStats(NaN)
|
||||
expect(sNan.conversionPct).toBe(5)
|
||||
|
||||
const sFrac = calculateMagicArrowStats(5.7)
|
||||
expect(sFrac.conversionPct).toBe(9)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-007-fire-arrow.ts'
|
||||
import { calculateFireArrowStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #007] Fire Arrow (AMA) — 1.13c Parity (Issue #161)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,60 @@ describe('[Skill #007] Fire Arrow (AMA) — 1.13c Parity (Issue #161)', () => {
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateFireArrowStats pure calculations and boundary invariants', () => {
|
||||
// Level 1: 80% conversion, 1-4 fire dmg, +10% AR, 3.0 mana
|
||||
const s1 = calculateFireArrowStats(1)
|
||||
expect(s1.physToElemPct).toBe(80)
|
||||
expect(s1.srcDam).toBe(80)
|
||||
expect(s1.minDamage).toBe(1)
|
||||
expect(s1.maxDamage).toBe(4)
|
||||
expect(s1.toHitBonusPct).toBe(10)
|
||||
expect(s1.manaCost).toBe(3.0)
|
||||
|
||||
// Level 10: 21-24 fire dmg, +91% AR, 5.25 mana
|
||||
const s10 = calculateFireArrowStats(10)
|
||||
expect(s10.minDamage).toBe(21)
|
||||
expect(s10.maxDamage).toBe(24)
|
||||
expect(s10.toHitBonusPct).toBe(91)
|
||||
expect(s10.manaCost).toBe(5.25)
|
||||
|
||||
// Level 20: 63-70 fire dmg, +181% AR, 7.75 mana
|
||||
const s20 = calculateFireArrowStats(20)
|
||||
expect(s20.minDamage).toBe(63)
|
||||
expect(s20.maxDamage).toBe(70)
|
||||
expect(s20.toHitBonusPct).toBe(181)
|
||||
expect(s20.manaCost).toBe(7.75)
|
||||
|
||||
// Synergy test: Exploding Arrow +12%/level
|
||||
const s20Syn = calculateFireArrowStats(20, { explodingArrow: 20 })
|
||||
expect(s20Syn.synergyBonusPct).toBe(240)
|
||||
expect(s20Syn.synergyMultiplier).toBe(3.4)
|
||||
expect(s20Syn.minDamage).toBe(Math.floor(63 * 3.4))
|
||||
expect(s20Syn.maxDamage).toBe(Math.floor(70 * 3.4))
|
||||
|
||||
// Monotonicity checks
|
||||
for (let lvl = 1; lvl < 30; lvl++) {
|
||||
const cur = calculateFireArrowStats(lvl)
|
||||
const nxt = calculateFireArrowStats(lvl + 1)
|
||||
expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage)
|
||||
expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage)
|
||||
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
|
||||
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
|
||||
}
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateFireArrowStats(0)
|
||||
expect(sZero.minDamage).toBe(1)
|
||||
expect(sZero.manaCost).toBe(3.0)
|
||||
|
||||
const sNeg = calculateFireArrowStats(-5)
|
||||
expect(sNeg.minDamage).toBe(1)
|
||||
|
||||
const sNan = calculateFireArrowStats(NaN)
|
||||
expect(sNan.minDamage).toBe(1)
|
||||
|
||||
const sFrac = calculateFireArrowStats(4.8)
|
||||
expect(sFrac.minDamage).toBe(calculateFireArrowStats(4).minDamage)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-011-cold-arrow.ts'
|
||||
import { calculateColdArrowStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #011] Cold Arrow (AMA) — 1.13c Parity (Issue #165)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,64 @@ describe('[Skill #011] Cold Arrow (AMA) — 1.13c Parity (Issue #165)', () => {
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateColdArrowStats pure calculations and boundary invariants', () => {
|
||||
// Level 1: 80% conversion, 3-4 cold dmg, 50 frames chill duration, +10% AR, 3.5 mana
|
||||
const s1 = calculateColdArrowStats(1)
|
||||
expect(s1.physToElemPct).toBe(80)
|
||||
expect(s1.srcDam).toBe(80)
|
||||
expect(s1.minDamage).toBe(3)
|
||||
expect(s1.maxDamage).toBe(4)
|
||||
expect(s1.durationFrames).toBe(50)
|
||||
expect(s1.toHitBonusPct).toBe(10)
|
||||
expect(s1.manaCost).toBe(3.5)
|
||||
|
||||
// Level 10: 22-23 cold dmg, 140 frames chill duration, +91% AR, 5.75 mana
|
||||
const s10 = calculateColdArrowStats(10)
|
||||
expect(s10.minDamage).toBe(22)
|
||||
expect(s10.maxDamage).toBe(23)
|
||||
expect(s10.durationFrames).toBe(140)
|
||||
expect(s10.toHitBonusPct).toBe(91)
|
||||
expect(s10.manaCost).toBe(5.75)
|
||||
|
||||
// Level 20: 53-56 cold dmg, 240 frames chill duration, +181% AR, 8.25 mana
|
||||
const s20 = calculateColdArrowStats(20)
|
||||
expect(s20.minDamage).toBe(53)
|
||||
expect(s20.maxDamage).toBe(56)
|
||||
expect(s20.durationFrames).toBe(240)
|
||||
expect(s20.toHitBonusPct).toBe(181)
|
||||
expect(s20.manaCost).toBe(8.25)
|
||||
|
||||
// Synergy test: Ice Arrow +12%/level
|
||||
const s20Syn = calculateColdArrowStats(20, { iceArrow: 20 })
|
||||
expect(s20Syn.synergyBonusPct).toBe(240)
|
||||
expect(s20Syn.synergyMultiplier).toBe(3.4)
|
||||
expect(s20Syn.minDamage).toBe(Math.floor(53 * 3.4))
|
||||
expect(s20Syn.maxDamage).toBe(Math.floor(56 * 3.4))
|
||||
|
||||
// Monotonicity checks
|
||||
for (let lvl = 1; lvl < 30; lvl++) {
|
||||
const cur = calculateColdArrowStats(lvl)
|
||||
const nxt = calculateColdArrowStats(lvl + 1)
|
||||
expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage)
|
||||
expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage)
|
||||
expect(nxt.durationFrames).toBeGreaterThan(cur.durationFrames)
|
||||
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
|
||||
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
|
||||
}
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateColdArrowStats(0)
|
||||
expect(sZero.minDamage).toBe(3)
|
||||
expect(sZero.manaCost).toBe(3.5)
|
||||
|
||||
const sNeg = calculateColdArrowStats(-1)
|
||||
expect(sNeg.minDamage).toBe(3)
|
||||
|
||||
const sNan = calculateColdArrowStats(NaN)
|
||||
expect(sNan.minDamage).toBe(3)
|
||||
|
||||
const sFrac = calculateColdArrowStats(7.2)
|
||||
expect(sFrac.minDamage).toBe(calculateColdArrowStats(7).minDamage)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-012-multiple-shot.ts'
|
||||
import {
|
||||
calculateMultipleShotStats,
|
||||
generateMultipleShotAngles,
|
||||
isMultipleShotCenterArrow,
|
||||
} from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #012] Multiple Shot (AMA) — 1.13c Parity (Issue #166)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +111,60 @@ describe('[Skill #012] Multiple Shot (AMA) — 1.13c Parity (Issue #166)', () =>
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateMultipleShotStats pure calculations and kinematics invariants', () => {
|
||||
// Level 1: 3 arrows, 75% weapon damage (srcdam=96), 4 mana
|
||||
const s1 = calculateMultipleShotStats(1)
|
||||
expect(s1.arrowCount).toBe(3)
|
||||
expect(s1.damageMultiplier).toBe(0.75)
|
||||
expect(s1.srcDam).toBe(96)
|
||||
expect(s1.centerProcArrows).toBe(2)
|
||||
expect(s1.onlyCenterArrowsProc).toBe(true)
|
||||
expect(s1.singleTargetImmunity).toBe(true)
|
||||
expect(s1.manaCost).toBe(4)
|
||||
|
||||
// Level 10: 12 arrows, 13 mana
|
||||
const s10 = calculateMultipleShotStats(10)
|
||||
expect(s10.arrowCount).toBe(12)
|
||||
expect(s10.manaCost).toBe(13)
|
||||
|
||||
// Level 20: 22 arrows, 23 mana
|
||||
const s20 = calculateMultipleShotStats(20)
|
||||
expect(s20.arrowCount).toBe(22)
|
||||
expect(s20.manaCost).toBe(23)
|
||||
|
||||
// Level 22+: capped at 24 arrows
|
||||
const s22 = calculateMultipleShotStats(22)
|
||||
expect(s22.arrowCount).toBe(24)
|
||||
const s30 = calculateMultipleShotStats(30)
|
||||
expect(s30.arrowCount).toBe(24)
|
||||
|
||||
// Proc flag: only center 2 arrows proc
|
||||
// For 4 arrows (0, 1, 2, 3): mid1=1, mid2=2
|
||||
expect(isMultipleShotCenterArrow(0, 4)).toBe(false)
|
||||
expect(isMultipleShotCenterArrow(1, 4)).toBe(true)
|
||||
expect(isMultipleShotCenterArrow(2, 4)).toBe(true)
|
||||
expect(isMultipleShotCenterArrow(3, 4)).toBe(false)
|
||||
|
||||
// Symmetrical fan angles test
|
||||
const angles = generateMultipleShotAngles(0, 5, Math.PI / 4)
|
||||
expect(angles.length).toBe(5)
|
||||
expect(angles[0]).toBeCloseTo(-Math.PI / 8)
|
||||
expect(angles[2]).toBeCloseTo(0)
|
||||
expect(angles[4]).toBeCloseTo(Math.PI / 8)
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateMultipleShotStats(0)
|
||||
expect(sZero.arrowCount).toBe(3)
|
||||
expect(sZero.manaCost).toBe(4)
|
||||
|
||||
const sNeg = calculateMultipleShotStats(-5)
|
||||
expect(sNeg.arrowCount).toBe(3)
|
||||
|
||||
const sNan = calculateMultipleShotStats(NaN)
|
||||
expect(sNan.arrowCount).toBe(3)
|
||||
|
||||
const sFrac = calculateMultipleShotStats(3.9)
|
||||
expect(sFrac.arrowCount).toBe(5)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-016-exploding-arrow.ts'
|
||||
import { calculateExplodingArrowStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #016] Exploding Arrow (AMA) — 1.13c Parity (Issue #170)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,59 @@ describe('[Skill #016] Exploding Arrow (AMA) — 1.13c Parity (Issue #170)', ()
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateExplodingArrowStats pure calculations and AoE invariants', () => {
|
||||
// Level 1: 2-6 blast fire dmg, 80 sub-pixel AoE, +20% AR, 5.0 mana
|
||||
const s1 = calculateExplodingArrowStats(1)
|
||||
expect(s1.minBlastDamage).toBe(2)
|
||||
expect(s1.maxBlastDamage).toBe(6)
|
||||
expect(s1.radiusPx).toBe(80)
|
||||
expect(s1.toHitBonusPct).toBe(20)
|
||||
expect(s1.manaCost).toBe(5.0)
|
||||
|
||||
// Level 10: 51-57 blast fire dmg, +101% AR, 9.5 mana
|
||||
const s10 = calculateExplodingArrowStats(10)
|
||||
expect(s10.minBlastDamage).toBe(51)
|
||||
expect(s10.maxBlastDamage).toBe(57)
|
||||
expect(s10.toHitBonusPct).toBe(101)
|
||||
expect(s10.manaCost).toBe(9.5)
|
||||
|
||||
// Level 20: 129-149 blast fire dmg, +191% AR, 14.5 mana
|
||||
const s20 = calculateExplodingArrowStats(20)
|
||||
expect(s20.minBlastDamage).toBe(129)
|
||||
expect(s20.maxBlastDamage).toBe(149)
|
||||
expect(s20.toHitBonusPct).toBe(191)
|
||||
expect(s20.manaCost).toBe(14.5)
|
||||
|
||||
// Synergy test: Fire Arrow +12%/level
|
||||
const s20Syn = calculateExplodingArrowStats(20, { fireArrow: 20 })
|
||||
expect(s20Syn.synergyBonusPct).toBe(240)
|
||||
expect(s20Syn.synergyMultiplier).toBe(3.4)
|
||||
expect(s20Syn.minBlastDamage).toBe(Math.floor(129 * 3.4))
|
||||
expect(s20Syn.maxBlastDamage).toBe(Math.floor(149 * 3.4))
|
||||
|
||||
// Monotonicity checks
|
||||
for (let lvl = 1; lvl < 30; lvl++) {
|
||||
const cur = calculateExplodingArrowStats(lvl)
|
||||
const nxt = calculateExplodingArrowStats(lvl + 1)
|
||||
expect(nxt.minBlastDamage).toBeGreaterThanOrEqual(cur.minBlastDamage)
|
||||
expect(nxt.maxBlastDamage).toBeGreaterThanOrEqual(cur.maxBlastDamage)
|
||||
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
|
||||
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
|
||||
}
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateExplodingArrowStats(0)
|
||||
expect(sZero.minBlastDamage).toBe(2)
|
||||
expect(sZero.manaCost).toBe(5.0)
|
||||
|
||||
const sNeg = calculateExplodingArrowStats(-3)
|
||||
expect(sNeg.minBlastDamage).toBe(2)
|
||||
|
||||
const sNan = calculateExplodingArrowStats(NaN)
|
||||
expect(sNan.minBlastDamage).toBe(2)
|
||||
|
||||
const sFrac = calculateExplodingArrowStats(8.6)
|
||||
expect(sFrac.minBlastDamage).toBe(calculateExplodingArrowStats(8).minBlastDamage)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-021-ice-arrow.ts'
|
||||
import { calculateIceArrowStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #021] Ice Arrow (AMA) — 1.13c Parity (Issue #175)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,63 @@ describe('[Skill #021] Ice Arrow (AMA) — 1.13c Parity (Issue #175)', () => {
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateIceArrowStats pure calculations and freeze invariants', () => {
|
||||
// Level 1: 6-10 cold dmg, 50 frames freeze duration, +20% AR, 4.0 mana
|
||||
const s1 = calculateIceArrowStats(1)
|
||||
expect(s1.minDamage).toBe(6)
|
||||
expect(s1.maxDamage).toBe(10)
|
||||
expect(s1.freezeFrames).toBe(50)
|
||||
expect(s1.toHitBonusPct).toBe(20)
|
||||
expect(s1.manaCost).toBe(4.0)
|
||||
|
||||
// Level 10: 72-78 cold dmg, 95 frames freeze duration, +101% AR, 6.25 mana
|
||||
const s10 = calculateIceArrowStats(10)
|
||||
expect(s10.minDamage).toBe(72)
|
||||
expect(s10.maxDamage).toBe(78)
|
||||
expect(s10.freezeFrames).toBe(95)
|
||||
expect(s10.toHitBonusPct).toBe(101)
|
||||
expect(s10.manaCost).toBe(6.25)
|
||||
|
||||
// Level 20: 216-232 cold dmg, 145 frames freeze duration, +191% AR, 8.75 mana
|
||||
const s20 = calculateIceArrowStats(20)
|
||||
expect(s20.minDamage).toBe(216)
|
||||
expect(s20.maxDamage).toBe(232)
|
||||
expect(s20.freezeFrames).toBe(145)
|
||||
expect(s20.toHitBonusPct).toBe(191)
|
||||
expect(s20.manaCost).toBe(8.75)
|
||||
|
||||
// Synergies test: Cold Arrow +8% damage/lvl, Freezing Arrow +5% freeze length/lvl
|
||||
const s20Syn = calculateIceArrowStats(20, { coldArrow: 20, freezingArrow: 20 })
|
||||
expect(s20Syn.dmgSynergyMultiplier).toBe(2.6) // 1 + 20 * 0.08
|
||||
expect(s20Syn.lenSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05
|
||||
expect(s20Syn.minDamage).toBe(Math.floor(216 * 2.6))
|
||||
expect(s20Syn.maxDamage).toBe(Math.floor(232 * 2.6))
|
||||
expect(s20Syn.freezeFrames).toBe(Math.floor(145 * 2.0))
|
||||
|
||||
// Monotonicity checks
|
||||
for (let lvl = 1; lvl < 30; lvl++) {
|
||||
const cur = calculateIceArrowStats(lvl)
|
||||
const nxt = calculateIceArrowStats(lvl + 1)
|
||||
expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage)
|
||||
expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage)
|
||||
expect(nxt.freezeFrames).toBeGreaterThan(cur.freezeFrames)
|
||||
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
|
||||
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
|
||||
}
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateIceArrowStats(0)
|
||||
expect(sZero.minDamage).toBe(6)
|
||||
expect(sZero.manaCost).toBe(4.0)
|
||||
|
||||
const sNeg = calculateIceArrowStats(-2)
|
||||
expect(sNeg.minDamage).toBe(6)
|
||||
|
||||
const sNan = calculateIceArrowStats(NaN)
|
||||
expect(sNan.minDamage).toBe(6)
|
||||
|
||||
const sFrac = calculateIceArrowStats(9.4)
|
||||
expect(sFrac.minDamage).toBe(calculateIceArrowStats(9).minDamage)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-022-guided-arrow.ts'
|
||||
import { calculateGuidedArrowStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #022] Guided Arrow (AMA) — 1.13c Parity (Issue #176)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,57 @@ describe('[Skill #022] Guided Arrow (AMA) — 1.13c Parity (Issue #176)', () =>
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateGuidedArrowStats pure calculations and zero-pierce invariant', () => {
|
||||
// Level 1: +0% ED, alwaysHits = true, canPierce = false, pierceChancePct = 0, 8.0 mana
|
||||
const s1 = calculateGuidedArrowStats(1)
|
||||
expect(s1.enhancedDamagePct).toBe(0)
|
||||
expect(s1.alwaysHits).toBe(true)
|
||||
expect(s1.autoHit).toBe(true)
|
||||
expect(s1.canPierce).toBe(false)
|
||||
expect(s1.pierceChancePct).toBe(0)
|
||||
expect(s1.manaCost).toBe(8.0)
|
||||
|
||||
// Level 10: +45% ED, 5.75 mana
|
||||
const s10 = calculateGuidedArrowStats(10)
|
||||
expect(s10.enhancedDamagePct).toBe(45)
|
||||
expect(s10.alwaysHits).toBe(true)
|
||||
expect(s10.canPierce).toBe(false)
|
||||
expect(s10.manaCost).toBe(5.75)
|
||||
|
||||
// Level 20: +95% ED, 3.25 mana
|
||||
const s20 = calculateGuidedArrowStats(20)
|
||||
expect(s20.enhancedDamagePct).toBe(95)
|
||||
expect(s20.alwaysHits).toBe(true)
|
||||
expect(s20.canPierce).toBe(false)
|
||||
expect(s20.manaCost).toBe(3.25)
|
||||
|
||||
// High level clamp: mana floor is 1.75
|
||||
const s30 = calculateGuidedArrowStats(30)
|
||||
expect(s30.manaCost).toBe(1.75)
|
||||
const s50 = calculateGuidedArrowStats(50)
|
||||
expect(s50.manaCost).toBe(1.75)
|
||||
|
||||
// Strict zero-pierce invariant at all levels
|
||||
for (let lvl = 1; lvl <= 40; lvl++) {
|
||||
const s = calculateGuidedArrowStats(lvl)
|
||||
expect(s.canPierce).toBe(false)
|
||||
expect(s.pierceChancePct).toBe(0)
|
||||
expect(s.alwaysHits).toBe(true)
|
||||
}
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateGuidedArrowStats(0)
|
||||
expect(sZero.enhancedDamagePct).toBe(0)
|
||||
expect(sZero.manaCost).toBe(8.0)
|
||||
|
||||
const sNeg = calculateGuidedArrowStats(-10)
|
||||
expect(sNeg.enhancedDamagePct).toBe(0)
|
||||
|
||||
const sNan = calculateGuidedArrowStats(NaN)
|
||||
expect(sNan.enhancedDamagePct).toBe(0)
|
||||
|
||||
const sFrac = calculateGuidedArrowStats(6.5)
|
||||
expect(sFrac.enhancedDamagePct).toBe(calculateGuidedArrowStats(6).enhancedDamagePct)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-026-strafe.ts'
|
||||
import { calculateStrafeStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #026] Strafe (AMA) — 1.13c Parity (Issue #180)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,53 @@ describe('[Skill #026] Strafe (AMA) — 1.13c Parity (Issue #180)', () => {
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateStrafeStats pure calculations and burst invariants', () => {
|
||||
// Level 1: 4 arrows, 75% weapon damage (srcdam=96), +0% ED, stationary lock-in, 11 mana
|
||||
const s1 = calculateStrafeStats(1)
|
||||
expect(s1.arrowCount).toBe(4)
|
||||
expect(s1.damageMultiplier).toBe(0.75)
|
||||
expect(s1.srcDam).toBe(96)
|
||||
expect(s1.enhancedDamagePct).toBe(0)
|
||||
expect(s1.stationaryLockIn).toBe(true)
|
||||
expect(s1.lockInFrames).toBe(12) // 4 * 2 + 4
|
||||
expect(s1.manaCost).toBe(11)
|
||||
|
||||
// Level 2: 5 arrows
|
||||
const s2 = calculateStrafeStats(2)
|
||||
expect(s2.arrowCount).toBe(5)
|
||||
|
||||
// Level 10: 9 arrows, +45% ED, 11 mana
|
||||
const s10 = calculateStrafeStats(10)
|
||||
expect(s10.arrowCount).toBe(9)
|
||||
expect(s10.enhancedDamagePct).toBe(45)
|
||||
expect(s10.manaCost).toBe(11)
|
||||
|
||||
// Level 12+: capped at 10 arrows
|
||||
const s12 = calculateStrafeStats(12)
|
||||
expect(s12.arrowCount).toBe(10)
|
||||
const s20 = calculateStrafeStats(20)
|
||||
expect(s20.arrowCount).toBe(10)
|
||||
expect(s20.enhancedDamagePct).toBe(95)
|
||||
expect(s20.manaCost).toBe(11)
|
||||
|
||||
// Fixed mana cost invariant
|
||||
for (let lvl = 1; lvl <= 30; lvl++) {
|
||||
expect(calculateStrafeStats(lvl).manaCost).toBe(11)
|
||||
}
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateStrafeStats(0)
|
||||
expect(sZero.arrowCount).toBe(4)
|
||||
expect(sZero.manaCost).toBe(11)
|
||||
|
||||
const sNeg = calculateStrafeStats(-5)
|
||||
expect(sNeg.arrowCount).toBe(4)
|
||||
|
||||
const sNan = calculateStrafeStats(NaN)
|
||||
expect(sNan.arrowCount).toBe(4)
|
||||
|
||||
const sFrac = calculateStrafeStats(7.9)
|
||||
expect(sFrac.arrowCount).toBe(calculateStrafeStats(7).arrowCount)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-027-immolation-arrow.ts'
|
||||
import { calculateImmolationArrowStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #027] Immolation Arrow (AMA) — 1.13c Parity (Issue #181)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,66 @@ describe('[Skill #027] Immolation Arrow (AMA) — 1.13c Parity (Issue #181)', ()
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateImmolationArrowStats pure calculations, cooldown, and fire patch invariants', () => {
|
||||
// Level 1: 12-23 explosion fire dmg, 75 frames ground fire, 25 frames delay, +30% AR, 6.0 mana
|
||||
const s1 = calculateImmolationArrowStats(1)
|
||||
expect(s1.minExplosionDamage).toBe(12)
|
||||
expect(s1.maxExplosionDamage).toBe(23)
|
||||
expect(s1.firePatchDurationFrames).toBe(75)
|
||||
expect(s1.cooldownFrames).toBe(25)
|
||||
expect(s1.toHitBonusPct).toBe(30)
|
||||
expect(s1.manaCost).toBe(6.0)
|
||||
|
||||
// Level 10: 142-153 explosion fire dmg, +111% AR, 10.5 mana
|
||||
const s10 = calculateImmolationArrowStats(10)
|
||||
expect(s10.minExplosionDamage).toBe(142)
|
||||
expect(s10.maxExplosionDamage).toBe(153)
|
||||
expect(s10.firePatchDurationFrames).toBe(75)
|
||||
expect(s10.cooldownFrames).toBe(25)
|
||||
expect(s10.toHitBonusPct).toBe(111)
|
||||
expect(s10.manaCost).toBe(10.5)
|
||||
|
||||
// Level 20: 416-427 explosion fire dmg, +201% AR, 15.5 mana
|
||||
const s20 = calculateImmolationArrowStats(20)
|
||||
expect(s20.minExplosionDamage).toBe(416)
|
||||
expect(s20.maxExplosionDamage).toBe(427)
|
||||
expect(s20.firePatchDurationFrames).toBe(75)
|
||||
expect(s20.cooldownFrames).toBe(25)
|
||||
expect(s20.toHitBonusPct).toBe(201)
|
||||
expect(s20.manaCost).toBe(15.5)
|
||||
|
||||
// Synergies: Fire Arrow (+5% explosion dmg, +10% patch dmg per level)
|
||||
const s20Syn = calculateImmolationArrowStats(20, { fireArrow: 20 })
|
||||
expect(s20Syn.explosionSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05
|
||||
expect(s20Syn.firePatchSynergyMultiplier).toBe(3.0) // 1 + 20 * 0.10
|
||||
expect(s20Syn.minExplosionDamage).toBe(Math.floor(416 * 2.0))
|
||||
expect(s20Syn.maxExplosionDamage).toBe(Math.floor(427 * 2.0))
|
||||
|
||||
// Monotonicity checks
|
||||
for (let lvl = 1; lvl < 30; lvl++) {
|
||||
const cur = calculateImmolationArrowStats(lvl)
|
||||
const nxt = calculateImmolationArrowStats(lvl + 1)
|
||||
expect(nxt.minExplosionDamage).toBeGreaterThanOrEqual(cur.minExplosionDamage)
|
||||
expect(nxt.maxExplosionDamage).toBeGreaterThanOrEqual(cur.maxExplosionDamage)
|
||||
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
|
||||
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
|
||||
expect(nxt.cooldownFrames).toBe(25)
|
||||
expect(nxt.firePatchDurationFrames).toBe(75)
|
||||
}
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateImmolationArrowStats(0)
|
||||
expect(sZero.minExplosionDamage).toBe(12)
|
||||
expect(sZero.manaCost).toBe(6.0)
|
||||
|
||||
const sNeg = calculateImmolationArrowStats(-4)
|
||||
expect(sNeg.minExplosionDamage).toBe(12)
|
||||
|
||||
const sNan = calculateImmolationArrowStats(NaN)
|
||||
expect(sNan.minExplosionDamage).toBe(12)
|
||||
|
||||
const sFrac = calculateImmolationArrowStats(11.3)
|
||||
expect(sFrac.minExplosionDamage).toBe(calculateImmolationArrowStats(11).minExplosionDamage)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts
|
|||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/ama/skill-031-freezing-arrow.ts'
|
||||
import { calculateFreezingArrowStats } from '../../../src/game/skills/amazon-bow.ts'
|
||||
|
||||
describe('[Skill #031] Freezing Arrow (AMA) — 1.13c Parity (Issue #185)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
|
|
@ -106,4 +107,64 @@ describe('[Skill #031] Freezing Arrow (AMA) — 1.13c Parity (Issue #185)', () =
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('validates 1.13c calculateFreezingArrowStats pure calculations, splash, and freeze invariants', () => {
|
||||
// Level 1: 40-50 cold dmg, 80 sub-pixel AoE, 50 frames freeze duration, +40% AR, 9.0 mana
|
||||
const s1 = calculateFreezingArrowStats(1)
|
||||
expect(s1.minColdDamage).toBe(40)
|
||||
expect(s1.maxColdDamage).toBe(50)
|
||||
expect(s1.radiusPx).toBe(80)
|
||||
expect(s1.freezeFrames).toBe(50)
|
||||
expect(s1.toHitBonusPct).toBe(40)
|
||||
expect(s1.manaCost).toBe(9.0)
|
||||
|
||||
// Level 10: 140-150 cold dmg, +121% AR, 13.5 mana
|
||||
const s10 = calculateFreezingArrowStats(10)
|
||||
expect(s10.minColdDamage).toBe(140)
|
||||
expect(s10.maxColdDamage).toBe(150)
|
||||
expect(s10.freezeFrames).toBe(50)
|
||||
expect(s10.toHitBonusPct).toBe(121)
|
||||
expect(s10.manaCost).toBe(13.5)
|
||||
|
||||
// Level 20: 310-320 cold dmg, +211% AR, 18.5 mana
|
||||
const s20 = calculateFreezingArrowStats(20)
|
||||
expect(s20.minColdDamage).toBe(310)
|
||||
expect(s20.maxColdDamage).toBe(320)
|
||||
expect(s20.freezeFrames).toBe(50)
|
||||
expect(s20.toHitBonusPct).toBe(211)
|
||||
expect(s20.manaCost).toBe(18.5)
|
||||
|
||||
// Synergies: Cold Arrow (+12% cold damage/lvl), Ice Arrow (+5% freeze length/lvl)
|
||||
const s20Syn = calculateFreezingArrowStats(20, { coldArrow: 20, iceArrow: 20 })
|
||||
expect(s20Syn.coldSynergyMultiplier).toBe(3.4) // 1 + 20 * 0.12
|
||||
expect(s20Syn.lenSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05
|
||||
expect(s20Syn.minColdDamage).toBe(Math.floor(310 * 3.4))
|
||||
expect(s20Syn.maxColdDamage).toBe(Math.floor(320 * 3.4))
|
||||
expect(s20Syn.freezeFrames).toBe(Math.floor(50 * 2.0))
|
||||
|
||||
// Monotonicity checks
|
||||
for (let lvl = 1; lvl < 30; lvl++) {
|
||||
const cur = calculateFreezingArrowStats(lvl)
|
||||
const nxt = calculateFreezingArrowStats(lvl + 1)
|
||||
expect(nxt.minColdDamage).toBeGreaterThanOrEqual(cur.minColdDamage)
|
||||
expect(nxt.maxColdDamage).toBeGreaterThanOrEqual(cur.maxColdDamage)
|
||||
expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct)
|
||||
expect(nxt.manaCost).toBeGreaterThan(cur.manaCost)
|
||||
expect(nxt.radiusPx).toBe(80)
|
||||
}
|
||||
|
||||
// Boundary resilience
|
||||
const sZero = calculateFreezingArrowStats(0)
|
||||
expect(sZero.minColdDamage).toBe(40)
|
||||
expect(sZero.manaCost).toBe(9.0)
|
||||
|
||||
const sNeg = calculateFreezingArrowStats(-7)
|
||||
expect(sNeg.minColdDamage).toBe(40)
|
||||
|
||||
const sNan = calculateFreezingArrowStats(NaN)
|
||||
expect(sNan.minColdDamage).toBe(40)
|
||||
|
||||
const sFrac = calculateFreezingArrowStats(15.8)
|
||||
expect(sFrac.minColdDamage).toBe(calculateFreezingArrowStats(15).minColdDamage)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue