diff --git a/src/game/engine/combat-pipeline.ts b/src/game/engine/combat-pipeline.ts index af33f0c..b8d39ec 100644 --- a/src/game/engine/combat-pipeline.ts +++ b/src/game/engine/combat-pipeline.ts @@ -323,6 +323,7 @@ export interface SUnitDmgPacket { readonly elemMin256?: number readonly elemMax256?: number readonly elemDurationFrames?: number + readonly slvl?: number readonly physToElemPct?: number // e.g. Berserk 100% magic, Magic Arrow / Fire Arrow % readonly concentrationBonusPct?: number // active Concentration aura %ED for Blessed Hammer (50% efficiency) readonly convictionPierce?: number @@ -460,7 +461,15 @@ export function executeSUnitDmg( // 3. Weapon `srcdam` (128 = 100%) + Physical Damage calculation const hasFlatPhys = (packet.flatPhysMin256 !== undefined && packet.flatPhysMin256 > 0) || (packet.flatPhysMax256 !== undefined && packet.flatPhysMax256 > 0) const isSpellWithoutFlatPhys = attackKind === 'spell' && !hasFlatPhys - const srcDam = isSpellWithoutFlatPhys ? 0 : (packet.srcDam ?? (attackKind !== 'spell' ? 128 : 0)) + const srcDam = + isSpellWithoutFlatPhys + ? 0 + : (packet.srcDam ?? + (packet.skillId === 12 || packet.skillId === 26 + ? 96 + : attackKind !== 'spell' + ? 128 + : 0)) let rawPhys256 = 0 if (!isSpellWithoutFlatPhys && (srcDam > 0 || hasFlatPhys)) { const weapMin256 = (attacker.weaponMinPhys ?? 25) * FIXED_ONE @@ -517,7 +526,15 @@ export function executeSUnitDmg( // 4. Physical-to-Elemental/Magic Conversion (e.g. Berserk 100% Magic, Magic Arrow, Fire Arrow, Cold Arrow) let rawElem256 = Math.trunc(((packet.elemMin256 ?? 0) + (packet.elemMax256 ?? 0)) / 2) - const convPct = Math.max(0, Math.min(100, packet.physToElemPct ?? (packet.skillId === 152 ? 100 : 0))) + const defaultConvPct = + packet.skillId === 152 + ? 100 + : packet.skillId === 7 || packet.skillId === 11 + ? 80 + : packet.skillId === 6 + ? Math.min(100, 5 + Math.max(0, (packet.slvl ?? attacker.statList.getEffectiveSkillLevel(6) ?? 1) - 1)) + : 0 + const convPct = Math.max(0, Math.min(100, packet.physToElemPct ?? defaultConvPct)) if (convPct > 0 && rawPhys256 > 0) { const converted256 = Math.trunc((rawPhys256 * convPct) / 100) rawPhys256 -= converted256 @@ -525,7 +542,14 @@ export function executeSUnitDmg( } // 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field / Enchant rules - let elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase() + let elemType = ( + packet.elemType ?? + (packet.skillId === 6 || packet.skillId === 152 || packet.skillId === 112 + ? 'mag' + : packet.skillId === 11 || packet.skillId === 21 || packet.skillId === 31 + ? 'cold' + : 'fire') + ).toLowerCase() // Added elemental damage from Paladin offensive auras (Holy Fire, Holy Freeze, Holy Shock) on physical attacks if (attackKind !== 'spell' && packet.skillId !== 97) { diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 8a2a0c8..0b5d4e6 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -20,6 +20,14 @@ import { getBlizzardDropOffset } from './blizzard-table.ts' import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts' import { calculateMeteorResidualFireDamage } from '../skills.ts' import { compute5BandScaling, computeDiminishingReturns } from './calc-ast.ts' +import { + calculateMultipleShotStats, + calculateStrafeStats, + calculateIceArrowStats, + calculateFreezingArrowStats, + generateMultipleShotAngles, + isMultipleShotCenterArrow, +} from '../skills/amazon-bow.ts' export const ISO_GROUND_ASPECT_RATIO = 0.5 @@ -647,6 +655,8 @@ export interface ActiveMissile { originX?: number | undefined originY?: number | undefined spiralTheta?: number | undefined + readonly volleyId?: string | undefined + readonly canTriggerProcs?: boolean | undefined } export interface MissileTerrainCollision { @@ -668,6 +678,7 @@ export class MissileEngine { readonly registry: D2DataRegistry private readonly missiles: ActiveMissile[] = [] private readonly nextDelayExpireByTarget = new Map() + private readonly volleyHits = new Set() private uidCounter = 1 terrain?: MissileTerrainCollision | undefined @@ -704,6 +715,8 @@ export class MissileEngine { vx?: number vy?: number angleRad?: number + volleyId?: string + canTriggerProcs?: boolean }): ActiveMissile | null { const rec = typeof params.missileNameOrId === 'number' @@ -880,8 +893,18 @@ export class MissileEngine { maxRangeTicks, nextHit: effectiveRec.nextHit, nextDelayFrames: effectiveRec.nextDelay, - canPierce: effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear', - pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0), + canPierce: + params.sourceSkillId === 22 || + effectiveRec.name.toLowerCase() === 'guidedarrow' || + effectiveRec.id === 86 + ? false + : effectiveRec.pierce || !effectiveRec.collideKill || (params.pierceChancePct ?? 0) > 0 || effectiveRec.name === 'bonespear', + pierceChancePct: + params.sourceSkillId === 22 || + effectiveRec.name.toLowerCase() === 'guidedarrow' || + effectiveRec.id === 86 + ? 0 + : (params.pierceChancePct ?? (effectiveRec.pierce || !effectiveRec.collideKill || effectiveRec.name === 'bonespear' ? 100 : 0)), pierceCount: 0, hitTargetIds: new Set(), dmgPacket: params.dmgPacket, @@ -889,6 +912,8 @@ export class MissileEngine { lastHitTargetId: params.lastHitTargetId, expired: false, isTurret, + ...(params.volleyId !== undefined ? { volleyId: params.volleyId } : {}), + ...(params.canTriggerProcs !== undefined ? { canTriggerProcs: params.canTriggerProcs } : {}), ...(params.altitude !== undefined ? { altitude: params.altitude } : {}), ...(params.fireCadenceTicks !== undefined ? { fireCadenceTicks: params.fireCadenceTicks } @@ -1093,7 +1118,10 @@ export class MissileEngine { // Authentic 1.13c Homing mechanics (Bone Spirit / Guided Arrow, pSrvDoFunc = 7): // Continuously tracks and steers toward closest active hostile target in 2:1 isometric perspective if ( - (m.name.toLowerCase() === 'bonespirit' || m.record.pSrvDoFunc === 7) && + (m.name.toLowerCase() === 'bonespirit' || + m.name.toLowerCase() === 'guidedarrow' || + m.sourceSkillId === 22 || + m.record.pSrvDoFunc === 7) && !m.name.includes('explode') ) { let closestTarget: { x: number; y: number } | null = null @@ -1115,9 +1143,17 @@ export class MissileEngine { if (closestTarget) { const speed = m.record.vel ? m.record.vel : 12 const targetAngle = Math.atan2((closestTarget.y - m.y) / ISO_GROUND_ASPECT_RATIO, closestTarget.x - m.x) - m.angleRad = targetAngle - m.vx = Math.cos(targetAngle) * speed - m.vy = Math.sin(targetAngle) * speed * ISO_GROUND_ASPECT_RATIO + let diff = targetAngle - m.angleRad + while (diff < -Math.PI) diff += Math.PI * 2 + while (diff > Math.PI) diff -= Math.PI * 2 + const maxTurn = 0.45 + if (Math.abs(diff) <= maxTurn) { + m.angleRad = targetAngle + } else { + m.angleRad += Math.sign(diff) * maxTurn + } + m.vx = Math.cos(m.angleRad) * speed + m.vy = Math.sin(m.angleRad) * speed * ISO_GROUND_ASPECT_RATIO } } @@ -1258,6 +1294,7 @@ export class MissileEngine { // Check collision against alive enemy targets if ( (m.name === 'meteorfire' && m.ageTicks % 5 === 0) || + (m.name === 'immolationfire' && m.ageTicks % 5 === 0) || m.name === 'firewall' || m.name === 'blaze' || m.name === 'groundfirebig' || @@ -1317,6 +1354,7 @@ export class MissileEngine { const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 } const isGroundPatch = m.name === 'meteorfire' || + m.name === 'immolationfire' || m.name === 'firewall' || m.name === 'blaze' || m.name === 'groundfirebig' @@ -1324,6 +1362,21 @@ export class MissileEngine { ? Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO) : Math.hypot(pos.x - m.x, pos.y - m.y) if (dist <= hitRadius) { + // Multiple Shot volley multi-hit immunity + if (m.volleyId) { + const vKey = `${m.volleyId}:${target.id}` + if (this.volleyHits.has(vKey)) { + continue + } + this.volleyHits.add(vKey) + } + + // Guided Arrow strict zero-pierce enforcement + if (m.sourceSkillId === 22 || m.name.toLowerCase() === 'guidedarrow' || m.missileId === 86) { + ;(m as any).canPierce = false + ;(m as any).pierceChancePct = 0 + } + // Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0` if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) { continue @@ -1401,14 +1454,119 @@ export class MissileEngine { } } - // `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`) + // Ice Arrow (Skill 21 / missile icearrow): freezes primary target solid + if (m.name === 'icearrow' || m.sourceSkillId === 21) { + if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) { + const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateIceArrowStats(m.slvl).freezeFrames + target.stateBus.applyState({ + stateNameOrId: 'freeze', + sourceSkillId: 21, + slvl: m.slvl, + durationFrames: frzFrames, + }) + } + } + + // Freezing Arrow (Skill 31 / missile freezingarrow): freezes primary target solid + if (m.name === 'freezingarrow' || m.sourceSkillId === 31) { + if (!dmgOut.immuneToElem && !target.statList.getAccruedStat('cannot_be_frozen')) { + const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateFreezingArrowStats(m.slvl).freezeFrames + target.stateBus.applyState({ + stateNameOrId: 'freeze', + sourceSkillId: 31, + slvl: m.slvl, + durationFrames: frzFrames, + }) + } + } + + // Sub-missiles on hit for Amazon Bow skills (explodingarrow, icearrow, immolationarrow, freezingarrow) + if (m.name === 'explodingarrow' || m.sourceSkillId === 16) { + const expName = m.record.explosionMissile || 'explodingarrowexp' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } + + if (m.name === 'immolationarrow' || m.sourceSkillId === 27) { + const patchName = m.record.hitSubMissile1 || 'immolationfire' + this.spawnMissile({ + missileNameOrId: patchName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + maxRangeTicks: 75, + }) + subMissilesSpawned.push(patchName) + } + + if (m.name === 'freezingarrow' || m.sourceSkillId === 31) { + const expName = m.record.explosionMissile || 'freezingarrowexp' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } + + if (m.name === 'icearrow' || m.sourceSkillId === 21) { + const expName = m.record.explosionMissile || 'icearrowexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } + + // `pSrvHitFunc = 1` or `4` or `9` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Immolation Arrow`, `Glacial Spike`) if ( m.record.pSrvHitFunc === 1 || m.record.pSrvHitFunc === 4 || m.record.pSrvHitFunc === 9 || - m.record.pSrvHitFunc === 13 + m.record.pSrvHitFunc === 13 || + m.sourceSkillId === 16 || + m.sourceSkillId === 27 || + m.sourceSkillId === 31 || + m.name === 'explodingarrow' || + m.name === 'immolationarrow' || + m.name === 'freezingarrow' ) { - const splashRadius = (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55) ? 64 : 85 + const isBowAoE = + m.sourceSkillId === 16 || + m.sourceSkillId === 27 || + m.sourceSkillId === 31 || + m.name === 'explodingarrow' || + m.name === 'immolationarrow' || + m.name === 'freezingarrow' + const splashRadius = isBowAoE ? 80 : (m.record.pSrvHitFunc === 13 || m.name === 'glacialspike' || m.sourceSkillId === 55 ? 64 : 85) for (const other of targets) { if (other.id === target.id || other.statList.getHp256() <= 0) continue const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 } @@ -1420,6 +1578,18 @@ export class MissileEngine { damage: splashOut.totalDamage, immune: splashOut.immuneToElem || splashOut.immuneToPhys, }) + // Freezing Arrow splash targets also receive solid freeze + if (m.name === 'freezingarrow' || m.sourceSkillId === 31) { + if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) { + const frzFrames = m.dmgPacket.elemDurationFrames ?? calculateFreezingArrowStats(m.slvl).freezeFrames + other.stateBus.applyState({ + stateNameOrId: 'freeze', + sourceSkillId: 31, + slvl: m.slvl, + durationFrames: frzFrames, + }) + } + } // Glacial Spike splash targets also receive solid freeze if (m.name === 'glacialspike' || m.sourceSkillId === 55 || m.record.pSrvHitFunc === 13) { if (!splashOut.immuneToElem && !other.statList.getAccruedStat('cannot_be_frozen')) { @@ -1524,6 +1694,7 @@ export class MissileEngine { !m.name.endsWith('explode') if ( m.name === 'meteorfire' || + m.name === 'immolationfire' || m.name === 'firewall' || m.name === 'blaze' || m.name === 'groundfirebig' || @@ -1637,6 +1808,63 @@ export class MissileEngine { dmgPacket: m.dmgPacket, }) subMissilesSpawned.push(expName) + } else if (m.name === 'explodingarrow' || m.sourceSkillId === 16) { + const expName = m.record.explosionMissile || 'explodingarrowexp' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } else if (m.name === 'icearrow' || m.sourceSkillId === 21) { + const expName = m.record.explosionMissile || 'icearrowexplode' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) + } else if (m.name === 'immolationarrow' || m.sourceSkillId === 27) { + const patchName = m.record.hitSubMissile1 || 'immolationfire' + this.spawnMissile({ + missileNameOrId: patchName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + maxRangeTicks: 75, + }) + subMissilesSpawned.push(patchName) + } else if (m.name === 'freezingarrow' || m.sourceSkillId === 31) { + const expName = m.record.explosionMissile || 'freezingarrowexp' + this.spawnMissile({ + missileNameOrId: expName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x, + startY: m.y, + targetX: m.x, + targetY: m.y, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(expName) } m.expired = true break @@ -1845,6 +2073,132 @@ export class MissileEngine { return spawned } + /** + * Spawns authentic 1.13c Multiple Shot volley (Skill 12): + * - Arrow count: min(24, 2 + slvl) + * - Fan arc spread (~45-50 degrees) symmetric around aim vector + * - 75% weapon damage (srcDam = 96) + * - Center 2 arrows flagged to trigger procs; outer arrows cannot + * - Shared volleyId enforces single-target hit immunity + */ + spawnMultipleShotVolley(params: { + sourceSkillId?: number + slvl: number + owner: CombatUnitContext + startX: number + startY: number + targetX: number + targetY: number + dmgPacket: SUnitDmgPacket + pierceChancePct?: number + missileName?: string + }): ActiveMissile[] { + const stats = calculateMultipleShotStats(params.slvl) + const arrowCount = stats.arrowCount + const dx = params.targetX - params.startX + const dy = (params.targetY - params.startY) / ISO_GROUND_ASPECT_RATIO + const aimAngleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx) + const angles = generateMultipleShotAngles(aimAngleRad, arrowCount, (50 * Math.PI) / 180) + const volleyId = `ms-volley-${String(this.uidCounter++)}` + const missiles: ActiveMissile[] = [] + const missileName = params.missileName || 'arrow' + + for (let i = 0; i < arrowCount; i += 1) { + const angle = angles[i]! + const canTriggerProcs = isMultipleShotCenterArrow(i, arrowCount) + const m = this.spawnMissile({ + missileNameOrId: missileName, + sourceSkillId: params.sourceSkillId ?? 12, + slvl: params.slvl, + owner: params.owner, + startX: params.startX, + startY: params.startY, + targetX: params.startX + Math.cos(angle) * 200, + targetY: params.startY + Math.sin(angle) * 200 * ISO_GROUND_ASPECT_RATIO, + angleRad: angle, + dmgPacket: { + ...params.dmgPacket, + srcDam: 96, + }, + ...(params.pierceChancePct !== undefined ? { pierceChancePct: params.pierceChancePct } : {}), + volleyId, + canTriggerProcs, + }) + if (m) missiles.push(m) + } + return missiles + } + + /** + * Spawns authentic 1.13c Strafe sequence (Skill 26): + * - Arrow count: min(10, 4 + floor(slvl/2)) + * - Cycles target acquisition among alive nearby enemies + * - 75% weapon damage (srcDam = 96) + Enhanced Damage bonus (+5%/lvl) + * - Locks caster in stationary state for lockInFrames + */ + spawnStrafeVolley(params: { + sourceSkillId?: number + slvl: number + owner: CombatUnitContext + startX: number + startY: number + targets?: readonly CombatUnitContext[] + targetPositions?: ReadonlyMap + targetX?: number + targetY?: number + dmgPacket: SUnitDmgPacket + pierceChancePct?: number + missileName?: string + }): ActiveMissile[] { + const stats = calculateStrafeStats(params.slvl) + const arrowCount = stats.arrowCount + const aliveTargets = params.targets ? params.targets.filter(t => t.statList.getHp256() > 0) : [] + const missiles: ActiveMissile[] = [] + const missileName = params.missileName || 'arrow' + + // Strafe stationary lock-in state on player + params.owner.stateBus.applyState({ + stateNameOrId: 'strafe_lockin', + sourceSkillId: 26, + slvl: params.slvl, + durationFrames: stats.lockInFrames, + }) + + for (let i = 0; i < arrowCount; i += 1) { + let tX = params.targetX ?? params.startX + 200 + let tY = params.targetY ?? params.startY + if (aliveTargets.length > 0) { + const assignedTarget = aliveTargets[i % aliveTargets.length]! + const pos = params.targetPositions?.get(assignedTarget.id) ?? { x: tX, y: tY } + tX = pos.x + tY = pos.y + } + const dx = tX - params.startX + const dy = (tY - params.startY) / ISO_GROUND_ASPECT_RATIO + const angle = dx === 0 && dy === 0 ? 0 : Math.atan2(dy, dx) + const m = this.spawnMissile({ + missileNameOrId: missileName, + sourceSkillId: params.sourceSkillId ?? 26, + slvl: params.slvl, + owner: params.owner, + startX: params.startX, + startY: params.startY, + targetX: tX, + targetY: tY, + angleRad: angle, + dmgPacket: { + ...params.dmgPacket, + srcDam: 96, + physDamagePct: (params.dmgPacket.physDamagePct ?? 0) + stats.enhancedDamagePct, + }, + ...(params.pierceChancePct !== undefined ? { pierceChancePct: params.pierceChancePct } : {}), + canTriggerProcs: true, + }) + if (m) missiles.push(m) + } + return missiles + } + /** * Executes authentic 1.13c instantaneous 360° Static Field discharge with difficulty floors and LOS. * Cuts 25% current HP with Normal (1 HP), Nightmare (33% max HP), and Hell (50% max HP) pre-check clamps. diff --git a/src/game/skills.ts b/src/game/skills.ts index 9948384..eedb0f5 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -285,6 +285,54 @@ export { METEOR_FIRE_SUBTILE_OFFSETS, type MeteorFireSubtileOffset, } from './engine/meteor-table.ts' +import { + calculateMagicArrowStats, + calculateFireArrowStats, + calculateColdArrowStats, + calculateMultipleShotStats, + calculateExplodingArrowStats, + calculateIceArrowStats, + calculateGuidedArrowStats, + calculateStrafeStats, + calculateImmolationArrowStats, + calculateFreezingArrowStats, + generateMultipleShotAngles, + isMultipleShotCenterArrow, + type MagicArrowStats, + type FireArrowStats, + type ColdArrowStats, + type MultipleShotStats, + type ExplodingArrowStats, + type IceArrowStats, + type GuidedArrowStats, + type StrafeStats, + type ImmolationArrowStats, + type FreezingArrowStats, +} from './skills/amazon-bow.ts' +export { + calculateMagicArrowStats, + calculateFireArrowStats, + calculateColdArrowStats, + calculateMultipleShotStats, + calculateExplodingArrowStats, + calculateIceArrowStats, + calculateGuidedArrowStats, + calculateStrafeStats, + calculateImmolationArrowStats, + calculateFreezingArrowStats, + generateMultipleShotAngles, + isMultipleShotCenterArrow, + type MagicArrowStats, + type FireArrowStats, + type ColdArrowStats, + type MultipleShotStats, + type ExplodingArrowStats, + type IceArrowStats, + type GuidedArrowStats, + type StrafeStats, + type ImmolationArrowStats, + type FreezingArrowStats, +} from './skills/amazon-bow.ts' /** One castable skill, read from a table row. */ export interface SkillDef { @@ -5549,6 +5597,36 @@ export function isBatch1Skill(skillId: number): boolean { * - Resilience: Invalid / NaN / non-positive levels default to level 1. */ export function getSkillManaCost(skillId: number, effectiveLevel: number): number { + if (skillId === 6) { + return calculateMagicArrowStats(effectiveLevel).manaCost + } + if (skillId === 7) { + return calculateFireArrowStats(effectiveLevel).manaCost + } + if (skillId === 11) { + return calculateColdArrowStats(effectiveLevel).manaCost + } + if (skillId === 12) { + return calculateMultipleShotStats(effectiveLevel).manaCost + } + if (skillId === 16) { + return calculateExplodingArrowStats(effectiveLevel).manaCost + } + if (skillId === 21) { + return calculateIceArrowStats(effectiveLevel).manaCost + } + if (skillId === 22) { + return calculateGuidedArrowStats(effectiveLevel).manaCost + } + if (skillId === 26) { + return calculateStrafeStats(effectiveLevel).manaCost + } + if (skillId === 27) { + return calculateImmolationArrowStats(effectiveLevel).manaCost + } + if (skillId === 31) { + return calculateFreezingArrowStats(effectiveLevel).manaCost + } if (skillId === 38) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 24, lvlmana = 4, manashift = 5 (factor = 0.125) @@ -5682,6 +5760,15 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe */ export function getSkillCooldownTicks(skillId: number): number { if ( + skillId === 6 || + skillId === 7 || + skillId === 11 || + skillId === 12 || + skillId === 16 || + skillId === 21 || + skillId === 22 || + skillId === 26 || + skillId === 31 || skillId === 38 || skillId === 40 || skillId === 41 || @@ -5705,6 +5792,9 @@ export function getSkillCooldownTicks(skillId: number): number { ) { return 0 } + if (skillId === 27) { + return 25 // Immolation Arrow: 25 frames (1.0s) + } if (skillId === 51) { return 35 } @@ -5753,6 +5843,68 @@ export function calculateSkillDamage( }, weaponDamage?: { min: number; max: number } ): { min: number; max: number } { + if (skillId === 6 || skillId === '6' || skillId === 'magicarrow' || skillId === 'magic arrow') { + const stats = calculateMagicArrowStats(slvl) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: baseMin + stats.flatMagicDamage, max: baseMax + stats.flatMagicDamage } + } + if (skillId === 7 || skillId === '7' || skillId === 'firearrow' || skillId === 'fire arrow') { + const stats = calculateFireArrowStats(slvl, synergies as any) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage } + } + if (skillId === 11 || skillId === '11' || skillId === 'coldarrow' || skillId === 'cold arrow') { + const stats = calculateColdArrowStats(slvl, synergies as any) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage } + } + if (skillId === 12 || skillId === '12' || skillId === 'multipleshot' || skillId === 'multiple shot') { + const stats = calculateMultipleShotStats(slvl) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: Math.floor(baseMin * stats.damageMultiplier), max: Math.floor(baseMax * stats.damageMultiplier) } + } + if (skillId === 16 || skillId === '16' || skillId === 'explodingarrow' || skillId === 'exploding arrow') { + const stats = calculateExplodingArrowStats(slvl, synergies as any) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: baseMin + stats.minBlastDamage, max: baseMax + stats.maxBlastDamage } + } + if (skillId === 21 || skillId === '21' || skillId === 'icearrow' || skillId === 'ice arrow') { + const stats = calculateIceArrowStats(slvl, synergies as any) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage } + } + if (skillId === 22 || skillId === '22' || skillId === 'guidedarrow' || skillId === 'guided arrow') { + const stats = calculateGuidedArrowStats(slvl) + const ed = 1 + stats.enhancedDamagePct / 100 + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) } + } + if (skillId === 26 || skillId === '26' || skillId === 'strafe') { + const stats = calculateStrafeStats(slvl) + const mult = stats.damageMultiplier * (1 + stats.enhancedDamagePct / 100) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: Math.floor(baseMin * mult), max: Math.floor(baseMax * mult) } + } + if (skillId === 27 || skillId === '27' || skillId === 'immolationarrow' || skillId === 'immolation arrow') { + const stats = calculateImmolationArrowStats(slvl, synergies as any) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: baseMin + stats.minExplosionDamage, max: baseMax + stats.maxExplosionDamage } + } + if (skillId === 31 || skillId === '31' || skillId === 'freezingarrow' || skillId === 'freezing arrow') { + const stats = calculateFreezingArrowStats(slvl, synergies as any) + const baseMin = weaponDamage?.min ?? 0 + const baseMax = weaponDamage?.max ?? 0 + return { min: baseMin + stats.minColdDamage, max: baseMax + stats.maxColdDamage } + } if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') { return calculateChargedBoltDamage(slvl, synergies as any) } diff --git a/src/game/skills/amazon-bow.ts b/src/game/skills/amazon-bow.ts new file mode 100644 index 0000000..7aee9df --- /dev/null +++ b/src/game/skills/amazon-bow.ts @@ -0,0 +1,648 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Amazon Bow & Crossbow Skills Tree Module + * + * Dedicated pure calculation and kinematics formulas for the 10 bow/crossbow skills: + * - Skill 006: Magic Arrow + * - Skill 007: Fire Arrow + * - Skill 011: Cold Arrow + * - Skill 012: Multiple Shot + * - Skill 016: Exploding Arrow + * - Skill 021: Ice Arrow + * - Skill 022: Guided Arrow + * - Skill 026: Strafe + * - Skill 027: Immolation Arrow + * - Skill 031: Freezing Arrow + * + * 1.13c Ground Truth: + * - D2Common.dll / D2Game.dll / Patch_D2.mpq: Skills.txt & Missiles.txt + * - Fixed-point 24.8 mana calculations + * - 5-band scaling across levels 1, 2..8, 9..16, 17..22, 23..28, 29+ + * - Guided Arrow strict zero-pierce invariant (`canPierce = false`, `pierceChancePct = 0`) + * - Multiple Shot fan distribution, center-2 proc limitation, and single-target hit immunity + * - Immolation Arrow 25-frame casting cooldown and 75-frame ground fire patch + * - Freezing Arrow 80 sub-pixel cold splash and 50-frame freeze duration + */ + +import { compute5BandScaling } from '../engine/calc-ast.ts' + +// --- Interfaces for Skill Calculations --- + +export interface MagicArrowStats { + readonly conversionPct: number + readonly flatMagicDamage: number + readonly minDamage: number + readonly maxDamage: number + readonly toHitBonusPct: number + readonly manaCost: number + readonly manaCost256: number + readonly noAmmo: boolean + readonly requiresAmmo: boolean +} + +export interface FireArrowStats { + readonly minDamage: number + readonly maxDamage: number + readonly baseMinDamage: number + readonly baseMaxDamage: number + readonly physToElemPct: number + readonly srcDam: number + readonly toHitBonusPct: number + readonly synergyMultiplier: number + readonly synergyBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface ColdArrowStats { + readonly minDamage: number + readonly maxDamage: number + readonly baseMinDamage: number + readonly baseMaxDamage: number + readonly durationFrames: number + readonly physToElemPct: number + readonly srcDam: number + readonly toHitBonusPct: number + readonly synergyMultiplier: number + readonly synergyBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface MultipleShotStats { + readonly arrowCount: number + readonly damageMultiplier: number + readonly srcDam: number + readonly centerProcArrows: number + readonly onlyCenterArrowsProc: boolean + readonly singleTargetImmunity: boolean + readonly toHitBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface ExplodingArrowStats { + readonly minBlastDamage: number + readonly maxBlastDamage: number + readonly baseMinBlastDamage: number + readonly baseMaxBlastDamage: number + readonly minDamage: number + readonly maxDamage: number + readonly radiusPx: number + readonly radiusSubtiles: number + readonly toHitBonusPct: number + readonly synergyMultiplier: number + readonly synergyBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface IceArrowStats { + readonly minDamage: number + readonly maxDamage: number + readonly baseMinDamage: number + readonly baseMaxDamage: number + readonly freezeFrames: number + readonly baseFreezeFrames: number + readonly toHitBonusPct: number + readonly dmgSynergyMultiplier: number + readonly lenSynergyMultiplier: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface GuidedArrowStats { + readonly enhancedDamagePct: number + readonly alwaysHits: boolean + readonly autoHit: boolean + readonly canPierce: boolean + readonly pierceChancePct: number + readonly toHitBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface StrafeStats { + readonly arrowCount: number + readonly damageMultiplier: number + readonly srcDam: number + readonly enhancedDamagePct: number + readonly stationaryLockIn: boolean + readonly lockInFrames: number + readonly toHitBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface ImmolationArrowStats { + readonly minExplosionDamage: number + readonly maxExplosionDamage: number + readonly baseMinExplosionDamage: number + readonly baseMaxExplosionDamage: number + readonly minDamage: number + readonly maxDamage: number + readonly firePatchDurationFrames: number + readonly firePatchMinDamagePerSec: number + readonly firePatchMaxDamagePerSec: number + readonly cooldownFrames: number + readonly toHitBonusPct: number + readonly explosionSynergyMultiplier: number + readonly firePatchSynergyMultiplier: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface FreezingArrowStats { + readonly minColdDamage: number + readonly maxColdDamage: number + readonly baseMinColdDamage: number + readonly baseMaxColdDamage: number + readonly minDamage: number + readonly maxDamage: number + readonly freezeFrames: number + readonly baseFreezeFrames: number + readonly radiusPx: number + readonly radiusSubtiles: number + readonly toHitBonusPct: number + readonly coldSynergyMultiplier: number + readonly lenSynergyMultiplier: number + readonly manaCost: number + readonly manaCost256: number +} + +// --- Pure Calculation Functions --- + +/** + * Skill 006: Magic Arrow + * Physical-to-magic conversion `min(100, 5 + (slvl-1))%`, + * flat magic damage 5-band scaling (1, 1, 2, 3, 4, 5), + * AR bonus `+10 + 9*(slvl-1)%`, + * mana `max(0, 1.5 - 0.125*(slvl-1))` in 24.8 fixed point (reaches 0 at lvl 13), + * zero ammo consumption invariant (`noammo: 1`). + */ +export function calculateMagicArrowStats(slvl: number): MagicArrowStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const conversionPct = Math.min(100, Math.max(0, 5 + (lvl - 1))) + const flatMagicDamage = compute5BandScaling(lvl, 1, 1, 2, 3, 4, 5) + const toHitBonusPct = 10 + 9 * (lvl - 1) + const manaCost = Math.max(0, 1.5 - 0.125 * (lvl - 1)) + const manaCost256 = Math.max(0, 384 - 32 * (lvl - 1)) + + return { + conversionPct, + flatMagicDamage, + minDamage: flatMagicDamage, + maxDamage: flatMagicDamage, + toHitBonusPct, + manaCost, + manaCost256, + noAmmo: true, + requiresAmmo: false, + } +} + +/** + * Skill 007: Fire Arrow + * 80% physical-to-fire conversion (`srcdam = 80`), + * 5-band fire damage: + * Min: (1, 2, 3, 6, 12, 24) + * Max: (4, 2, 3, 7, 14, 27) + * Exploding Arrow synergy (+12%/lvl), + * AR bonus `+10 + 9*(slvl-1)%`, + * mana `3.0 + 0.25*(slvl-1)`. + */ +export function calculateFireArrowStats( + slvl: number, + synergies?: number | { readonly explodingArrow?: number; readonly [key: string]: any } +): FireArrowStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const baseMin = compute5BandScaling(lvl, 1, 2, 3, 6, 12, 24) + const baseMax = compute5BandScaling(lvl, 4, 2, 3, 7, 14, 27) + let synergyMultiplier = 1.0 + let synergyBonusPct = 0 + if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) { + synergyMultiplier = synergies + synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100) + } else if (synergies && typeof synergies === 'object') { + const synBlvl = Math.max(0, Math.floor(synergies?.explodingArrow ?? (synergies as any)?.['Exploding Arrow'] ?? 0)) + synergyBonusPct = synBlvl * 12 + synergyMultiplier = 1 + synergyBonusPct / 100 + } + const minDamage = Math.floor(baseMin * synergyMultiplier) + const maxDamage = Math.floor(baseMax * synergyMultiplier) + const toHitBonusPct = 10 + 9 * (lvl - 1) + const manaCost = 3.0 + 0.25 * (lvl - 1) + const manaCost256 = Math.round(manaCost * 256) + + return { + minDamage, + maxDamage, + baseMinDamage: baseMin, + baseMaxDamage: baseMax, + physToElemPct: 80, + srcDam: 80, + toHitBonusPct, + synergyMultiplier, + synergyBonusPct, + manaCost, + manaCost256, + } +} + +/** + * Skill 011: Cold Arrow + * 80% physical-to-cold conversion (`srcdam = 80`), + * 5-band cold damage (Skills.txt: eMin=6, eMax=8 with hitShift=7 factor 0.5): + * Min: (6, 4, 5, 8, 16, 42) * 0.5 + * Max: (8, 4, 5, 9, 17, 44) * 0.5 + * Chill duration `50 + 10*(slvl-1)` frames, + * Ice Arrow synergy (+12%/lvl), + * AR bonus `+10 + 9*(slvl-1)%`, + * mana `3.5 + 0.25*(slvl-1)`. + */ +export function calculateColdArrowStats( + slvl: number, + synergies?: number | { readonly iceArrow?: number; readonly [key: string]: any } +): ColdArrowStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const baseMin = compute5BandScaling(lvl, 6, 4, 5, 8, 16, 42) * 0.5 + const baseMax = compute5BandScaling(lvl, 8, 4, 5, 9, 17, 44) * 0.5 + let synergyMultiplier = 1.0 + let synergyBonusPct = 0 + if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) { + synergyMultiplier = synergies + synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100) + } else if (synergies && typeof synergies === 'object') { + const synBlvl = Math.max(0, Math.floor(synergies?.iceArrow ?? (synergies as any)?.['Ice Arrow'] ?? 0)) + synergyBonusPct = synBlvl * 12 + synergyMultiplier = 1 + synergyBonusPct / 100 + } + const minDamage = Math.floor(baseMin * synergyMultiplier) + const maxDamage = Math.floor(baseMax * synergyMultiplier) + const durationFrames = 50 + 10 * (lvl - 1) + const toHitBonusPct = 10 + 9 * (lvl - 1) + const manaCost = 3.5 + 0.25 * (lvl - 1) + const manaCost256 = Math.round(manaCost * 256) + + return { + minDamage, + maxDamage, + baseMinDamage: baseMin, + baseMaxDamage: baseMax, + durationFrames, + physToElemPct: 80, + srcDam: 80, + toHitBonusPct, + synergyMultiplier, + synergyBonusPct, + manaCost, + manaCost256, + } +} + +/** + * Skill 012: Multiple Shot + * Arrow count `min(24, 2 + slvl)`, + * 75% weapon damage (`srcdam = 96`), + * center 2 arrows proc flag, + * single-target multi-hit immunity per volley, + * mana `4 + 1*(slvl-1)`. + */ +export function calculateMultipleShotStats(slvl: number): MultipleShotStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const arrowCount = Math.min(24, Math.max(2, 2 + lvl)) + const manaCost = 4 + 1 * (lvl - 1) + const manaCost256 = manaCost * 256 + + return { + arrowCount, + damageMultiplier: 0.75, + srcDam: 96, + centerProcArrows: 2, + onlyCenterArrowsProc: true, + singleTargetImmunity: true, + toHitBonusPct: 0, + manaCost, + manaCost256, + } +} + +/** + * Skill 016: Exploding Arrow + * 5-band blast fire damage: + * Min: (2, 5, 7, 9, 12, 20) + * Max: (6, 5, 8, 11, 14, 23) + * AoE 80 sub-pixels (5.33 yards / 2.66 tiles), + * Fire Arrow synergy (+12%/lvl), + * AR bonus `+20 + 9*(slvl-1)%`, + * mana `5 + 0.5*(slvl-1)`. + */ +export function calculateExplodingArrowStats( + slvl: number, + synergies?: number | { readonly fireArrow?: number; readonly [key: string]: any } +): ExplodingArrowStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const baseMin = compute5BandScaling(lvl, 2, 5, 7, 9, 12, 20) + const baseMax = compute5BandScaling(lvl, 6, 5, 8, 11, 14, 23) + let synergyMultiplier = 1.0 + let synergyBonusPct = 0 + if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) { + synergyMultiplier = synergies + synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100) + } else if (synergies && typeof synergies === 'object') { + const synBlvl = Math.max(0, Math.floor(synergies?.fireArrow ?? (synergies as any)?.['Fire Arrow'] ?? 0)) + synergyBonusPct = synBlvl * 12 + synergyMultiplier = 1 + synergyBonusPct / 100 + } + const minBlastDamage = Math.floor(baseMin * synergyMultiplier) + const maxBlastDamage = Math.floor(baseMax * synergyMultiplier) + const toHitBonusPct = 20 + 9 * (lvl - 1) + const manaCost = 5 + 0.5 * (lvl - 1) + const manaCost256 = Math.round(manaCost * 256) + + return { + minBlastDamage, + maxBlastDamage, + baseMinBlastDamage: baseMin, + baseMaxBlastDamage: baseMax, + minDamage: minBlastDamage, + maxDamage: maxBlastDamage, + radiusPx: 80, + radiusSubtiles: 80, + toHitBonusPct, + synergyMultiplier, + synergyBonusPct, + manaCost, + manaCost256, + } +} + +/** + * Skill 021: Ice Arrow + * 5-band cold damage: + * Min: (6, 6, 12, 18, 26, 36) + * Max: (10, 6, 13, 19, 27, 38) + * Freeze duration `50 + 5*(slvl-1)` frames, + * AR bonus `+20 + 9*(slvl-1)%`, + * Cold Arrow synergy (+8% damage/lvl), + * Freezing Arrow synergy (+5% length/lvl), + * mana `4 + 0.25*(slvl-1)`. + */ +export function calculateIceArrowStats( + slvl: number, + synergies?: number | { + readonly coldArrow?: number + readonly freezingArrow?: number + readonly [key: string]: any + } +): IceArrowStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const baseMin = compute5BandScaling(lvl, 6, 6, 12, 18, 26, 36) + const baseMax = compute5BandScaling(lvl, 10, 6, 13, 19, 27, 38) + let dmgSynergyMultiplier = 1.0 + let lenSynergyMultiplier = 1.0 + + if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) { + dmgSynergyMultiplier = synergies + } else if (synergies && typeof synergies === 'object') { + const caBlvl = Math.max(0, Math.floor(synergies?.coldArrow ?? (synergies as any)?.['Cold Arrow'] ?? 0)) + const dmgSynPct = caBlvl * 8 + dmgSynergyMultiplier = 1 + dmgSynPct / 100 + + const faBlvl = Math.max(0, Math.floor(synergies?.freezingArrow ?? (synergies as any)?.['Freezing Arrow'] ?? 0)) + const lenSynPct = faBlvl * 5 + lenSynergyMultiplier = 1 + lenSynPct / 100 + } + const minDamage = Math.floor(baseMin * dmgSynergyMultiplier) + const maxDamage = Math.floor(baseMax * dmgSynergyMultiplier) + + const baseFreezeFrames = 50 + 5 * (lvl - 1) + const freezeFrames = Math.floor(baseFreezeFrames * lenSynergyMultiplier) + + const toHitBonusPct = 20 + 9 * (lvl - 1) + const manaCost = 4 + 0.25 * (lvl - 1) + const manaCost256 = Math.round(manaCost * 256) + + return { + minDamage, + maxDamage, + baseMinDamage: baseMin, + baseMaxDamage: baseMax, + freezeFrames, + baseFreezeFrames, + toHitBonusPct, + dmgSynergyMultiplier, + lenSynergyMultiplier, + manaCost, + manaCost256, + } +} + +/** + * Skill 022: Guided Arrow + * Enhanced damage `+5%*(slvl-1)`, + * auto-hit invariant (`alwaysHits = true`), + * strict `canPierce = false` and `pierceChancePct = 0` (1.13c ground truth), + * mana `max(1.75, 8.0 - 0.25*(slvl-1))`. + */ +export function calculateGuidedArrowStats(slvl: number): GuidedArrowStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const enhancedDamagePct = 5 * (lvl - 1) + const manaCost = Math.max(1.75, 8.0 - 0.25 * (lvl - 1)) + const manaCost256 = Math.round(manaCost * 256) + + return { + enhancedDamagePct, + alwaysHits: true, + autoHit: true, + canPierce: false, + pierceChancePct: 0, + toHitBonusPct: 0, + manaCost, + manaCost256, + } +} + +/** + * Skill 026: Strafe + * Arrow count `min(10, 4 + floor(slvl/2))`, + * 75% weapon damage (`srcdam = 96`), + * Enhanced damage `+5%*(slvl-1)`, + * stationary lock-in duration, + * mana fixed at 11. + */ +export function calculateStrafeStats(slvl: number): StrafeStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const arrowCount = Math.min(10, Math.max(4, 4 + Math.floor(lvl / 2))) + const enhancedDamagePct = 5 * (lvl - 1) + const lockInFrames = arrowCount * 2 + 4 + + return { + arrowCount, + damageMultiplier: 0.75, + srcDam: 96, + enhancedDamagePct, + stationaryLockIn: true, + lockInFrames, + toHitBonusPct: 0, + manaCost: 11, + manaCost256: 2816, + } +} + +/** + * Skill 027: Immolation Arrow + * 5-band explosion fire damage: + * Min: (12, 12, 23, 34, 36, 38) + * Max: (23, 12, 23, 34, 36, 38) + * 75-frame ground fire patch (`immolationfire`), + * 25-frame casting cooldown (1.0s delay), + * Fire Arrow synergies (+5% explosion, +10% fire patch), + * AR bonus `+30 + 9*(slvl-1)%`, + * mana `6 + 0.5*(slvl-1)`. + */ +export function calculateImmolationArrowStats( + slvl: number, + synergies?: number | { readonly fireArrow?: number; readonly [key: string]: any } +): ImmolationArrowStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const baseMin = compute5BandScaling(lvl, 12, 12, 23, 34, 36, 38) + const baseMax = compute5BandScaling(lvl, 23, 12, 23, 34, 36, 38) + let explosionSynergyMultiplier = 1.0 + let firePatchSynergyMultiplier = 1.0 + + if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) { + explosionSynergyMultiplier = synergies + firePatchSynergyMultiplier = synergies + } else if (synergies && typeof synergies === 'object') { + const faBlvl = Math.max(0, Math.floor(synergies?.fireArrow ?? (synergies as any)?.['Fire Arrow'] ?? 0)) + const expSynPct = faBlvl * 5 + explosionSynergyMultiplier = 1 + expSynPct / 100 + const patchSynPct = faBlvl * 10 + firePatchSynergyMultiplier = 1 + patchSynPct / 100 + } + const minExplosionDamage = Math.floor(baseMin * explosionSynergyMultiplier) + const maxExplosionDamage = Math.floor(baseMax * explosionSynergyMultiplier) + + // 1.13c immolationfire (missile 230) base ~7-10 damage/sec scaled by synergy + const firePatchMinDamagePerSec = Math.floor(7 * firePatchSynergyMultiplier) + const firePatchMaxDamagePerSec = Math.floor(10 * firePatchSynergyMultiplier) + + const toHitBonusPct = 30 + 9 * (lvl - 1) + const manaCost = 6 + 0.5 * (lvl - 1) + const manaCost256 = Math.round(manaCost * 256) + + return { + minExplosionDamage, + maxExplosionDamage, + baseMinExplosionDamage: baseMin, + baseMaxExplosionDamage: baseMax, + minDamage: minExplosionDamage, + maxDamage: maxExplosionDamage, + firePatchDurationFrames: 75, + firePatchMinDamagePerSec, + firePatchMaxDamagePerSec, + cooldownFrames: 25, + toHitBonusPct, + explosionSynergyMultiplier, + firePatchSynergyMultiplier, + manaCost, + manaCost256, + } +} + +/** + * Skill 031: Freezing Arrow + * 5-band frost blast cold damage: + * Min: (40, 10, 15, 20, 22, 24) + * Max: (50, 10, 15, 20, 22, 24) + * AoE 80 sub-pixels (5.33 yards / 2.66 tiles), + * 50-frame freeze duration, + * Cold Arrow synergy (+12% damage/lvl), + * Ice Arrow synergy (+5% length/lvl), + * AR bonus `+40 + 9*(slvl-1)%`, + * mana `9 + 0.5*(slvl-1)`. + */ +export function calculateFreezingArrowStats( + slvl: number, + synergies?: number | { + readonly coldArrow?: number + readonly iceArrow?: number + readonly [key: string]: any + } +): FreezingArrowStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const baseMin = compute5BandScaling(lvl, 40, 10, 15, 20, 22, 24) + const baseMax = compute5BandScaling(lvl, 50, 10, 15, 20, 22, 24) + let coldSynergyMultiplier = 1.0 + let lenSynergyMultiplier = 1.0 + + if (typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0) { + coldSynergyMultiplier = synergies + } else if (synergies && typeof synergies === 'object') { + const caBlvl = Math.max(0, Math.floor(synergies?.coldArrow ?? (synergies as any)?.['Cold Arrow'] ?? 0)) + const coldSynPct = caBlvl * 12 + coldSynergyMultiplier = 1 + coldSynPct / 100 + + const iaBlvl = Math.max(0, Math.floor(synergies?.iceArrow ?? (synergies as any)?.['Ice Arrow'] ?? 0)) + const lenSynPct = iaBlvl * 5 + lenSynergyMultiplier = 1 + lenSynPct / 100 + } + const minColdDamage = Math.floor(baseMin * coldSynergyMultiplier) + const maxColdDamage = Math.floor(baseMax * coldSynergyMultiplier) + + const freezeFrames = Math.floor(50 * lenSynergyMultiplier) + + const toHitBonusPct = 40 + 9 * (lvl - 1) + const manaCost = 9 + 0.5 * (lvl - 1) + const manaCost256 = Math.round(manaCost * 256) + + return { + minColdDamage, + maxColdDamage, + baseMinColdDamage: baseMin, + baseMaxColdDamage: baseMax, + minDamage: minColdDamage, + maxDamage: maxColdDamage, + freezeFrames, + baseFreezeFrames: 50, + radiusPx: 80, + radiusSubtiles: 80, + toHitBonusPct, + coldSynergyMultiplier, + lenSynergyMultiplier, + manaCost, + manaCost256, + } +} + +// --- Kinematics Helpers --- + +/** + * Computes symmetrical fan angles for Multiple Shot. + * Spreads `arrowCount` arrows across an arc centered at `aimAngleRad`. + */ +export function generateMultipleShotAngles( + aimAngleRad: number, + arrowCount: number, + arcRad: number = (45 * Math.PI) / 180 +): number[] { + if (arrowCount <= 1) return [aimAngleRad] + const angles: number[] = [] + const halfArc = arcRad / 2 + const step = arcRad / (arrowCount - 1) + for (let i = 0; i < arrowCount; i += 1) { + angles.push(aimAngleRad - halfArc + i * step) + } + return angles +} + +/** + * Checks if an arrow index in a Multiple Shot volley is one of the two center arrows. + * Only center arrows can trigger "chance to cast on striking" procs and knockback in 1.13c. + */ +export function isMultipleShotCenterArrow(index: number, arrowCount: number): boolean { + if (arrowCount <= 2) return true + const mid1 = Math.floor((arrowCount - 1) / 2) + const mid2 = Math.ceil((arrowCount - 1) / 2) + return index === mid1 || index === mid2 +} diff --git a/src/game/skills/impl/ama/skill-006-magic-arrow.ts b/src/game/skills/impl/ama/skill-006-magic-arrow.ts index e81b862..e2860ee 100644 --- a/src/game/skills/impl/ama/skill-006-magic-arrow.ts +++ b/src/game/skills/impl/ama/skill-006-magic-arrow.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateMagicArrowStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-007-fire-arrow.ts b/src/game/skills/impl/ama/skill-007-fire-arrow.ts index 6aba537..2a187ac 100644 --- a/src/game/skills/impl/ama/skill-007-fire-arrow.ts +++ b/src/game/skills/impl/ama/skill-007-fire-arrow.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateFireArrowStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-011-cold-arrow.ts b/src/game/skills/impl/ama/skill-011-cold-arrow.ts index 88f6405..5aed5af 100644 --- a/src/game/skills/impl/ama/skill-011-cold-arrow.ts +++ b/src/game/skills/impl/ama/skill-011-cold-arrow.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateColdArrowStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-012-multiple-shot.ts b/src/game/skills/impl/ama/skill-012-multiple-shot.ts index a6bb54a..3a6d4c9 100644 --- a/src/game/skills/impl/ama/skill-012-multiple-shot.ts +++ b/src/game/skills/impl/ama/skill-012-multiple-shot.ts @@ -41,4 +41,9 @@ export const skillModule: SkillModule = { }, } +export { + calculateMultipleShotStats, + generateMultipleShotAngles, + isMultipleShotCenterArrow, +} from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-016-exploding-arrow.ts b/src/game/skills/impl/ama/skill-016-exploding-arrow.ts index 2b5fe23..45dc61b 100644 --- a/src/game/skills/impl/ama/skill-016-exploding-arrow.ts +++ b/src/game/skills/impl/ama/skill-016-exploding-arrow.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateExplodingArrowStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-021-ice-arrow.ts b/src/game/skills/impl/ama/skill-021-ice-arrow.ts index 1110356..ddccddc 100644 --- a/src/game/skills/impl/ama/skill-021-ice-arrow.ts +++ b/src/game/skills/impl/ama/skill-021-ice-arrow.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateIceArrowStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-022-guided-arrow.ts b/src/game/skills/impl/ama/skill-022-guided-arrow.ts index cd9535f..72a38a6 100644 --- a/src/game/skills/impl/ama/skill-022-guided-arrow.ts +++ b/src/game/skills/impl/ama/skill-022-guided-arrow.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateGuidedArrowStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-026-strafe.ts b/src/game/skills/impl/ama/skill-026-strafe.ts index d3d5bfb..b887414 100644 --- a/src/game/skills/impl/ama/skill-026-strafe.ts +++ b/src/game/skills/impl/ama/skill-026-strafe.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateStrafeStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-027-immolation-arrow.ts b/src/game/skills/impl/ama/skill-027-immolation-arrow.ts index 3ac6d6f..9c740e7 100644 --- a/src/game/skills/impl/ama/skill-027-immolation-arrow.ts +++ b/src/game/skills/impl/ama/skill-027-immolation-arrow.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateImmolationArrowStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-031-freezing-arrow.ts b/src/game/skills/impl/ama/skill-031-freezing-arrow.ts index f76597a..0ed4e30 100644 --- a/src/game/skills/impl/ama/skill-031-freezing-arrow.ts +++ b/src/game/skills/impl/ama/skill-031-freezing-arrow.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateFreezingArrowStats } from '../../amazon-bow.ts' export default skillModule diff --git a/src/game/skills/registry.ts b/src/game/skills/registry.ts index 67783b0..5fbd5a9 100644 --- a/src/game/skills/registry.ts +++ b/src/game/skills/registry.ts @@ -154,7 +154,16 @@ export function evaluateSkillCore113c(ctx: SkillEvalContext): SkillEvalResult { let elemHitShift = skill.hitShift let elemType = skill.eType - const primaryMissileName = skill.srvMissile || skill.srvMissileA || skill.cltMissile || skill.cltMissileA + const primaryMissileName = + skill.srvMissile || + skill.srvMissileA || + skill.cltMissile || + skill.cltMissileA || + (skill.id === 22 + ? 'guidedarrow' + : skill.id === 12 || skill.id === 26 + ? 'arrow' + : '') if (baseEMin === 0 && baseEMax === 0 && primaryMissileName) { const msl = registry.getMissileByName(primaryMissileName) if (msl && (msl.eMin > 0 || msl.eMax > 0)) { @@ -980,25 +989,59 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { } const piercePct = - caster.statList.getEffectiveSkillLevel(33) > 0 - ? Math.min(95, 25 + caster.statList.getEffectiveSkillLevel(33) * 3) - : 0 + skill.id === 22 + ? 0 + : caster.statList.getEffectiveSkillLevel(33) > 0 + ? Math.min(95, 25 + caster.statList.getEffectiveSkillLevel(33) * 3) + : 0 - 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, + if (skill.id === 12) { + const vol = missileEngine.spawnMultipleShotVolley({ + sourceSkillId: 12, slvl: evalResult.slvl, owner: caster, startX: 0, startY: 0, - targetX: targetX + spreadOffset, + targetX, targetY, dmgPacket, pierceChancePct: piercePct, + missileName: evalResult.missileA || 'arrow', }) - if (m) missilesSpawned.push(m.name) + missilesSpawned.push(...vol.map(m => m.name)) + } else if (skill.id === 26) { + const st = missileEngine.spawnStrafeVolley({ + sourceSkillId: 26, + slvl: evalResult.slvl, + owner: caster, + startX: 0, + startY: 0, + targets, + targetPositions: targetPositions ?? new Map(), + targetX, + targetY, + dmgPacket, + pierceChancePct: piercePct, + missileName: evalResult.missileA || 'arrow', + }) + missilesSpawned.push(...st.map(m => m.name)) + } else { + for (let i = 0; i < projectileCount; i += 1) { + const spreadOffset = (i - (projectileCount - 1) / 2) * 14 + const m = missileEngine.spawnMissile({ + missileNameOrId: evalResult.missileA, + sourceSkillId: skill.id, + slvl: evalResult.slvl, + owner: caster, + startX: 0, + startY: 0, + targetX: targetX + spreadOffset, + targetY, + dmgPacket, + pierceChancePct: piercePct, + }) + if (m) missilesSpawned.push(m.name) + } } // Advance missile engine so immediate or close-range missiles land and record damage diff --git a/tests/skills/ama/adv-ama-bow-kinematics-stress.test.ts b/tests/skills/ama/adv-ama-bow-kinematics-stress.test.ts new file mode 100644 index 0000000..085fd5e --- /dev/null +++ b/tests/skills/ama/adv-ama-bow-kinematics-stress.test.ts @@ -0,0 +1,566 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Amazon Bow & Crossbow Kinematics Stress Suite + * + * Comprehensive adversarial and E2E kinematics integration verification: + * 1. Guided Arrow homing continuous angle steering and strict zero-pierce invariant under 100% Pierce skill. + * 2. Multiple Shot 2..24 arrow scaling, symmetrical fan arc, center 2 proc flag, and single-target hit immunity. + * 3. Strafe 4..10 arrow scaling, multi-target cycling, and stationary lock-in state. + * 4. Immolation Arrow 25-frame casting cooldown and 75-frame ground fire patch ticking. + * 5. Freezing Arrow 80 sub-pixel AoE cold splash and solid freeze application. + * 6. Magic Arrow physical-to-magic conversion and zero-ammo requirement invariant. + * 7. System invariant: BATCH1_SKILLS length strictly maintained at 38; zero mocks. + * + * Ground Truth: + * - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt + */ + +import { describe, expect, it } from 'vitest' +import { + BATCH1_SKILLS, + getSkillManaCost, + getSkillCooldownTicks, + calculateSkillDamage, +} from '../../../src/game/skills.ts' +import { + calculateMagicArrowStats, + calculateFireArrowStats, + calculateColdArrowStats, + calculateMultipleShotStats, + calculateExplodingArrowStats, + calculateIceArrowStats, + calculateGuidedArrowStats, + calculateStrafeStats, + calculateImmolationArrowStats, + calculateFreezingArrowStats, + generateMultipleShotAngles, + isMultipleShotCenterArrow, +} from '../../../src/game/skills/amazon-bow.ts' +import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' +import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/game/engine/missile-engine.ts' +import { WorldArena } from '../../../src/game/engine/world-arena.ts' +import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' +import type { CombatUnitContext, SUnitDmgPacket } from '../../../src/game/engine/combat-pipeline.ts' + +function createTestCombatUnit( + id: string, + pos: { x: number; y: number }, + registry: any, + opts?: { hp?: number; cannotBeFrozen?: boolean; isBoss?: boolean } +): CombatUnitContext { + const statList = new UnitStatList(registry, { + level: 80, + hitpoints: (opts?.hp ?? 10000) * FIXED_ONE, + maxhp: (opts?.hp ?? 10000) * FIXED_ONE, + armorclass: 400, + fireresist: 0, + coldresist: 0, + lightresist: 0, + poisonresist: 0, + damageresist: 0, + magicresist: 0, + x: pos.x, + y: pos.y, + ...(opts?.cannotBeFrozen ? { cannot_be_frozen: 1 } : {}), + }) + const stateBus = new StateBus(statList, registry) + return { + id, + name: id, + statList, + stateBus, + isBoss: opts?.isBoss ?? false, + } +} + +describe('Amazon Bow & Crossbow Kinematics Adversarial Stress Suite (1.13c Ground Truth)', () => { + it('enforces System Invariants: BATCH1_SKILLS length strictly equals 38 and zero mocks utilized', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + expect(BATCH1_SKILLS[6]).toBeDefined() // Magic Arrow + expect(BATCH1_SKILLS[7]).toBeDefined() // Fire Arrow + expect(BATCH1_SKILLS[11]).toBeDefined() // Cold Arrow + }) + + describe('Guided Arrow: Homing Kinematics, Continuous Steering & Strict Zero-Pierce', () => { + it('strictly forces canPierce = false and pierceChancePct = 0 even with 100% Pierce skill', async () => { + const registry = await getSharedDataRegistry() + const missileEngine = new MissileEngine(registry) + + const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry) + // Simulate 100% Pierce skill from Skill 33 or Razortail + owner.statList.setBaseSkillLevel(33, 20) + + const dmgPacket: SUnitDmgPacket = { + skillId: 22, + attackKind: 'missile', + srcDam: 128, + physDamagePct: 95, // Level 20 GA: +95% ED + autoHit: true, + } + + // Spawn Guided Arrow with pierceChancePct: 100 explicitly requested + const missile = missileEngine.spawnMissile({ + missileNameOrId: 'guidedarrow', + sourceSkillId: 22, + slvl: 20, + owner, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket, + pierceChancePct: 100, // Adversarial input: 100% pierce + }) + + expect(missile).not.toBeNull() + // In 1.13c, Guided Arrow NEVER pierces under any circumstances + expect(missile!.canPierce).toBe(false) + expect(missile!.pierceChancePct).toBe(0) + }) + + it('continuously steers towards moving targets in 2:1 isometric space and auto-hits target defense', async () => { + const registry = await getSharedDataRegistry() + const missileEngine = new MissileEngine(registry) + + const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry) + const targetPos = { x: 100, y: 60 } + const target = createTestCombatUnit('moving-target', targetPos, registry) + const targetPositions = new Map() + 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() + 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() + 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() + 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() + 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) + } + }) + }) +}) diff --git a/tests/skills/ama/skill-006-magic-arrow.test.ts b/tests/skills/ama/skill-006-magic-arrow.test.ts index cb54a96..306c6d5 100644 --- a/tests/skills/ama/skill-006-magic-arrow.test.ts +++ b/tests/skills/ama/skill-006-magic-arrow.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-006-magic-arrow.ts' +import { calculateMagicArrowStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #006] Magic Arrow (AMA) — 1.13c Parity (Issue #160)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,60 @@ describe('[Skill #006] Magic Arrow (AMA) — 1.13c Parity (Issue #160)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateMagicArrowStats pure calculations and boundary invariants', () => { + // Level 1: 5% magic conversion, 1 flat magic dmg, +10% AR, 1.5 mana (384 fixed-point), noAmmo: true + const s1 = calculateMagicArrowStats(1) + expect(s1.conversionPct).toBe(5) + expect(s1.flatMagicDamage).toBe(1) + expect(s1.toHitBonusPct).toBe(10) + expect(s1.manaCost).toBe(1.5) + expect(s1.manaCost256).toBe(384) + expect(s1.noAmmo).toBe(true) + expect(s1.requiresAmmo).toBe(false) + + // Level 10: 14% conversion, 12 flat magic dmg, +91% AR, 0.375 mana (96 units) + const s10 = calculateMagicArrowStats(10) + expect(s10.conversionPct).toBe(14) + expect(s10.flatMagicDamage).toBe(12) + expect(s10.toHitBonusPct).toBe(91) + expect(s10.manaCost).toBe(0.375) + expect(s10.manaCost256).toBe(96) + + // Level 20: 24% conversion, 36 flat magic dmg, +181% AR, 0 mana + const s20 = calculateMagicArrowStats(20) + expect(s20.conversionPct).toBe(24) + expect(s20.flatMagicDamage).toBe(36) + expect(s20.toHitBonusPct).toBe(181) + expect(s20.manaCost).toBe(0) + expect(s20.manaCost256).toBe(0) + + // Level 96+: capped at 100% conversion + const s96 = calculateMagicArrowStats(96) + expect(s96.conversionPct).toBe(100) + + // Monotonicity checks + for (let lvl = 1; lvl < 30; lvl++) { + const cur = calculateMagicArrowStats(lvl) + const nxt = calculateMagicArrowStats(lvl + 1) + expect(nxt.conversionPct).toBeGreaterThanOrEqual(cur.conversionPct) + expect(nxt.flatMagicDamage).toBeGreaterThanOrEqual(cur.flatMagicDamage) + expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct) + expect(nxt.manaCost).toBeLessThanOrEqual(cur.manaCost) + } + + // Boundary resilience: 0, negative, NaN, fractional + const sZero = calculateMagicArrowStats(0) + expect(sZero.conversionPct).toBe(5) + expect(sZero.manaCost).toBe(1.5) + + const sNeg = calculateMagicArrowStats(-10) + expect(sNeg.conversionPct).toBe(5) + + const sNan = calculateMagicArrowStats(NaN) + expect(sNan.conversionPct).toBe(5) + + const sFrac = calculateMagicArrowStats(5.7) + expect(sFrac.conversionPct).toBe(9) + }) }) diff --git a/tests/skills/ama/skill-007-fire-arrow.test.ts b/tests/skills/ama/skill-007-fire-arrow.test.ts index 9d8ee5a..37e1179 100644 --- a/tests/skills/ama/skill-007-fire-arrow.test.ts +++ b/tests/skills/ama/skill-007-fire-arrow.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-007-fire-arrow.ts' +import { calculateFireArrowStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #007] Fire Arrow (AMA) — 1.13c Parity (Issue #161)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,60 @@ describe('[Skill #007] Fire Arrow (AMA) — 1.13c Parity (Issue #161)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateFireArrowStats pure calculations and boundary invariants', () => { + // Level 1: 80% conversion, 1-4 fire dmg, +10% AR, 3.0 mana + const s1 = calculateFireArrowStats(1) + expect(s1.physToElemPct).toBe(80) + expect(s1.srcDam).toBe(80) + expect(s1.minDamage).toBe(1) + expect(s1.maxDamage).toBe(4) + expect(s1.toHitBonusPct).toBe(10) + expect(s1.manaCost).toBe(3.0) + + // Level 10: 21-24 fire dmg, +91% AR, 5.25 mana + const s10 = calculateFireArrowStats(10) + expect(s10.minDamage).toBe(21) + expect(s10.maxDamage).toBe(24) + expect(s10.toHitBonusPct).toBe(91) + expect(s10.manaCost).toBe(5.25) + + // Level 20: 63-70 fire dmg, +181% AR, 7.75 mana + const s20 = calculateFireArrowStats(20) + expect(s20.minDamage).toBe(63) + expect(s20.maxDamage).toBe(70) + expect(s20.toHitBonusPct).toBe(181) + expect(s20.manaCost).toBe(7.75) + + // Synergy test: Exploding Arrow +12%/level + const s20Syn = calculateFireArrowStats(20, { explodingArrow: 20 }) + expect(s20Syn.synergyBonusPct).toBe(240) + expect(s20Syn.synergyMultiplier).toBe(3.4) + expect(s20Syn.minDamage).toBe(Math.floor(63 * 3.4)) + expect(s20Syn.maxDamage).toBe(Math.floor(70 * 3.4)) + + // Monotonicity checks + for (let lvl = 1; lvl < 30; lvl++) { + const cur = calculateFireArrowStats(lvl) + const nxt = calculateFireArrowStats(lvl + 1) + expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage) + expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage) + expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct) + expect(nxt.manaCost).toBeGreaterThan(cur.manaCost) + } + + // Boundary resilience + const sZero = calculateFireArrowStats(0) + expect(sZero.minDamage).toBe(1) + expect(sZero.manaCost).toBe(3.0) + + const sNeg = calculateFireArrowStats(-5) + expect(sNeg.minDamage).toBe(1) + + const sNan = calculateFireArrowStats(NaN) + expect(sNan.minDamage).toBe(1) + + const sFrac = calculateFireArrowStats(4.8) + expect(sFrac.minDamage).toBe(calculateFireArrowStats(4).minDamage) + }) }) diff --git a/tests/skills/ama/skill-011-cold-arrow.test.ts b/tests/skills/ama/skill-011-cold-arrow.test.ts index d98fbda..6968d93 100644 --- a/tests/skills/ama/skill-011-cold-arrow.test.ts +++ b/tests/skills/ama/skill-011-cold-arrow.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-011-cold-arrow.ts' +import { calculateColdArrowStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #011] Cold Arrow (AMA) — 1.13c Parity (Issue #165)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,64 @@ describe('[Skill #011] Cold Arrow (AMA) — 1.13c Parity (Issue #165)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateColdArrowStats pure calculations and boundary invariants', () => { + // Level 1: 80% conversion, 3-4 cold dmg, 50 frames chill duration, +10% AR, 3.5 mana + const s1 = calculateColdArrowStats(1) + expect(s1.physToElemPct).toBe(80) + expect(s1.srcDam).toBe(80) + expect(s1.minDamage).toBe(3) + expect(s1.maxDamage).toBe(4) + expect(s1.durationFrames).toBe(50) + expect(s1.toHitBonusPct).toBe(10) + expect(s1.manaCost).toBe(3.5) + + // Level 10: 22-23 cold dmg, 140 frames chill duration, +91% AR, 5.75 mana + const s10 = calculateColdArrowStats(10) + expect(s10.minDamage).toBe(22) + expect(s10.maxDamage).toBe(23) + expect(s10.durationFrames).toBe(140) + expect(s10.toHitBonusPct).toBe(91) + expect(s10.manaCost).toBe(5.75) + + // Level 20: 53-56 cold dmg, 240 frames chill duration, +181% AR, 8.25 mana + const s20 = calculateColdArrowStats(20) + expect(s20.minDamage).toBe(53) + expect(s20.maxDamage).toBe(56) + expect(s20.durationFrames).toBe(240) + expect(s20.toHitBonusPct).toBe(181) + expect(s20.manaCost).toBe(8.25) + + // Synergy test: Ice Arrow +12%/level + const s20Syn = calculateColdArrowStats(20, { iceArrow: 20 }) + expect(s20Syn.synergyBonusPct).toBe(240) + expect(s20Syn.synergyMultiplier).toBe(3.4) + expect(s20Syn.minDamage).toBe(Math.floor(53 * 3.4)) + expect(s20Syn.maxDamage).toBe(Math.floor(56 * 3.4)) + + // Monotonicity checks + for (let lvl = 1; lvl < 30; lvl++) { + const cur = calculateColdArrowStats(lvl) + const nxt = calculateColdArrowStats(lvl + 1) + expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage) + expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage) + expect(nxt.durationFrames).toBeGreaterThan(cur.durationFrames) + expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct) + expect(nxt.manaCost).toBeGreaterThan(cur.manaCost) + } + + // Boundary resilience + const sZero = calculateColdArrowStats(0) + expect(sZero.minDamage).toBe(3) + expect(sZero.manaCost).toBe(3.5) + + const sNeg = calculateColdArrowStats(-1) + expect(sNeg.minDamage).toBe(3) + + const sNan = calculateColdArrowStats(NaN) + expect(sNan.minDamage).toBe(3) + + const sFrac = calculateColdArrowStats(7.2) + expect(sFrac.minDamage).toBe(calculateColdArrowStats(7).minDamage) + }) }) diff --git a/tests/skills/ama/skill-012-multiple-shot.test.ts b/tests/skills/ama/skill-012-multiple-shot.test.ts index dbca149..8535142 100644 --- a/tests/skills/ama/skill-012-multiple-shot.test.ts +++ b/tests/skills/ama/skill-012-multiple-shot.test.ts @@ -7,6 +7,11 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-012-multiple-shot.ts' +import { + calculateMultipleShotStats, + generateMultipleShotAngles, + isMultipleShotCenterArrow, +} from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #012] Multiple Shot (AMA) — 1.13c Parity (Issue #166)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +111,60 @@ describe('[Skill #012] Multiple Shot (AMA) — 1.13c Parity (Issue #166)', () => expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateMultipleShotStats pure calculations and kinematics invariants', () => { + // Level 1: 3 arrows, 75% weapon damage (srcdam=96), 4 mana + const s1 = calculateMultipleShotStats(1) + expect(s1.arrowCount).toBe(3) + expect(s1.damageMultiplier).toBe(0.75) + expect(s1.srcDam).toBe(96) + expect(s1.centerProcArrows).toBe(2) + expect(s1.onlyCenterArrowsProc).toBe(true) + expect(s1.singleTargetImmunity).toBe(true) + expect(s1.manaCost).toBe(4) + + // Level 10: 12 arrows, 13 mana + const s10 = calculateMultipleShotStats(10) + expect(s10.arrowCount).toBe(12) + expect(s10.manaCost).toBe(13) + + // Level 20: 22 arrows, 23 mana + const s20 = calculateMultipleShotStats(20) + expect(s20.arrowCount).toBe(22) + expect(s20.manaCost).toBe(23) + + // Level 22+: capped at 24 arrows + const s22 = calculateMultipleShotStats(22) + expect(s22.arrowCount).toBe(24) + const s30 = calculateMultipleShotStats(30) + expect(s30.arrowCount).toBe(24) + + // Proc flag: only center 2 arrows proc + // For 4 arrows (0, 1, 2, 3): mid1=1, mid2=2 + expect(isMultipleShotCenterArrow(0, 4)).toBe(false) + expect(isMultipleShotCenterArrow(1, 4)).toBe(true) + expect(isMultipleShotCenterArrow(2, 4)).toBe(true) + expect(isMultipleShotCenterArrow(3, 4)).toBe(false) + + // Symmetrical fan angles test + const angles = generateMultipleShotAngles(0, 5, Math.PI / 4) + expect(angles.length).toBe(5) + expect(angles[0]).toBeCloseTo(-Math.PI / 8) + expect(angles[2]).toBeCloseTo(0) + expect(angles[4]).toBeCloseTo(Math.PI / 8) + + // Boundary resilience + const sZero = calculateMultipleShotStats(0) + expect(sZero.arrowCount).toBe(3) + expect(sZero.manaCost).toBe(4) + + const sNeg = calculateMultipleShotStats(-5) + expect(sNeg.arrowCount).toBe(3) + + const sNan = calculateMultipleShotStats(NaN) + expect(sNan.arrowCount).toBe(3) + + const sFrac = calculateMultipleShotStats(3.9) + expect(sFrac.arrowCount).toBe(5) + }) }) diff --git a/tests/skills/ama/skill-016-exploding-arrow.test.ts b/tests/skills/ama/skill-016-exploding-arrow.test.ts index 8184859..8da41c4 100644 --- a/tests/skills/ama/skill-016-exploding-arrow.test.ts +++ b/tests/skills/ama/skill-016-exploding-arrow.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-016-exploding-arrow.ts' +import { calculateExplodingArrowStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #016] Exploding Arrow (AMA) — 1.13c Parity (Issue #170)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,59 @@ describe('[Skill #016] Exploding Arrow (AMA) — 1.13c Parity (Issue #170)', () expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateExplodingArrowStats pure calculations and AoE invariants', () => { + // Level 1: 2-6 blast fire dmg, 80 sub-pixel AoE, +20% AR, 5.0 mana + const s1 = calculateExplodingArrowStats(1) + expect(s1.minBlastDamage).toBe(2) + expect(s1.maxBlastDamage).toBe(6) + expect(s1.radiusPx).toBe(80) + expect(s1.toHitBonusPct).toBe(20) + expect(s1.manaCost).toBe(5.0) + + // Level 10: 51-57 blast fire dmg, +101% AR, 9.5 mana + const s10 = calculateExplodingArrowStats(10) + expect(s10.minBlastDamage).toBe(51) + expect(s10.maxBlastDamage).toBe(57) + expect(s10.toHitBonusPct).toBe(101) + expect(s10.manaCost).toBe(9.5) + + // Level 20: 129-149 blast fire dmg, +191% AR, 14.5 mana + const s20 = calculateExplodingArrowStats(20) + expect(s20.minBlastDamage).toBe(129) + expect(s20.maxBlastDamage).toBe(149) + expect(s20.toHitBonusPct).toBe(191) + expect(s20.manaCost).toBe(14.5) + + // Synergy test: Fire Arrow +12%/level + const s20Syn = calculateExplodingArrowStats(20, { fireArrow: 20 }) + expect(s20Syn.synergyBonusPct).toBe(240) + expect(s20Syn.synergyMultiplier).toBe(3.4) + expect(s20Syn.minBlastDamage).toBe(Math.floor(129 * 3.4)) + expect(s20Syn.maxBlastDamage).toBe(Math.floor(149 * 3.4)) + + // Monotonicity checks + for (let lvl = 1; lvl < 30; lvl++) { + const cur = calculateExplodingArrowStats(lvl) + const nxt = calculateExplodingArrowStats(lvl + 1) + expect(nxt.minBlastDamage).toBeGreaterThanOrEqual(cur.minBlastDamage) + expect(nxt.maxBlastDamage).toBeGreaterThanOrEqual(cur.maxBlastDamage) + expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct) + expect(nxt.manaCost).toBeGreaterThan(cur.manaCost) + } + + // Boundary resilience + const sZero = calculateExplodingArrowStats(0) + expect(sZero.minBlastDamage).toBe(2) + expect(sZero.manaCost).toBe(5.0) + + const sNeg = calculateExplodingArrowStats(-3) + expect(sNeg.minBlastDamage).toBe(2) + + const sNan = calculateExplodingArrowStats(NaN) + expect(sNan.minBlastDamage).toBe(2) + + const sFrac = calculateExplodingArrowStats(8.6) + expect(sFrac.minBlastDamage).toBe(calculateExplodingArrowStats(8).minBlastDamage) + }) }) diff --git a/tests/skills/ama/skill-021-ice-arrow.test.ts b/tests/skills/ama/skill-021-ice-arrow.test.ts index 533be52..0a535cc 100644 --- a/tests/skills/ama/skill-021-ice-arrow.test.ts +++ b/tests/skills/ama/skill-021-ice-arrow.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-021-ice-arrow.ts' +import { calculateIceArrowStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #021] Ice Arrow (AMA) — 1.13c Parity (Issue #175)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,63 @@ describe('[Skill #021] Ice Arrow (AMA) — 1.13c Parity (Issue #175)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateIceArrowStats pure calculations and freeze invariants', () => { + // Level 1: 6-10 cold dmg, 50 frames freeze duration, +20% AR, 4.0 mana + const s1 = calculateIceArrowStats(1) + expect(s1.minDamage).toBe(6) + expect(s1.maxDamage).toBe(10) + expect(s1.freezeFrames).toBe(50) + expect(s1.toHitBonusPct).toBe(20) + expect(s1.manaCost).toBe(4.0) + + // Level 10: 72-78 cold dmg, 95 frames freeze duration, +101% AR, 6.25 mana + const s10 = calculateIceArrowStats(10) + expect(s10.minDamage).toBe(72) + expect(s10.maxDamage).toBe(78) + expect(s10.freezeFrames).toBe(95) + expect(s10.toHitBonusPct).toBe(101) + expect(s10.manaCost).toBe(6.25) + + // Level 20: 216-232 cold dmg, 145 frames freeze duration, +191% AR, 8.75 mana + const s20 = calculateIceArrowStats(20) + expect(s20.minDamage).toBe(216) + expect(s20.maxDamage).toBe(232) + expect(s20.freezeFrames).toBe(145) + expect(s20.toHitBonusPct).toBe(191) + expect(s20.manaCost).toBe(8.75) + + // Synergies test: Cold Arrow +8% damage/lvl, Freezing Arrow +5% freeze length/lvl + const s20Syn = calculateIceArrowStats(20, { coldArrow: 20, freezingArrow: 20 }) + expect(s20Syn.dmgSynergyMultiplier).toBe(2.6) // 1 + 20 * 0.08 + expect(s20Syn.lenSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05 + expect(s20Syn.minDamage).toBe(Math.floor(216 * 2.6)) + expect(s20Syn.maxDamage).toBe(Math.floor(232 * 2.6)) + expect(s20Syn.freezeFrames).toBe(Math.floor(145 * 2.0)) + + // Monotonicity checks + for (let lvl = 1; lvl < 30; lvl++) { + const cur = calculateIceArrowStats(lvl) + const nxt = calculateIceArrowStats(lvl + 1) + expect(nxt.minDamage).toBeGreaterThanOrEqual(cur.minDamage) + expect(nxt.maxDamage).toBeGreaterThanOrEqual(cur.maxDamage) + expect(nxt.freezeFrames).toBeGreaterThan(cur.freezeFrames) + expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct) + expect(nxt.manaCost).toBeGreaterThan(cur.manaCost) + } + + // Boundary resilience + const sZero = calculateIceArrowStats(0) + expect(sZero.minDamage).toBe(6) + expect(sZero.manaCost).toBe(4.0) + + const sNeg = calculateIceArrowStats(-2) + expect(sNeg.minDamage).toBe(6) + + const sNan = calculateIceArrowStats(NaN) + expect(sNan.minDamage).toBe(6) + + const sFrac = calculateIceArrowStats(9.4) + expect(sFrac.minDamage).toBe(calculateIceArrowStats(9).minDamage) + }) }) diff --git a/tests/skills/ama/skill-022-guided-arrow.test.ts b/tests/skills/ama/skill-022-guided-arrow.test.ts index 4b04c8a..9e4df3a 100644 --- a/tests/skills/ama/skill-022-guided-arrow.test.ts +++ b/tests/skills/ama/skill-022-guided-arrow.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-022-guided-arrow.ts' +import { calculateGuidedArrowStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #022] Guided Arrow (AMA) — 1.13c Parity (Issue #176)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,57 @@ describe('[Skill #022] Guided Arrow (AMA) — 1.13c Parity (Issue #176)', () => expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateGuidedArrowStats pure calculations and zero-pierce invariant', () => { + // Level 1: +0% ED, alwaysHits = true, canPierce = false, pierceChancePct = 0, 8.0 mana + const s1 = calculateGuidedArrowStats(1) + expect(s1.enhancedDamagePct).toBe(0) + expect(s1.alwaysHits).toBe(true) + expect(s1.autoHit).toBe(true) + expect(s1.canPierce).toBe(false) + expect(s1.pierceChancePct).toBe(0) + expect(s1.manaCost).toBe(8.0) + + // Level 10: +45% ED, 5.75 mana + const s10 = calculateGuidedArrowStats(10) + expect(s10.enhancedDamagePct).toBe(45) + expect(s10.alwaysHits).toBe(true) + expect(s10.canPierce).toBe(false) + expect(s10.manaCost).toBe(5.75) + + // Level 20: +95% ED, 3.25 mana + const s20 = calculateGuidedArrowStats(20) + expect(s20.enhancedDamagePct).toBe(95) + expect(s20.alwaysHits).toBe(true) + expect(s20.canPierce).toBe(false) + expect(s20.manaCost).toBe(3.25) + + // High level clamp: mana floor is 1.75 + const s30 = calculateGuidedArrowStats(30) + expect(s30.manaCost).toBe(1.75) + const s50 = calculateGuidedArrowStats(50) + expect(s50.manaCost).toBe(1.75) + + // Strict zero-pierce invariant at all levels + for (let lvl = 1; lvl <= 40; lvl++) { + const s = calculateGuidedArrowStats(lvl) + expect(s.canPierce).toBe(false) + expect(s.pierceChancePct).toBe(0) + expect(s.alwaysHits).toBe(true) + } + + // Boundary resilience + const sZero = calculateGuidedArrowStats(0) + expect(sZero.enhancedDamagePct).toBe(0) + expect(sZero.manaCost).toBe(8.0) + + const sNeg = calculateGuidedArrowStats(-10) + expect(sNeg.enhancedDamagePct).toBe(0) + + const sNan = calculateGuidedArrowStats(NaN) + expect(sNan.enhancedDamagePct).toBe(0) + + const sFrac = calculateGuidedArrowStats(6.5) + expect(sFrac.enhancedDamagePct).toBe(calculateGuidedArrowStats(6).enhancedDamagePct) + }) }) diff --git a/tests/skills/ama/skill-026-strafe.test.ts b/tests/skills/ama/skill-026-strafe.test.ts index a5d1a7c..65a9ccb 100644 --- a/tests/skills/ama/skill-026-strafe.test.ts +++ b/tests/skills/ama/skill-026-strafe.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-026-strafe.ts' +import { calculateStrafeStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #026] Strafe (AMA) — 1.13c Parity (Issue #180)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,53 @@ describe('[Skill #026] Strafe (AMA) — 1.13c Parity (Issue #180)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateStrafeStats pure calculations and burst invariants', () => { + // Level 1: 4 arrows, 75% weapon damage (srcdam=96), +0% ED, stationary lock-in, 11 mana + const s1 = calculateStrafeStats(1) + expect(s1.arrowCount).toBe(4) + expect(s1.damageMultiplier).toBe(0.75) + expect(s1.srcDam).toBe(96) + expect(s1.enhancedDamagePct).toBe(0) + expect(s1.stationaryLockIn).toBe(true) + expect(s1.lockInFrames).toBe(12) // 4 * 2 + 4 + expect(s1.manaCost).toBe(11) + + // Level 2: 5 arrows + const s2 = calculateStrafeStats(2) + expect(s2.arrowCount).toBe(5) + + // Level 10: 9 arrows, +45% ED, 11 mana + const s10 = calculateStrafeStats(10) + expect(s10.arrowCount).toBe(9) + expect(s10.enhancedDamagePct).toBe(45) + expect(s10.manaCost).toBe(11) + + // Level 12+: capped at 10 arrows + const s12 = calculateStrafeStats(12) + expect(s12.arrowCount).toBe(10) + const s20 = calculateStrafeStats(20) + expect(s20.arrowCount).toBe(10) + expect(s20.enhancedDamagePct).toBe(95) + expect(s20.manaCost).toBe(11) + + // Fixed mana cost invariant + for (let lvl = 1; lvl <= 30; lvl++) { + expect(calculateStrafeStats(lvl).manaCost).toBe(11) + } + + // Boundary resilience + const sZero = calculateStrafeStats(0) + expect(sZero.arrowCount).toBe(4) + expect(sZero.manaCost).toBe(11) + + const sNeg = calculateStrafeStats(-5) + expect(sNeg.arrowCount).toBe(4) + + const sNan = calculateStrafeStats(NaN) + expect(sNan.arrowCount).toBe(4) + + const sFrac = calculateStrafeStats(7.9) + expect(sFrac.arrowCount).toBe(calculateStrafeStats(7).arrowCount) + }) }) diff --git a/tests/skills/ama/skill-027-immolation-arrow.test.ts b/tests/skills/ama/skill-027-immolation-arrow.test.ts index eb16121..962cb92 100644 --- a/tests/skills/ama/skill-027-immolation-arrow.test.ts +++ b/tests/skills/ama/skill-027-immolation-arrow.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-027-immolation-arrow.ts' +import { calculateImmolationArrowStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #027] Immolation Arrow (AMA) — 1.13c Parity (Issue #181)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,66 @@ describe('[Skill #027] Immolation Arrow (AMA) — 1.13c Parity (Issue #181)', () expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateImmolationArrowStats pure calculations, cooldown, and fire patch invariants', () => { + // Level 1: 12-23 explosion fire dmg, 75 frames ground fire, 25 frames delay, +30% AR, 6.0 mana + const s1 = calculateImmolationArrowStats(1) + expect(s1.minExplosionDamage).toBe(12) + expect(s1.maxExplosionDamage).toBe(23) + expect(s1.firePatchDurationFrames).toBe(75) + expect(s1.cooldownFrames).toBe(25) + expect(s1.toHitBonusPct).toBe(30) + expect(s1.manaCost).toBe(6.0) + + // Level 10: 142-153 explosion fire dmg, +111% AR, 10.5 mana + const s10 = calculateImmolationArrowStats(10) + expect(s10.minExplosionDamage).toBe(142) + expect(s10.maxExplosionDamage).toBe(153) + expect(s10.firePatchDurationFrames).toBe(75) + expect(s10.cooldownFrames).toBe(25) + expect(s10.toHitBonusPct).toBe(111) + expect(s10.manaCost).toBe(10.5) + + // Level 20: 416-427 explosion fire dmg, +201% AR, 15.5 mana + const s20 = calculateImmolationArrowStats(20) + expect(s20.minExplosionDamage).toBe(416) + expect(s20.maxExplosionDamage).toBe(427) + expect(s20.firePatchDurationFrames).toBe(75) + expect(s20.cooldownFrames).toBe(25) + expect(s20.toHitBonusPct).toBe(201) + expect(s20.manaCost).toBe(15.5) + + // Synergies: Fire Arrow (+5% explosion dmg, +10% patch dmg per level) + const s20Syn = calculateImmolationArrowStats(20, { fireArrow: 20 }) + expect(s20Syn.explosionSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05 + expect(s20Syn.firePatchSynergyMultiplier).toBe(3.0) // 1 + 20 * 0.10 + expect(s20Syn.minExplosionDamage).toBe(Math.floor(416 * 2.0)) + expect(s20Syn.maxExplosionDamage).toBe(Math.floor(427 * 2.0)) + + // Monotonicity checks + for (let lvl = 1; lvl < 30; lvl++) { + const cur = calculateImmolationArrowStats(lvl) + const nxt = calculateImmolationArrowStats(lvl + 1) + expect(nxt.minExplosionDamage).toBeGreaterThanOrEqual(cur.minExplosionDamage) + expect(nxt.maxExplosionDamage).toBeGreaterThanOrEqual(cur.maxExplosionDamage) + expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct) + expect(nxt.manaCost).toBeGreaterThan(cur.manaCost) + expect(nxt.cooldownFrames).toBe(25) + expect(nxt.firePatchDurationFrames).toBe(75) + } + + // Boundary resilience + const sZero = calculateImmolationArrowStats(0) + expect(sZero.minExplosionDamage).toBe(12) + expect(sZero.manaCost).toBe(6.0) + + const sNeg = calculateImmolationArrowStats(-4) + expect(sNeg.minExplosionDamage).toBe(12) + + const sNan = calculateImmolationArrowStats(NaN) + expect(sNan.minExplosionDamage).toBe(12) + + const sFrac = calculateImmolationArrowStats(11.3) + expect(sFrac.minExplosionDamage).toBe(calculateImmolationArrowStats(11).minExplosionDamage) + }) }) diff --git a/tests/skills/ama/skill-031-freezing-arrow.test.ts b/tests/skills/ama/skill-031-freezing-arrow.test.ts index 9252178..d356dc0 100644 --- a/tests/skills/ama/skill-031-freezing-arrow.test.ts +++ b/tests/skills/ama/skill-031-freezing-arrow.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-031-freezing-arrow.ts' +import { calculateFreezingArrowStats } from '../../../src/game/skills/amazon-bow.ts' describe('[Skill #031] Freezing Arrow (AMA) — 1.13c Parity (Issue #185)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,64 @@ describe('[Skill #031] Freezing Arrow (AMA) — 1.13c Parity (Issue #185)', () = expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateFreezingArrowStats pure calculations, splash, and freeze invariants', () => { + // Level 1: 40-50 cold dmg, 80 sub-pixel AoE, 50 frames freeze duration, +40% AR, 9.0 mana + const s1 = calculateFreezingArrowStats(1) + expect(s1.minColdDamage).toBe(40) + expect(s1.maxColdDamage).toBe(50) + expect(s1.radiusPx).toBe(80) + expect(s1.freezeFrames).toBe(50) + expect(s1.toHitBonusPct).toBe(40) + expect(s1.manaCost).toBe(9.0) + + // Level 10: 140-150 cold dmg, +121% AR, 13.5 mana + const s10 = calculateFreezingArrowStats(10) + expect(s10.minColdDamage).toBe(140) + expect(s10.maxColdDamage).toBe(150) + expect(s10.freezeFrames).toBe(50) + expect(s10.toHitBonusPct).toBe(121) + expect(s10.manaCost).toBe(13.5) + + // Level 20: 310-320 cold dmg, +211% AR, 18.5 mana + const s20 = calculateFreezingArrowStats(20) + expect(s20.minColdDamage).toBe(310) + expect(s20.maxColdDamage).toBe(320) + expect(s20.freezeFrames).toBe(50) + expect(s20.toHitBonusPct).toBe(211) + expect(s20.manaCost).toBe(18.5) + + // Synergies: Cold Arrow (+12% cold damage/lvl), Ice Arrow (+5% freeze length/lvl) + const s20Syn = calculateFreezingArrowStats(20, { coldArrow: 20, iceArrow: 20 }) + expect(s20Syn.coldSynergyMultiplier).toBe(3.4) // 1 + 20 * 0.12 + expect(s20Syn.lenSynergyMultiplier).toBe(2.0) // 1 + 20 * 0.05 + expect(s20Syn.minColdDamage).toBe(Math.floor(310 * 3.4)) + expect(s20Syn.maxColdDamage).toBe(Math.floor(320 * 3.4)) + expect(s20Syn.freezeFrames).toBe(Math.floor(50 * 2.0)) + + // Monotonicity checks + for (let lvl = 1; lvl < 30; lvl++) { + const cur = calculateFreezingArrowStats(lvl) + const nxt = calculateFreezingArrowStats(lvl + 1) + expect(nxt.minColdDamage).toBeGreaterThanOrEqual(cur.minColdDamage) + expect(nxt.maxColdDamage).toBeGreaterThanOrEqual(cur.maxColdDamage) + expect(nxt.toHitBonusPct).toBeGreaterThan(cur.toHitBonusPct) + expect(nxt.manaCost).toBeGreaterThan(cur.manaCost) + expect(nxt.radiusPx).toBe(80) + } + + // Boundary resilience + const sZero = calculateFreezingArrowStats(0) + expect(sZero.minColdDamage).toBe(40) + expect(sZero.manaCost).toBe(9.0) + + const sNeg = calculateFreezingArrowStats(-7) + expect(sNeg.minColdDamage).toBe(40) + + const sNan = calculateFreezingArrowStats(NaN) + expect(sNan.minColdDamage).toBe(40) + + const sFrac = calculateFreezingArrowStats(15.8) + expect(sFrac.minColdDamage).toBe(calculateFreezingArrowStats(15).minColdDamage) + }) })