diablo2-web/tests/skills/ama/adv-ama-challenger-javelin-...

951 lines
36 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Challenger Adversarial Stress Suite 2
* Focus: Amazon Javelin & Summons Parity Verification
*
* Targeted Systems:
* 1. Lightning Fury (#035): Charged bolt cascade on impact, multi-target emission, and 4-pierce cascade recursion.
* 2. Lightning Strike (#034): Chain hopping across targets with 4-frame NextHitDelay and range bounds.
* 3. Decoy (#028): Threat drawing, life scaling from player HP, max resist cap (85%), and stationary posture.
* 4. Valkyrie (#032): Base life scaling, Decoy synergy (+20% life per hard point), equipment tiering, 6.0s cooldown.
* 5. Slow Missiles (#017): Exact 33% velocity multiplier and duration scaling.
* 6. Inner Sight (#008): Exact flat defense strip scaling and combat to-hit integration.
*
* Ground Truth Invariants:
* - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt
* - Zero mocks utilized (vi.mock strictly forbidden).
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/game/engine/missile-engine.ts'
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
import {
computeToHitChance,
type CombatUnitContext,
type SUnitDmgPacket,
} from '../../../src/game/engine/combat-pipeline.ts'
import {
calculateLightningFuryStats,
calculateLightningStrikeStats,
} from '../../../src/game/skills/amazon-javelin-spear.ts'
import {
calculateDecoyStats,
calculateValkyrieStats,
calculateSlowMissilesStats,
calculateInnerSightStats,
type ValkyrieEquipmentTier,
} from '../../../src/game/skills/amazon-passive.ts'
import { getSkillCooldownTicks } from '../../../src/game/skills.ts'
function createCombatUnit(
id: string,
pos: { x: number; y: number },
registry: any,
opts?: {
hp?: number
def?: number
ar?: number
level?: number
isMoving?: boolean
}
): CombatUnitContext {
const hp = opts?.hp ?? 1000
const statList = new UnitStatList(registry, {
level: opts?.level ?? 80,
hitpoints: hp * FIXED_ONE,
maxhp: hp * FIXED_ONE,
armorclass: opts?.def ?? 400,
tohit: opts?.ar ?? 1000,
fireresist: 0,
coldresist: 0,
lightresist: 0,
poisonresist: 0,
damageresist: 0,
magicresist: 0,
x: pos.x,
y: pos.y,
})
const stateBus = new StateBus(statList, registry)
return {
id,
name: id,
statList,
stateBus,
isMoving: opts?.isMoving ?? false,
x: pos.x,
y: pos.y,
weaponMinPhys: 50,
weaponMaxPhys: 100,
}
}
describe('Challenger Adversarial Stress Suite 2 — Amazon Javelin & Summons (1.13c Ground Truth)', () => {
// =========================================================================
// 1. LIGHTNING FURY (Skill 35)
// =========================================================================
describe('1. Lightning Fury: Impact Cascade & Pierce Recursion', () => {
it('spawns radial lightningjavelin sub-missiles to all other nearby alive enemies upon impact', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const amazon = createCombatUnit('amazon', { x: 0, y: 0 }, registry)
const target1 = createCombatUnit('enemy-1', { x: 100, y: 0 }, registry)
const target2 = createCombatUnit('enemy-2', { x: 120, y: 50 }, registry)
const target3 = createCombatUnit('enemy-3', { x: 80, y: -40 }, registry)
const deadTarget = createCombatUnit('enemy-dead', { x: 110, y: 20 }, registry, { hp: 0 })
deadTarget.statList.setHp256(0)
const dmgPacket: SUnitDmgPacket = {
skillId: 35,
attackKind: 'missile',
elemMin256: 1 * FIXED_ONE,
elemMax256: 40 * FIXED_ONE,
}
// Spawn primary Lightning Fury javelin traveling towards target1
const lf = missileEngine.spawnMissile({
missileNameOrId: 'lightningfury',
sourceSkillId: 35,
slvl: 20,
owner: amazon,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket,
})!
expect(lf).toBeDefined()
expect(lf.name).toBe('lightningfury')
const targets = [target1, target2, target3, deadTarget]
const targetPositions = new Map([
['enemy-1', { x: 100, y: 0 }],
['enemy-2', { x: 120, y: 50 }],
['enemy-3', { x: 80, y: -40 }],
['enemy-dead', { x: 110, y: 20 }],
])
// Step missile until it collides with target1
let impactTick = -1
for (let t = 0; t < 20; t++) {
const res = missileEngine.tick(t, targets, targetPositions)
if (res.hits.length > 0) {
impactTick = t
break
}
}
expect(impactTick).toBeGreaterThanOrEqual(0)
// Examine active missiles: should contain the primary LF javelin and spawned sub-bolts
const activeMissiles = (missileEngine as any).missiles as any[]
const subBolts = activeMissiles.filter(
(m: any) => (m.name === 'furylightning' || m.name === 'lightningjavelin') && !m.expired
)
// Exactly 2 sub-bolts spawned (towards target2 and target3; deadTarget and self target1 excluded)
expect(subBolts.length).toBe(2)
for (const bolt of subBolts) {
expect(bolt.sourceSkillId).toBe(35)
expect(bolt.slvl).toBe(20)
expect(bolt.owner.id).toBe('amazon')
}
})
it('cascades independent bolt bursts on EACH pierced target up to the 4-pierce cap (5 bursts total)', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const amazon = createCombatUnit('amazon', { x: 0, y: 0 }, registry)
// Set up 5 enemies in a linear row along the flight path
const lineEnemies = [
createCombatUnit('line-1', { x: 60, y: 0 }, registry),
createCombatUnit('line-2', { x: 120, y: 0 }, registry),
createCombatUnit('line-3', { x: 180, y: 0 }, registry),
createCombatUnit('line-4', { x: 240, y: 0 }, registry),
createCombatUnit('line-5', { x: 300, y: 0 }, registry),
]
// 6th enemy beyond max pierce cap
const line6 = createCombatUnit('line-6', { x: 360, y: 0 }, registry)
// Side flank enemy that receives radial bolts at every pierce location
const flankEnemy = createCombatUnit('flank', { x: 180, y: 80 }, registry)
const allTargets = [...lineEnemies, line6, flankEnemy]
const targetPositions = new Map<string, { x: number; y: number }>()
for (const tgt of allTargets) {
targetPositions.set(tgt.id, { x: tgt.x ?? 0, y: tgt.y ?? 0 })
}
const dmgPacket: SUnitDmgPacket = {
skillId: 35,
attackKind: 'missile',
elemMin256: 1 * FIXED_ONE,
elemMax256: 40 * FIXED_ONE,
}
// Primary javelin with 100% pierce chance
const lf = missileEngine.spawnMissile({
missileNameOrId: 'lightningfury',
sourceSkillId: 35,
slvl: 20,
owner: amazon,
startX: 0,
startY: 0,
targetX: 500,
targetY: 0,
dmgPacket,
pierceChancePct: 100,
})!
expect(lf.canPierce).toBe(true)
expect(lf.pierceChancePct).toBe(100)
let totalPrimaryHits = 0
let totalSubMissilesSpawned = 0
// Step the simulation tick by tick
for (let t = 0; t < 60; t++) {
const res = missileEngine.tick(t, allTargets, targetPositions)
for (const h of res.hits) {
if (h.missileName === 'lightningfury') {
totalPrimaryHits++
}
}
totalSubMissilesSpawned += res.subMissilesSpawned.filter(
name => name === 'furylightning' || name === 'lightningjavelin'
).length
}
// 1.13c Invariant: Max pierce count is 4 (hits exactly 5 targets: line-1..line-5)
expect(totalPrimaryHits).toBe(5)
expect(lf.pierceCount).toBe(4)
expect(lf.expired).toBe(true) // Killed after hitting 5th target
// Each of the 5 hits triggered a cascade of radial sub-bolts
expect(totalSubMissilesSpawned).toBeGreaterThanOrEqual(5)
// line-6 was beyond the 4-pierce cap and must NOT have been hit by the primary javelin
expect(lf.hitTargetIds.has('line-6')).toBe(false)
})
it('dies on first hit when pierce is 0%, spawning only 1 cascade', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const amazon = createCombatUnit('amazon', { x: 0, y: 0 }, registry)
const e1 = createCombatUnit('e1', { x: 60, y: 0 }, registry)
const e2 = createCombatUnit('e2', { x: 120, y: 0 }, registry)
const flank = createCombatUnit('flank', { x: 60, y: 60 }, registry)
const targets = [e1, e2, flank]
const targetPositions = new Map([
['e1', { x: 60, y: 0 }],
['e2', { x: 120, y: 0 }],
['flank', { x: 60, y: 60 }],
])
const dmgPacket: SUnitDmgPacket = {
skillId: 35,
attackKind: 'missile',
elemMin256: 1 * FIXED_ONE,
elemMax256: 40 * FIXED_ONE,
}
// Primary javelin with 0% pierce
const lf = missileEngine.spawnMissile({
missileNameOrId: 'lightningfury',
sourceSkillId: 35,
slvl: 20,
owner: amazon,
startX: 0,
startY: 0,
targetX: 300,
targetY: 0,
dmgPacket,
pierceChancePct: 0,
})!
let primaryHits = 0
for (let t = 0; t < 30; t++) {
const res = missileEngine.tick(t, targets, targetPositions)
primaryHits += res.hits.filter(h => h.missileName === 'lightningfury').length
}
expect(primaryHits).toBe(1)
expect(lf.hitTargetIds.has('e1')).toBe(true)
expect(lf.hitTargetIds.has('e2')).toBe(false)
expect(lf.pierceCount).toBe(0)
expect(lf.expired).toBe(true)
})
it('differential oracle test for 5-band damage progression across slvl 1..50', () => {
// Differential oracle for 5-band lightning damage: 1 - 40 base, scaling +20/30/40/50/50
function oracleLfMaxDamage(lvl: number): number {
let max = 40
if (lvl <= 1) return max
const b1 = Math.min(lvl, 8) - 1
max += b1 * 20
if (lvl <= 8) return max
const b2 = Math.min(lvl, 16) - 8
max += b2 * 30
if (lvl <= 16) return max
const b3 = Math.min(lvl, 22) - 16
max += b3 * 40
if (lvl <= 22) return max
const b4 = Math.min(lvl, 28) - 22
max += b4 * 50
if (lvl <= 28) return max
const b5 = lvl - 28
max += b5 * 50
return max
}
for (let lvl = 1; lvl <= 50; lvl++) {
const stats = calculateLightningFuryStats(lvl)
expect(stats.baseMinDamage).toBe(1)
expect(stats.baseMaxDamage).toBe(oracleLfMaxDamage(lvl))
expect(stats.releaseBoltsCount).toBe(lvl)
expect(stats.searchRadiusPx).toBe(200)
}
})
})
// =========================================================================
// 2. LIGHTNING STRIKE (Skill 34)
// =========================================================================
describe('2. Lightning Strike: Chain Hopping & 4-Frame NextHitDelay', () => {
it('verifies chain hops formula min(24, slvl + 1) with exact 24-hop cap', () => {
expect(calculateLightningStrikeStats(1).chainHops).toBe(2)
expect(calculateLightningStrikeStats(5).chainHops).toBe(6)
expect(calculateLightningStrikeStats(10).chainHops).toBe(11)
expect(calculateLightningStrikeStats(20).chainHops).toBe(21)
expect(calculateLightningStrikeStats(23).chainHops).toBe(24) // 23 + 1 = 24
expect(calculateLightningStrikeStats(24).chainHops).toBe(24) // capped
expect(calculateLightningStrikeStats(30).chainHops).toBe(24) // capped
expect(calculateLightningStrikeStats(99).chainHops).toBe(24) // capped
expect(calculateLightningStrikeStats(1).nextHitDelayFrames).toBe(4)
})
it('enforces 4-frame NextHitDelay immunity window on struck targets', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const amazon = createCombatUnit('amazon', { x: 0, y: 0 }, registry)
const target = createCombatUnit('target', { x: 50, y: 0 }, registry)
const targets = [target]
const targetPositions = new Map([['target', { x: 50, y: 0 }]])
const dmgPacket: SUnitDmgPacket = {
skillId: 34,
attackKind: 'missile',
elemMin256: 10 * FIXED_ONE,
elemMax256: 20 * FIXED_ONE,
}
// Spawn a missile with nextDelay = 4 (like lightningstrike / chainlightning)
const m1 = missileEngine.spawnMissile({
missileNameOrId: 'chainlightning',
sourceSkillId: 34,
slvl: 20,
owner: amazon,
startX: 40,
startY: 0,
targetX: 50,
targetY: 0,
dmgPacket,
})!
expect(m1.nextDelayFrames).toBe(4)
// Tick 0: m1 hits target at tick 0 -> sets NextHitDelay expiration to tick 0 + 4 = 4
const res0 = missileEngine.tick(0, targets, targetPositions)
expect(res0.hits.length).toBe(1)
expect(missileEngine.isTargetInNextDelay('target', 0)).toBe(true)
expect(missileEngine.isTargetInNextDelay('target', 1)).toBe(true)
expect(missileEngine.isTargetInNextDelay('target', 2)).toBe(true)
expect(missileEngine.isTargetInNextDelay('target', 3)).toBe(true)
// At tick 4, NextHitDelay must expire:
expect(missileEngine.isTargetInNextDelay('target', 4)).toBe(false)
// Adversarial test: Spawn second missile trying to hit during immunity window (tick 2)
const m2 = missileEngine.spawnMissile({
missileNameOrId: 'chainlightning',
sourceSkillId: 34,
slvl: 20,
owner: amazon,
startX: 40,
startY: 0,
targetX: 50,
targetY: 0,
dmgPacket,
})!
const res2 = missileEngine.tick(2, targets, targetPositions)
// m2 MUST NOT hit target because target is in NextHitDelay!
expect(res2.hits.length).toBe(0)
expect(m2.hitTargetIds.has('target')).toBe(false)
// At tick 4 (after expiration), target can be hit again
const m3 = missileEngine.spawnMissile({
missileNameOrId: 'chainlightning',
sourceSkillId: 34,
slvl: 20,
owner: amazon,
startX: 40,
startY: 0,
targetX: 50,
targetY: 0,
dmgPacket,
})!
const res4 = missileEngine.tick(4, targets, targetPositions)
expect(res4.hits.length).toBe(1)
expect(m3.hitTargetIds.has('target')).toBe(true)
})
it('chain leaps between nearby targets within 400px search radius, excluding last-hit target', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const amazon = createCombatUnit('amazon', { x: 0, y: 0 }, registry)
const targetA = createCombatUnit('target-A', { x: 50, y: 0 }, registry)
const targetB = createCombatUnit('target-B', { x: 150, y: 0 }, registry) // 100px away from A (within 400px)
const targetFar = createCombatUnit('target-Far', { x: 900, y: 0 }, registry) // 750px away from B (out of 400px)
const targets = [targetA, targetB, targetFar]
const targetPositions = new Map([
['target-A', { x: 50, y: 0 }],
['target-B', { x: 150, y: 0 }],
['target-Far', { x: 900, y: 0 }],
])
const dmgPacket: SUnitDmgPacket = {
skillId: 34,
attackKind: 'missile',
elemMin256: 10 * FIXED_ONE,
elemMax256: 20 * FIXED_ONE,
}
// Initial strike against targetA with 5 chain hops
missileEngine.spawnMissile({
missileNameOrId: 'chainlightning',
sourceSkillId: 34,
slvl: 20,
owner: amazon,
startX: 40,
startY: 0,
targetX: 50,
targetY: 0,
dmgPacket,
chainCount: 5,
})
// Tick 0: Hits targetA, spawns chain leap to targetB
const res0 = missileEngine.tick(0, targets, targetPositions)
expect(res0.hits.length).toBe(1)
expect(res0.hits[0].targetId).toBe('target-A')
expect(res0.subMissilesSpawned.length).toBe(1) // Chain leap to target-B
// Examine active missile spawned for chain leap
const activeMissiles = (missileEngine as any).missiles as any[]
const chainMissile = activeMissiles.find((m: any) => !m.expired && m.lastHitTargetId === 'target-A')
expect(chainMissile).toBeDefined()
expect(chainMissile.chainRemaining).toBe(4) // Decremented from 5 to 4
})
})
// =========================================================================
// 3. DECOY (Skill 28)
// =========================================================================
describe('3. Decoy: Threat Drawing, HP Scaling & 85% Resist Cap', () => {
it('scales Decoy life from Amazon HP using exact formula HP * (0.5 + 0.1 * slvl)', async () => {
const registry = await getSharedDataRegistry()
const summonManager = new SummonManager(registry)
// Test across multiple Amazon HP values and slvls
const testCases = [
{ playerHp: 500, slvl: 1, expectedPct: 60, expectedHp: 300 },
{ playerHp: 500, slvl: 5, expectedPct: 100, expectedHp: 500 },
{ playerHp: 1000, slvl: 10, expectedPct: 150, expectedHp: 1500 },
{ playerHp: 1200, slvl: 20, expectedPct: 250, expectedHp: 3000 },
{ playerHp: 2450, slvl: 30, expectedPct: 350, expectedHp: 8575 },
]
for (const tc of testCases) {
const stats = calculateDecoyStats(tc.slvl, tc.playerHp)
expect(stats.hpPctOfAmazon).toBe(tc.expectedPct)
expect(stats.hp).toBe(tc.expectedHp)
// Runtime creation in SummonManager
const amazon = createCombatUnit('amazon', { x: 0, y: 0 }, registry, { hp: tc.playerHp })
const res = summonManager.createPet({
owner: amazon,
skillId: 28,
slvl: tc.slvl,
})
expect(res.created).toBe(true)
expect(res.pet!.hp).toBe(tc.expectedHp)
expect(res.pet!.maxHp).toBe(tc.expectedHp)
expect(res.pet!.kind).toBe('dopplezon')
expect(res.pet!.isStationary).toBe(true)
}
})
it('strictly clamps Decoy allResistances to 85% maximum (min(85, 4 * slvl))', async () => {
const registry = await getSharedDataRegistry()
const summonManager = new SummonManager(registry)
// Sweep slvl 1..50
for (let lvl = 1; lvl <= 50; lvl++) {
const stats = calculateDecoyStats(lvl)
const expectedRes = Math.min(85, 4 * lvl)
expect(stats.allResistancesPct).toBe(expectedRes)
if (lvl >= 22) {
expect(stats.allResistancesPct).toBe(85) // Capped at 85%
}
}
// Check on runtime SummonedPetUnit
const amazon = createCombatUnit('amazon', { x: 0, y: 0 }, registry, { hp: 1000 })
const resLvl25 = summonManager.createPet({
owner: amazon,
skillId: 28,
slvl: 25, // 4 * 25 = 100% -> must clamp to 85%
})
const pet = resLvl25.pet!
expect(pet.statList.getAccruedStat('fireresist')).toBe(85)
expect(pet.statList.getAccruedStat('lightresist')).toBe(85)
expect(pet.statList.getAccruedStat('coldresist')).toBe(85)
expect(pet.statList.getAccruedStat('poisonresist')).toBe(85)
})
it('enforces stationary posture and threat-drawing contract (cannot move or attack)', async () => {
const registry = await getSharedDataRegistry()
const summonManager = new SummonManager(registry)
const amazon = createCombatUnit('amazon', { x: 100, y: 100 }, registry)
const outcome = summonManager.createPet({
owner: amazon,
skillId: 28,
slvl: 10,
x: 120,
y: 100,
})
const decoy = outcome.pet!
expect(decoy.isStationary).toBe(true)
expect(decoy.kind).toBe('dopplezon')
// Ticking Decoy in combat with surrounding enemies yields 0 attacks
const enemy = createCombatUnit('enemy', { x: 125, y: 100 }, registry)
const tickOutcome = summonManager.tickPets({
currentTick: 1,
owner: amazon,
enemies: [enemy],
enemyPositions: new Map([['enemy', { x: 125, y: 100 }]]),
missileEngine: new MissileEngine(registry),
auraScanner: new AuraScanner(registry),
})
expect(tickOutcome.petAttacks).toBe(0)
})
})
// =========================================================================
// 4. VALKYRIE (Skill 32)
// =========================================================================
describe('4. Valkyrie: Life Scaling, Decoy Synergy, Tiering & 6.0s Cooldown', () => {
it('scales base life accurately per 440 * (1 + 0.2 * (slvl - 1))', () => {
expect(calculateValkyrieStats(1).baseHp).toBe(440)
expect(calculateValkyrieStats(5).baseHp).toBe(792) // 440 * 1.8
expect(calculateValkyrieStats(10).baseHp).toBe(1232) // 440 * 2.8
expect(calculateValkyrieStats(20).baseHp).toBe(2112) // 440 * 4.8
expect(calculateValkyrieStats(30).baseHp).toBe(2992) // 440 * 6.8
})
it('grants +20% life per hard point in Decoy synergy and isolates soft points', async () => {
const registry = await getSharedDataRegistry()
const summonManager = new SummonManager(registry)
// Test 0, 1, 5, 10, 20 hard points
const testSynergies = [
{ hardPts: 0, mult: 1.0 },
{ hardPts: 1, mult: 1.2 },
{ hardPts: 5, mult: 2.0 },
{ hardPts: 10, mult: 3.0 },
{ hardPts: 20, mult: 5.0 },
]
for (const { hardPts, mult } of testSynergies) {
const stats = calculateValkyrieStats(10, { decoyHardPoints: hardPts })
expect(stats.decoySynergyBonusPct).toBe(hardPts * 20)
expect(stats.finalHp).toBe(Math.floor(1232 * mult))
const amazon = createCombatUnit('amazon', { x: 0, y: 0 }, registry)
amazon.statList.setBaseSkillLevel(28, hardPts)
const petRes = summonManager.createPet({
owner: amazon,
skillId: 32,
slvl: 10,
})
expect(petRes.pet!.hp).toBe(Math.floor(1232 * mult))
}
// Soft Points Isolation: +20 allskills with 0 hard points MUST yield 0% synergy
const amazonSoftOnly = createCombatUnit('amazon-soft', { x: 0, y: 0 }, registry)
amazonSoftOnly.statList.setBaseSkillLevel(28, 0)
amazonSoftOnly.statList.addStat('item_allskills', 20)
const softRes = summonManager.createPet({
owner: amazonSoftOnly,
skillId: 32,
slvl: 10,
})
// HP must strictly equal base 1232 HP without synergy
expect(softRes.pet!.hp).toBe(1232)
})
it('verifies 5 equipment generation tiers across level boundaries', () => {
function getExpectedTier(slvl: number): ValkyrieEquipmentTier {
if (slvl >= 27) return 'tier5_rare_tiara'
if (slvl >= 17) return 'tier4_war_pike'
if (slvl >= 11) return 'tier3_gloves'
if (slvl >= 7) return 'tier2_boots'
return 'tier1_basic'
}
// Exhaustive boundary testing
for (let lvl = 1; lvl <= 40; lvl++) {
expect(calculateValkyrieStats(lvl).equipmentTier).toBe(getExpectedTier(lvl))
}
// Explicit boundary assertions
expect(calculateValkyrieStats(6).equipmentTier).toBe('tier1_basic')
expect(calculateValkyrieStats(7).equipmentTier).toBe('tier2_boots')
expect(calculateValkyrieStats(10).equipmentTier).toBe('tier2_boots')
expect(calculateValkyrieStats(11).equipmentTier).toBe('tier3_gloves')
expect(calculateValkyrieStats(16).equipmentTier).toBe('tier3_gloves')
expect(calculateValkyrieStats(17).equipmentTier).toBe('tier4_war_pike')
expect(calculateValkyrieStats(26).equipmentTier).toBe('tier4_war_pike')
expect(calculateValkyrieStats(27).equipmentTier).toBe('tier5_rare_tiara')
})
it('strictly enforces 150-frame (6.0s) casting delay cooldown', () => {
expect(getSkillCooldownTicks(32)).toBe(150)
for (let lvl = 1; lvl <= 30; lvl++) {
const stats = calculateValkyrieStats(lvl)
expect(stats.cooldownFrames).toBe(150)
expect(stats.cooldownSeconds).toBe(6.0)
}
})
})
// =========================================================================
// 5. SLOW MISSILES (Skill 17)
// =========================================================================
describe('5. Slow Missiles: Exact 33% Velocity Multiplier & Duration', () => {
it('multiplies projectile velocity by exactly 0.33 (67% reduction)', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const caster = createCombatUnit('caster', { x: 0, y: 0 }, registry)
const dmgPacket: SUnitDmgPacket = {
skillId: 0,
attackKind: 'missile',
flatPhysMin256: 10 * FIXED_ONE,
flatPhysMax256: 10 * FIXED_ONE,
}
// Baseline normal missile
const normal = missileEngine.spawnMissile({
missileNameOrId: 'arrow',
sourceSkillId: 0,
slvl: 1,
owner: caster,
startX: 0,
startY: 0,
targetX: 400,
targetY: 200,
dmgPacket,
})!
// Slowed missile
const slowed = missileEngine.spawnMissile({
missileNameOrId: 'arrow',
sourceSkillId: 0,
slvl: 1,
owner: caster,
startX: 0,
startY: 0,
targetX: 400,
targetY: 200,
dmgPacket,
isSlowed: true,
slowMultiplier: 0.33,
})!
// Step for 10 ticks
for (let t = 0; t < 10; t++) {
missileEngine.tick(t, [], new Map())
}
// Verify exact 0.33 displacement ratio in 2:1 isometric space
expect(slowed.x).toBeCloseTo(normal.x * 0.33, 1)
expect(slowed.y).toBeCloseTo(normal.y * 0.33, 1)
})
it('scales duration linearly per 300 + 60 * (slvl - 1) frames (12.0s + 2.4s/lvl)', () => {
for (let lvl = 1; lvl <= 30; lvl++) {
const stats = calculateSlowMissilesStats(lvl)
const expectedFrames = 300 + 60 * (lvl - 1)
expect(stats.durationFrames).toBe(expectedFrames)
expect(stats.durationSeconds).toBe(expectedFrames / 25)
expect(stats.velocityMultiplier).toBe(0.33)
expect(stats.velocityReductionPct).toBe(67)
expect(stats.radiusPx).toBe(200)
expect(stats.manaCost).toBe(5)
}
})
})
// =========================================================================
// 6. INNER SIGHT (Skill 8)
// =========================================================================
describe('6. Inner Sight: Exact Flat Defense Strip Scaling', () => {
it('scales flat defense reduction per -40 - 25 * (slvl - 1)', () => {
expect(calculateInnerSightStats(1).flatDefenseReduction).toBe(-40)
expect(calculateInnerSightStats(5).flatDefenseReduction).toBe(-140)
expect(calculateInnerSightStats(10).flatDefenseReduction).toBe(-265)
expect(calculateInnerSightStats(20).flatDefenseReduction).toBe(-515)
expect(calculateInnerSightStats(30).flatDefenseReduction).toBe(-765)
})
it('scales duration per 700 + 150 * (slvl - 1) frames (28.0s + 6.0s/lvl)', () => {
for (let lvl = 1; lvl <= 30; lvl++) {
const stats = calculateInnerSightStats(lvl)
const expectedFrames = 700 + 150 * (lvl - 1)
expect(stats.durationFrames).toBe(expectedFrames)
expect(stats.durationSeconds).toBe(expectedFrames / 25)
expect(stats.radiusPx).toBe(200)
expect(stats.manaCost).toBe(5)
}
})
it('correctly integrates with combat to-hit chance and clamps defense at 0', () => {
const stats20 = calculateInnerSightStats(20) // -515 flat defense
const baseMonsterDef = 400
// When flat reduction (-515) exceeds base defense (400), defense is clamped to 0
const debuffedDef = Math.max(0, baseMonsterDef + stats20.flatDefenseReduction)
expect(debuffedDef).toBe(0)
// Compute to-hit chance with 0 defense: should hit the 95% ceiling (alvl=80, dlvl=80, ar=1000)
const hitChance = computeToHitChance({
attackerAr: 1000,
defenderDef: debuffedDef,
attackerLvl: 80,
defenderLvl: 80,
})
expect(hitChance).toBe(95) // Max 1.13c hit chance ceiling
// Monster with high defense (1000 defense)
const highDef = 1000
const hitChanceBefore = computeToHitChance({
attackerAr: 1000,
defenderDef: highDef,
attackerLvl: 80,
defenderLvl: 80,
})
const hitChanceAfter = computeToHitChance({
attackerAr: 1000,
defenderDef: highDef + stats20.flatDefenseReduction, // 1000 - 515 = 485
attackerLvl: 80,
defenderLvl: 80,
})
expect(hitChanceBefore).toBe(50) // (2*1000*100*80)/((1000+1000)*160) = 50%
expect(hitChanceAfter).toBe(67) // (2*1000*100*80)/((1000+485)*160) = 67%
expect(hitChanceAfter).toBeGreaterThan(hitChanceBefore)
})
})
// =========================================================================
// 7. ADVERSARIAL FUZZING & DIFFERENTIAL ORACLES (Playbook Compliant)
// =========================================================================
describe('7. Adversarial Fuzzing & Differential Oracle Harnesses', () => {
it('Phase 2 Fuzzing: Lightning Fury & Lightning Strike synergies over 100 random allocations', () => {
// Linear Congruential Generator for reproducible deterministic fuzzing
let seed = 123456789
function nextRandom(): number {
seed = (1103515245 * seed + 12345) & 0x7fffffff
return seed / 0x7fffffff
}
for (let i = 0; i < 100; i++) {
const slvl = Math.floor(nextRandom() * 40) + 1
const ps = Math.floor(nextRandom() * 21) // 0..20 hard points
const lb = Math.floor(nextRandom() * 21)
const cs = Math.floor(nextRandom() * 21)
const lfOrLs = Math.floor(nextRandom() * 21)
// 1. Lightning Fury: 1% per point from PS, LB, CS, LS
const lfStats = calculateLightningFuryStats(slvl, {
powerStrike: ps,
lightningBolt: lb,
chargedStrike: cs,
lightningStrike: lfOrLs,
})
const expectedLfBonus = ps + lb + cs + lfOrLs
expect(lfStats.synergyBonusPct).toBe(expectedLfBonus)
expect(lfStats.synergyMultiplier).toBeCloseTo(1.0 + expectedLfBonus / 100, 5)
expect(lfStats.maxDamage).toBe(Math.floor(lfStats.baseMaxDamage * lfStats.synergyMultiplier))
// 2. Lightning Strike: 8% per point from PS, LB, CS, LF
const lsStats = calculateLightningStrikeStats(slvl, {
powerStrike: ps,
lightningBolt: lb,
chargedStrike: cs,
lightningFury: lfOrLs,
})
const expectedLsBonus = 8 * (ps + lb + cs + lfOrLs)
expect(lsStats.synergyBonusPct).toBe(expectedLsBonus)
expect(lsStats.synergyMultiplier).toBeCloseTo(1.0 + expectedLsBonus / 100, 5)
expect(lsStats.maxDamage).toBe(Math.floor(lsStats.baseMaxDamage * lsStats.synergyMultiplier))
}
})
it('Phase 2 Fuzzing: Decoy HP & Resistance bounds over 200 random HP and slvl combinations', () => {
let seed = 987654321
function nextRandom(): number {
seed = (1103515245 * seed + 12345) & 0x7fffffff
return seed / 0x7fffffff
}
for (let i = 0; i < 200; i++) {
const playerHp = Math.floor(nextRandom() * 8000) + 10 // 10..8010 HP
const slvl = Math.floor(nextRandom() * 60) + 1 // 1..60 slvl
const stats = calculateDecoyStats(slvl, playerHp)
// Oracle verification
const expectedPct = Math.round((0.5 + 0.1 * slvl) * 100)
const expectedHp = Math.floor(playerHp * (0.5 + 0.1 * slvl))
const expectedRes = Math.min(85, 4 * slvl)
expect(stats.hpPctOfAmazon).toBe(expectedPct)
expect(stats.hp).toBe(expectedHp)
expect(stats.allResistancesPct).toBe(expectedRes)
expect(stats.allResistancesPct).toBeLessThanOrEqual(85)
}
})
it('Phase 2 Fuzzing: Valkyrie Life & Synergy differential testing over 200 combinations', () => {
let seed = 555666777
function nextRandom(): number {
seed = (1103515245 * seed + 12345) & 0x7fffffff
return seed / 0x7fffffff
}
for (let i = 0; i < 200; i++) {
const slvl = Math.floor(nextRandom() * 50) + 1
const decoyHardPoints = Math.floor(nextRandom() * 30) // 0..29
const stats = calculateValkyrieStats(slvl, { decoyHardPoints })
// Differential oracle
const oracleBaseHp = Math.floor(440 * (1 + 0.2 * (slvl - 1)))
const oracleDecoyBonus = decoyHardPoints * 20
const oracleFinalHp = Math.floor(oracleBaseHp * (1 + oracleDecoyBonus / 100))
const oracleRes = Math.min(75, 2 * slvl)
expect(stats.baseHp).toBe(oracleBaseHp)
expect(stats.decoySynergyBonusPct).toBe(oracleDecoyBonus)
expect(stats.finalHp).toBe(oracleFinalHp)
expect(stats.allResistancesPct).toBe(oracleRes)
expect(stats.cooldownFrames).toBe(150)
}
})
it('Phase 2 Fuzzing: Slow Missiles 33% speed damper across 36 angles (0 to 350 degrees)', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const caster = createCombatUnit('caster', { x: 0, y: 0 }, registry)
const dmgPacket: SUnitDmgPacket = {
skillId: 0,
attackKind: 'missile',
flatPhysMin256: 10 * FIXED_ONE,
flatPhysMax256: 10 * FIXED_ONE,
}
for (let angleDeg = 0; angleDeg < 360; angleDeg += 10) {
const angleRad = (angleDeg * Math.PI) / 180
const targetX = Math.round(500 * Math.cos(angleRad))
const targetY = Math.round(500 * Math.sin(angleRad))
const normal = missileEngine.spawnMissile({
missileNameOrId: 'arrow',
sourceSkillId: 0,
slvl: 1,
owner: caster,
startX: 0,
startY: 0,
targetX,
targetY,
dmgPacket,
})!
const slowed = missileEngine.spawnMissile({
missileNameOrId: 'arrow',
sourceSkillId: 0,
slvl: 1,
owner: caster,
startX: 0,
startY: 0,
targetX,
targetY,
dmgPacket,
isSlowed: true,
slowMultiplier: 0.33,
})!
// Step for 1 tick
missileEngine.tick(0, [], new Map())
const normalDist = Math.hypot(normal.x, normal.y)
const slowedDist = Math.hypot(slowed.x, slowed.y)
// Distance ratio must be 0.33 within floating-point tolerance
expect(slowedDist / normalDist).toBeCloseTo(0.33, 2)
}
})
it('Phase 4 Degenerate Inputs: boundary sanitization and defense clamping', () => {
// Degenerate inputs (slvl <= 0, NaN, Infinity) safely clamp to 1
expect(calculateDecoyStats(0, 1000).hp).toBe(600) // Sanitized to slvl 1 (60%)
expect(calculateDecoyStats(-5, 1000).hp).toBe(600)
expect(calculateDecoyStats(NaN, 1000).hp).toBe(600)
expect(calculateValkyrieStats(0).baseHp).toBe(440)
expect(calculateValkyrieStats(-10).baseHp).toBe(440)
expect(calculateValkyrieStats(NaN).baseHp).toBe(440)
expect(calculateSlowMissilesStats(0).durationFrames).toBe(300)
expect(calculateInnerSightStats(0).flatDefenseReduction).toBe(-40)
// Inner Sight: monster defense 0 -> stripped remains 0
const strip20 = calculateInnerSightStats(20).flatDefenseReduction // -515
const clampedDef = Math.max(0, 0 + strip20)
expect(clampedDef).toBe(0)
const hitChance = computeToHitChance({
attackerAr: 500,
defenderDef: clampedDef,
attackerLvl: 50,
defenderLvl: 50,
})
expect(hitChance).toBe(95) // Capped at 95%
})
})
})