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