561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
/**
|
|
* Adversarial Stress & Ground Truth Parity Test Suite — Assassin Traps (10 skills)
|
|
*
|
|
* Validates 1.13c Diablo II mechanics:
|
|
* 1. Global 5-trap cap on PetType `assassintrap` with strict FIFO eviction.
|
|
* 2. NextDelay mechanics (Wake of Fire 4-frame immunity, Shock Web 25-frame immunity).
|
|
* 3. Death Sentry dual-mode AI (corpse prioritization, 50/50 phys/fire split, radius progression, shot scaling).
|
|
* 4. Blade skills weapon damage scaling (3/8 for Sentinel, 3/4 for Fury, 1/4 for Shield).
|
|
* 5. Shock Web missile cluster dispersion and synergy scaling.
|
|
* 6. Sentry shot depletion and life cycles.
|
|
*
|
|
* Gitea Milestone: M26 (Issue #473)
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
ASSASSIN_TRAP_SKILL_IDS,
|
|
MAX_ACTIVE_TRAPS,
|
|
BLADE_SRC_DAM,
|
|
NEXT_DELAY_FRAMES,
|
|
TrapManager,
|
|
NextDelayTracker,
|
|
computeBladeWeaponDamage,
|
|
evaluateFireBlastStats,
|
|
evaluateShockWebStats,
|
|
evaluateBladeSentinelStats,
|
|
evaluateChargedBoltSentryStats,
|
|
evaluateWakeOfFireStats,
|
|
evaluateBladeFuryStats,
|
|
evaluateLightningSentryStats,
|
|
evaluateWakeOfInfernoStats,
|
|
evaluateDeathSentryStats,
|
|
evaluateBladeShieldStats,
|
|
simulateDeathSentryAI,
|
|
simulateWakeOfFireWaveHit,
|
|
computeShockWebClusterPoints,
|
|
type SimulationCorpse,
|
|
type SimulationEnemy,
|
|
} from '../../../src/game/skills/assassin-traps.ts'
|
|
|
|
describe('Assassin Traps Ground Truth & Adversarial Suite (Milestone M26)', () => {
|
|
describe('1. Global Trap Cap & FIFO Eviction (PetType: assassintrap, Limit: 5)', () => {
|
|
it('allows up to 5 traps without eviction', () => {
|
|
const manager = new TrapManager()
|
|
const ownerId = 'assassin_1'
|
|
|
|
// Summon 5 different traps
|
|
const trap1 = manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.CHARGED_BOLT_SENTRY,
|
|
summonMon: 'chargeboltsentry',
|
|
x: 100,
|
|
y: 100,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 10,
|
|
})
|
|
expect(trap1.evictedTrap).toBeUndefined()
|
|
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
|
|
|
|
manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
|
|
summonMon: 'wakeofdestruction',
|
|
x: 120,
|
|
y: 100,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 20,
|
|
})
|
|
manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
|
|
summonMon: 'lightningsentry',
|
|
x: 140,
|
|
y: 100,
|
|
slvl: 20,
|
|
shots: 10,
|
|
currentFrame: 30,
|
|
})
|
|
manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_INFERNO,
|
|
summonMon: 'infernosentry',
|
|
x: 160,
|
|
y: 100,
|
|
slvl: 20,
|
|
shots: 10,
|
|
currentFrame: 40,
|
|
})
|
|
const trap5 = manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
|
|
summonMon: 'deathsentry',
|
|
x: 180,
|
|
y: 100,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 50,
|
|
})
|
|
|
|
expect(trap5.evictedTrap).toBeUndefined()
|
|
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
|
|
})
|
|
|
|
it('strictly evicts the oldest trap (FIFO) when a 6th trap is summoned', () => {
|
|
const manager = new TrapManager()
|
|
const ownerId = 'assassin_1'
|
|
|
|
// Register 5 traps
|
|
const t1 = manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
|
|
summonMon: 'lightningsentry',
|
|
x: 10,
|
|
y: 10,
|
|
slvl: 20,
|
|
shots: 10,
|
|
currentFrame: 10,
|
|
}).trap
|
|
|
|
const t2 = manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
|
|
summonMon: 'lightningsentry',
|
|
x: 20,
|
|
y: 10,
|
|
slvl: 20,
|
|
shots: 10,
|
|
currentFrame: 20,
|
|
}).trap
|
|
|
|
manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
|
|
summonMon: 'lightningsentry',
|
|
x: 30,
|
|
y: 10,
|
|
slvl: 20,
|
|
shots: 10,
|
|
currentFrame: 30,
|
|
})
|
|
|
|
manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
|
|
summonMon: 'deathsentry',
|
|
x: 40,
|
|
y: 10,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 40,
|
|
})
|
|
|
|
manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
|
|
summonMon: 'deathsentry',
|
|
x: 50,
|
|
y: 10,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 50,
|
|
})
|
|
|
|
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
|
|
|
|
// Summon 6th trap -> t1 (the oldest) MUST be evicted!
|
|
const res6 = manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
|
|
summonMon: 'wakeofdestruction',
|
|
x: 60,
|
|
y: 10,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 60,
|
|
})
|
|
|
|
expect(res6.evictedTrap).toBeDefined()
|
|
expect(res6.evictedTrap?.id).toBe(t1.id)
|
|
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
|
|
|
|
// Active traps should NOT contain t1
|
|
const activeIds = manager.getActiveTraps(ownerId).map(t => t.id)
|
|
expect(activeIds).not.toContain(t1.id)
|
|
expect(activeIds).toContain(res6.trap.id)
|
|
|
|
// Summon 7th trap -> t2 MUST be evicted!
|
|
const res7 = manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.CHARGED_BOLT_SENTRY,
|
|
summonMon: 'chargeboltsentry',
|
|
x: 70,
|
|
y: 10,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 70,
|
|
})
|
|
expect(res7.evictedTrap?.id).toBe(t2.id)
|
|
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
|
|
})
|
|
|
|
it('isolates trap quotas per player/owner in multiplayer arena', () => {
|
|
const manager = new TrapManager()
|
|
const p1 = 'assassin_alice'
|
|
const p2 = 'assassin_bob'
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
manager.registerTrap({
|
|
ownerId: p1,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
|
|
summonMon: 'lightningsentry',
|
|
x: i * 10,
|
|
y: 0,
|
|
slvl: 20,
|
|
shots: 10,
|
|
currentFrame: 0,
|
|
})
|
|
manager.registerTrap({
|
|
ownerId: p2,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
|
|
summonMon: 'deathsentry',
|
|
x: i * 10,
|
|
y: 50,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 0,
|
|
})
|
|
}
|
|
|
|
expect(manager.getActiveTrapCount(p1)).toBe(5)
|
|
expect(manager.getActiveTrapCount(p2)).toBe(5)
|
|
|
|
// Summon 6th trap for p1; only p1's trap is evicted, p2's remain intact
|
|
const res = manager.registerTrap({
|
|
ownerId: p1,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
|
|
summonMon: 'wakeofdestruction',
|
|
x: 999,
|
|
y: 0,
|
|
slvl: 20,
|
|
shots: 5,
|
|
currentFrame: 100,
|
|
})
|
|
|
|
expect(res.evictedTrap?.ownerId).toBe(p1)
|
|
expect(manager.getActiveTrapCount(p1)).toBe(5)
|
|
expect(manager.getActiveTrapCount(p2)).toBe(5)
|
|
})
|
|
|
|
it('automatically destroys trap when shot count reaches zero', () => {
|
|
const manager = new TrapManager()
|
|
const ownerId = 'assassin_1'
|
|
|
|
const { trap } = manager.registerTrap({
|
|
ownerId,
|
|
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
|
|
summonMon: 'deathsentry',
|
|
x: 0,
|
|
y: 0,
|
|
slvl: 1,
|
|
shots: 3,
|
|
currentFrame: 0,
|
|
})
|
|
|
|
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
|
|
|
|
const s1 = manager.consumeShot(trap.id)
|
|
expect(s1.remaining).toBe(2)
|
|
expect(s1.destroyed).toBe(false)
|
|
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
|
|
|
|
const s2 = manager.consumeShot(trap.id)
|
|
expect(s2.remaining).toBe(1)
|
|
expect(s2.destroyed).toBe(false)
|
|
|
|
const s3 = manager.consumeShot(trap.id)
|
|
expect(s3.remaining).toBe(0)
|
|
expect(s3.destroyed).toBe(true)
|
|
expect(manager.getActiveTrapCount(ownerId)).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('2. NextDelay Mechanics (Wake of Fire & Shock Web)', () => {
|
|
it('enforces 4-frame damage immunity for Wake of Fire', () => {
|
|
const tracker = new NextDelayTracker()
|
|
const enemyId = 'monster_1'
|
|
|
|
// At frame 100, monster is eligible
|
|
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 100)).toBe(true)
|
|
|
|
// Hit monster at frame 100 with NextDelay 4
|
|
tracker.recordHit(enemyId, 'wake_of_fire', 100, NEXT_DELAY_FRAMES.WAKE_OF_FIRE)
|
|
|
|
// Monster must be immune at frame 100, 101, 102, 103
|
|
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 100)).toBe(false)
|
|
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 101)).toBe(false)
|
|
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 102)).toBe(false)
|
|
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 103)).toBe(false)
|
|
|
|
// At frame 104, NextDelay window has expired (100 + 4 = 104)
|
|
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 104)).toBe(true)
|
|
})
|
|
|
|
it('enforces 25-frame damage immunity for Shock Web', () => {
|
|
const tracker = new NextDelayTracker()
|
|
const enemyId = 'monster_boss'
|
|
|
|
expect(tracker.canTakeDamage(enemyId, 'shock_web', 500)).toBe(true)
|
|
tracker.recordHit(enemyId, 'shock_web', 500, NEXT_DELAY_FRAMES.SHOCK_WEB)
|
|
|
|
// Immune throughout frames 500..524
|
|
expect(tracker.canTakeDamage(enemyId, 'shock_web', 510)).toBe(false)
|
|
expect(tracker.canTakeDamage(enemyId, 'shock_web', 524)).toBe(false)
|
|
|
|
// Vulnerable again at frame 525 (500 + 25 = 525)
|
|
expect(tracker.canTakeDamage(enemyId, 'shock_web', 525)).toBe(true)
|
|
})
|
|
|
|
it('simulates Wake of Fire wave hit against grouped enemies with NextDelay', () => {
|
|
const tracker = new NextDelayTracker()
|
|
const enemies: SimulationEnemy[] = [
|
|
{ id: 'm1', x: 100, y: 100, currentHp: 500, maxHp: 500 },
|
|
{ id: 'm2', x: 150, y: 100, currentHp: 500, maxHp: 500 },
|
|
]
|
|
|
|
const trap: any = {
|
|
id: 'wof_1',
|
|
x: 0,
|
|
y: 100,
|
|
slvl: 20,
|
|
}
|
|
|
|
// First wave at frame 0
|
|
const hit0 = simulateWakeOfFireWaveHit(trap, enemies, 0, tracker)
|
|
expect(hit0.enemiesHit).toContain('m1')
|
|
expect(hit0.enemiesHit).toContain('m2')
|
|
expect(enemies[0]!.currentHp).toBeLessThan(500)
|
|
|
|
// Rapid overlap wave at frame 2 (within 4-frame NextDelay window) -> deals 0 damage!
|
|
const hpBefore = enemies[0]!.currentHp
|
|
const hit2 = simulateWakeOfFireWaveHit(trap, enemies, 2, tracker)
|
|
expect(hit2.enemiesHit).toHaveLength(0)
|
|
expect(hit2.totalDamageDealt).toBe(0)
|
|
expect(enemies[0]!.currentHp).toBe(hpBefore)
|
|
|
|
// Next wave at frame 5 (> 4 frames) -> hits again!
|
|
const hit5 = simulateWakeOfFireWaveHit(trap, enemies, 5, tracker)
|
|
expect(hit5.enemiesHit).toContain('m1')
|
|
expect(enemies[0]!.currentHp).toBeLessThan(hpBefore)
|
|
})
|
|
})
|
|
|
|
describe('3. Death Sentry Dual-Mode Corpse Explosion AI', () => {
|
|
it('prioritizes Corpse Explosion over Lightning when a corpse is in radius', () => {
|
|
const trap: any = { id: 'ds_1', x: 100, y: 100, slvl: 20 }
|
|
const corpses: SimulationCorpse[] = [
|
|
{ id: 'corpse_1', x: 120, y: 100, baseMaxHp: 1000, consumed: false },
|
|
]
|
|
const enemies: SimulationEnemy[] = [
|
|
{ id: 'live_mon', x: 130, y: 100, currentHp: 800, maxHp: 800 },
|
|
]
|
|
|
|
const decision = simulateDeathSentryAI(trap, corpses, enemies)
|
|
expect(decision.action).toBe('corpse_explosion')
|
|
expect(decision.targetId).toBe('corpse_1')
|
|
expect(corpses[0]!.consumed).toBe(true)
|
|
// 40-80% of 1000 HP -> 600 damage avg (300 phys, 300 fire)
|
|
expect(decision.damageDealt?.phys).toBe(300)
|
|
expect(decision.damageDealt?.fire).toBe(300)
|
|
expect(decision.damageDealt?.ltng).toBe(0)
|
|
expect(enemies[0]!.currentHp).toBe(200)
|
|
})
|
|
|
|
it('falls back to piercing lightning bolt when no valid corpse exists in radius', () => {
|
|
const trap: any = { id: 'ds_1', x: 0, y: 0, slvl: 20 }
|
|
const corpses: SimulationCorpse[] = [
|
|
// Corpse is far away outside radius (> 29 subtiles * 16px = 464px)
|
|
{ id: 'corpse_far', x: 900, y: 900, baseMaxHp: 1000, consumed: false },
|
|
]
|
|
const enemies: SimulationEnemy[] = [
|
|
{ id: 'near_mon', x: 100, y: 0, currentHp: 500, maxHp: 500 },
|
|
]
|
|
|
|
const decision = simulateDeathSentryAI(trap, corpses, enemies)
|
|
expect(decision.action).toBe('lightning')
|
|
expect(decision.targetId).toBe('near_mon')
|
|
expect(decision.damageDealt?.ltng).toBeGreaterThan(0)
|
|
expect(decision.damageDealt?.phys).toBe(0)
|
|
expect(decision.damageDealt?.fire).toBe(0)
|
|
expect(corpses[0]!.consumed).toBe(false)
|
|
})
|
|
|
|
it('evaluates Death Sentry shot count scaling with Fire Blast synergy', () => {
|
|
// Base shots: 5. +1 shot per 3 hard points of Fire Blast
|
|
const s0 = evaluateDeathSentryStats(20, { fireBlast: 0 })
|
|
expect(s0.shots).toBe(5)
|
|
|
|
const s2 = evaluateDeathSentryStats(20, { fireBlast: 2 })
|
|
expect(s2.shots).toBe(5)
|
|
|
|
const s3 = evaluateDeathSentryStats(20, { fireBlast: 3 })
|
|
expect(s3.shots).toBe(6)
|
|
|
|
const s9 = evaluateDeathSentryStats(20, { fireBlast: 9 })
|
|
expect(s9.shots).toBe(8)
|
|
|
|
const s20 = evaluateDeathSentryStats(20, { fireBlast: 20 })
|
|
expect(s20.shots).toBe(11) // 5 + floor(20 / 3) = 5 + 6 = 11 shots
|
|
})
|
|
|
|
it('verifies Corpse Explosion radius progression: 10 + (slvl - 1) subtiles', () => {
|
|
const s1 = evaluateDeathSentryStats(1)
|
|
expect(s1.corpseExplosionRadiusSubtiles).toBe(10) // 10 subtiles = 3.3 yards
|
|
|
|
const s10 = evaluateDeathSentryStats(10)
|
|
expect(s10.corpseExplosionRadiusSubtiles).toBe(19) // 19 subtiles = 6.3 yards
|
|
|
|
const s20 = evaluateDeathSentryStats(20)
|
|
expect(s20.corpseExplosionRadiusSubtiles).toBe(29) // 29 subtiles = 9.6 yards
|
|
})
|
|
})
|
|
|
|
describe('4. Blade Skills Weapon Damage Scaling (srcDam 128ths)', () => {
|
|
it('scales Blade Sentinel weapon damage to exact 3/8 (37.5%)', () => {
|
|
expect(BLADE_SRC_DAM.BLADE_SENTINEL).toBe(48)
|
|
// 48 / 128 = 3/8
|
|
|
|
// Weapon damage 80 -> 80 * 3/8 = 30
|
|
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(30)
|
|
// Weapon damage 240 -> 240 * 3/8 = 90
|
|
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(90)
|
|
// Weapon damage 100 -> floor(100 * 48 / 128) = floor(37.5) = 37
|
|
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(37)
|
|
})
|
|
|
|
it('scales Blade Fury weapon damage to exact 3/4 (75.0%)', () => {
|
|
expect(BLADE_SRC_DAM.BLADE_FURY).toBe(96)
|
|
// 96 / 128 = 3/4
|
|
|
|
// Weapon damage 80 -> 80 * 3/4 = 60
|
|
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(60)
|
|
// Weapon damage 240 -> 240 * 3/4 = 180
|
|
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(180)
|
|
// Weapon damage 100 -> floor(100 * 96 / 128) = 75
|
|
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(75)
|
|
})
|
|
|
|
it('scales Blade Shield weapon damage to exact 1/4 (25.0%)', () => {
|
|
expect(BLADE_SRC_DAM.BLADE_SHIELD).toBe(32)
|
|
// 32 / 128 = 1/4
|
|
|
|
// Weapon damage 80 -> 80 * 1/4 = 20
|
|
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(20)
|
|
// Weapon damage 240 -> 240 * 1/4 = 60
|
|
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(60)
|
|
// Weapon damage 100 -> floor(100 * 32 / 128) = 25
|
|
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(25)
|
|
})
|
|
|
|
it('combines flat physical damage with scaled weapon damage on Blade skills', () => {
|
|
const sentStats = evaluateBladeSentinelStats(20, 200)
|
|
// flat min = 79, max = 83. scaled weapon (200 * 3/8) = 75
|
|
expect(sentStats.scaledWeaponPhys).toBe(75)
|
|
expect(sentStats.totalMinPhys).toBe(79 + 75)
|
|
expect(sentStats.totalMaxPhys).toBe(83 + 75)
|
|
|
|
const furyStats = evaluateBladeFuryStats(20, 200)
|
|
// flat min = 101, max = 103. scaled weapon (200 * 3/4) = 150
|
|
expect(furyStats.scaledWeaponPhys).toBe(150)
|
|
expect(furyStats.totalMinPhys).toBe(101 + 150)
|
|
expect(furyStats.totalMaxPhys).toBe(103 + 150)
|
|
|
|
const shieldStats = evaluateBladeShieldStats(20, 200)
|
|
// flat min = 112, max = 141. scaled weapon (200 * 1/4) = 50
|
|
expect(shieldStats.scaledWeaponPhys).toBe(50)
|
|
expect(shieldStats.totalMinPhys).toBe(112 + 50)
|
|
expect(shieldStats.totalMaxPhys).toBe(141 + 50)
|
|
})
|
|
})
|
|
|
|
describe('5. Shock Web Cluster & Synergy Mechanics', () => {
|
|
it('calculates exact missile count: 6 + floor(slvl / 4) + floor(FireBlast.blvl / 3)', () => {
|
|
const sw1 = evaluateShockWebStats(1, { fireBlast: 0 })
|
|
expect(sw1.missileCount).toBe(6)
|
|
|
|
const sw4 = evaluateShockWebStats(4, { fireBlast: 0 })
|
|
expect(sw4.missileCount).toBe(7)
|
|
|
|
const sw20 = evaluateShockWebStats(20, { fireBlast: 0 })
|
|
expect(sw20.missileCount).toBe(11) // 6 + 5 = 11
|
|
|
|
const sw20_syn = evaluateShockWebStats(20, { fireBlast: 20 })
|
|
expect(sw20_syn.missileCount).toBe(17) // 6 + 5 + 6 = 17
|
|
})
|
|
|
|
it('generates distributed cluster points around impact origin', () => {
|
|
const points = computeShockWebClusterPoints(200, 200, 20, 20, 64)
|
|
expect(points).toHaveLength(17)
|
|
// Ensure all points lie within max cluster radius (64 + epsilon)
|
|
for (const pt of points) {
|
|
const dist = Math.hypot(pt.x - 200, pt.y - 200)
|
|
expect(dist).toBeLessThanOrEqual(65)
|
|
}
|
|
})
|
|
|
|
it('applies +11% synergy per hard point of CBS, LS, DS', () => {
|
|
const base = evaluateShockWebStats(20, { chargedBoltSentry: 0, lightningSentry: 0, deathSentry: 0 })
|
|
const syn = evaluateShockWebStats(20, { chargedBoltSentry: 20, lightningSentry: 20, deathSentry: 20 })
|
|
// 60 hard points * 11% = 660% bonus damage
|
|
expect(syn.synergyPct).toBe(660)
|
|
expect(syn.maxDamage).toBe(Math.floor(base.maxDamage * (1 + 6.6)))
|
|
})
|
|
})
|
|
|
|
describe('6. Sentry Lifecycles & Synergies', () => {
|
|
it('evaluates Charged Bolt Sentry shots and bolt count scaling', () => {
|
|
const cbs = evaluateChargedBoltSentryStats(20, {
|
|
lightningSentry: 20, // +5 shots
|
|
shockWeb: 20, // +6 bolts
|
|
})
|
|
// Base shots: 5 + floor(20/4) = 10 shots
|
|
expect(cbs.shots).toBe(10)
|
|
// Base bolts: 5 + floor(20/3) = 11 bolts per shot
|
|
expect(cbs.boltsPerShot).toBe(11)
|
|
})
|
|
|
|
it('evaluates Lightning Sentry piercing damage and 12% synergy scaling', () => {
|
|
const lsBase = evaluateLightningSentryStats(20, { shockWeb: 0, chargedBoltSentry: 0, deathSentry: 0 })
|
|
expect(lsBase.shots).toBe(10)
|
|
expect(lsBase.minDamage).toBe(1)
|
|
expect(lsBase.maxDamage).toBe(314) // 5-band level 20 max
|
|
|
|
const lsMaxSyn = evaluateLightningSentryStats(20, { shockWeb: 20, chargedBoltSentry: 20, deathSentry: 20 })
|
|
// 60 * 12% = 720% bonus
|
|
expect(lsMaxSyn.synergyPct).toBe(720)
|
|
expect(lsMaxSyn.maxDamage).toBe(Math.floor(314 * (1 + 7.2)))
|
|
})
|
|
|
|
it('evaluates Wake of Inferno 10 attacks and 7% synergies', () => {
|
|
const woi = evaluateWakeOfInfernoStats(20, { fireBlast: 20, wakeOfFire: 20, deathSentry: 20 })
|
|
expect(woi.attacks).toBe(10)
|
|
// 60 * 7% = 420% synergy
|
|
expect(woi.synergyPct).toBe(420)
|
|
})
|
|
|
|
it('evaluates Blade Shield 20s-115s duration and 25-frame tick rate', () => {
|
|
const bs1 = evaluateBladeShieldStats(1)
|
|
expect(bs1.durationFrames).toBe(500) // 20s
|
|
expect(bs1.tickRateFrames).toBe(25) // 1.0s
|
|
expect(bs1.radiusSubtiles).toBe(6)
|
|
|
|
const bs20 = evaluateBladeShieldStats(20)
|
|
expect(bs20.durationFrames).toBe(2875) // 500 + 125 * 19 = 2875 frames = 115s
|
|
expect(bs20.tickRateFrames).toBe(25)
|
|
expect(bs20.manaCost).toBe(65) // 27 + 2 * 19 = 65
|
|
})
|
|
})
|
|
})
|