951 lines
36 KiB
TypeScript
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%
|
|
})
|
|
})
|
|
})
|
|
|