1023 lines
42 KiB
TypeScript
1023 lines
42 KiB
TypeScript
/**
|
|
* Adversarial Edge-Case Stress Testing & Coverage Boundary Verification Suite
|
|
* Focus: Sorceress Cold & Fire Trees (All 11 Skills & 16 Calculation Functions)
|
|
*
|
|
* Test Dimensions:
|
|
* 1. Zero, Negative, and Extreme Skill Levels across all 16 calculation functions:
|
|
* - calculateFrozenArmorDefense, calculateShiverArmorDamage, calculateChillingArmorDamage
|
|
* - calculateIceBlastDamage, calculateIceBlastFreezeLength
|
|
* - calculateGlacialSpikeDamage, calculateGlacialSpikeFreezeLength
|
|
* - calculateColdMasteryPierce, calculateFireWallDamage, calculateBlazeDamage
|
|
* - calculateInfernoDamage, calculateInfernoContinuousMana256
|
|
* - calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration
|
|
* - calculateFireMasteryBonus
|
|
* Tested with: slvl = 0, slvl = -1, slvl = 99, slvl = 1000, NaN, undefined, fractions, infinities.
|
|
* 2. Mana Depletion and Channeling Exhaustion:
|
|
* - Inferno channeled stream mid-stream depletion to 0 mana (halt, state reset, event logging).
|
|
* - Casting rejection with insufficient mana (mana < cost) with zero state corruption.
|
|
* 3. Cooldown & Cast Delay Integrity:
|
|
* - Fire Wall (35 frames), Meteor (30 frames), Blizzard (45 frames), Hydra (40 frames), Frozen Orb (25 frames).
|
|
* - Global cast delay group locking: blocks other delayed spells while allowing non-delayed spells.
|
|
* 4. Screen & Spatial Collision Boundaries:
|
|
* - Extreme coordinates (-9999, +9999) & sub-pixel fractions for Glacial Spike burst & Ice Blast.
|
|
* - Collision raycasting (hasLineOfSight) deadlock-freedom and boundary precision.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
calculateFrozenArmorDefense,
|
|
calculateShiverArmorDamage,
|
|
calculateChillingArmorDamage,
|
|
calculateIceBlastDamage,
|
|
calculateIceBlastFreezeLength,
|
|
calculateGlacialSpikeDamage,
|
|
calculateGlacialSpikeFreezeLength,
|
|
calculateColdMasteryPierce,
|
|
calculateFireWallDamage,
|
|
calculateBlazeDamage,
|
|
calculateInfernoDamage,
|
|
calculateInfernoContinuousMana256,
|
|
calculateEnchantDamage,
|
|
calculateEnchantAttackRating,
|
|
calculateEnchantDuration,
|
|
calculateFireMasteryBonus,
|
|
calculateFireWallPatches,
|
|
hasLineOfSight,
|
|
MissileEngine,
|
|
} from '../../../src/game/engine/missile-engine.ts'
|
|
import {
|
|
BATCH1_SKILLS,
|
|
getSkillManaCost,
|
|
getSkillCooldownTicks,
|
|
calculateSkillDamage,
|
|
executeSkill,
|
|
type SkillExecutionContext,
|
|
} from '../../../src/game/skills.ts'
|
|
import { castSkill, getSkillMana } from '../../../src/scene/act-scene.ts'
|
|
import { createPlayer } from '../../../src/game/combat.ts'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
|
|
|
|
function createMockCombatUnit(
|
|
id: string,
|
|
options?: {
|
|
hp?: number
|
|
coldResist?: number
|
|
cannotBeFrozen?: boolean
|
|
coldMasterySlvl?: number
|
|
},
|
|
): CombatUnitContext {
|
|
const statList = new UnitStatList()
|
|
const hp = options?.hp ?? 500
|
|
statList.setBaseStat('hp', hp * 256)
|
|
statList.setBaseStat('maxhp', hp * 256)
|
|
statList.setBaseStat('hitpoints', hp * 256)
|
|
|
|
if (options?.coldResist !== undefined) {
|
|
statList.setBaseStat('coldresist', options.coldResist)
|
|
}
|
|
if (options?.cannotBeFrozen) {
|
|
statList.setBaseStat('cannot_be_frozen', 1)
|
|
}
|
|
if (options?.coldMasterySlvl !== undefined && options.coldMasterySlvl > 0) {
|
|
const pierce = calculateColdMasteryPierce(options.coldMasterySlvl)
|
|
statList.setBaseStat('passive_cold_pierce', pierce)
|
|
}
|
|
|
|
const stateBus = new StateBus(statList)
|
|
return {
|
|
id,
|
|
name: id,
|
|
statList,
|
|
stateBus,
|
|
}
|
|
}
|
|
|
|
describe('Adversarial Boundary Stress Suite — Cold & Fire Skills', () => {
|
|
// ==========================================================================
|
|
// Dimension 1: Zero, Negative, and Extreme Skill Levels
|
|
// ==========================================================================
|
|
describe('Dimension 1: Zero, Negative, and Extreme Skill Levels', () => {
|
|
const testLevels: Array<any> = [
|
|
0,
|
|
-1,
|
|
-100,
|
|
-Infinity,
|
|
NaN,
|
|
undefined,
|
|
1,
|
|
20,
|
|
99,
|
|
1000,
|
|
10000,
|
|
0.5,
|
|
15.7,
|
|
]
|
|
|
|
describe('Defense, Buff & Passive Calculations', () => {
|
|
it('calculateFrozenArmorDefense: safely handles all degenerate and extreme slvl inputs', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateFrozenArmorDefense(slvl)).not.toThrow()
|
|
const def = calculateFrozenArmorDefense(slvl)
|
|
expect(Number.isFinite(def)).toBe(true)
|
|
expect(Number.isNaN(def)).toBe(false)
|
|
expect(def).toBeGreaterThanOrEqual(30) // Minimum level 1 defense bonus
|
|
}
|
|
// Specific boundary assertions
|
|
expect(calculateFrozenArmorDefense(0)).toBe(30)
|
|
expect(calculateFrozenArmorDefense(-1)).toBe(30)
|
|
expect(calculateFrozenArmorDefense(NaN)).toBe(30)
|
|
expect(calculateFrozenArmorDefense(undefined as any)).toBe(30)
|
|
expect(calculateFrozenArmorDefense(99)).toBe(30 + 98 * 5) // 520%
|
|
expect(calculateFrozenArmorDefense(1000)).toBe(30 + 999 * 5) // 5025%
|
|
})
|
|
|
|
it('calculateColdMasteryPierce: clamps to 0 for non-positive or non-finite inputs, scales monotonically', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateColdMasteryPierce(slvl)).not.toThrow()
|
|
const pierce = calculateColdMasteryPierce(slvl)
|
|
expect(Number.isFinite(pierce)).toBe(true)
|
|
expect(Number.isNaN(pierce)).toBe(false)
|
|
expect(pierce).toBeGreaterThanOrEqual(0)
|
|
}
|
|
expect(calculateColdMasteryPierce(0)).toBe(0)
|
|
expect(calculateColdMasteryPierce(-1)).toBe(0)
|
|
expect(calculateColdMasteryPierce(-100)).toBe(0)
|
|
expect(calculateColdMasteryPierce(NaN)).toBe(0)
|
|
expect(calculateColdMasteryPierce(undefined as any)).toBe(0)
|
|
expect(calculateColdMasteryPierce(1)).toBe(20)
|
|
expect(calculateColdMasteryPierce(20)).toBe(20 + 19 * 5) // 115%
|
|
expect(calculateColdMasteryPierce(99)).toBe(20 + 98 * 5) // 510%
|
|
expect(calculateColdMasteryPierce(1000)).toBe(20 + 999 * 5) // 5015%
|
|
})
|
|
|
|
it('calculateFireMasteryBonus: clamps to 0 for non-positive or non-finite inputs, scales monotonically', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateFireMasteryBonus(slvl)).not.toThrow()
|
|
const bonus = calculateFireMasteryBonus(slvl)
|
|
expect(Number.isFinite(bonus)).toBe(true)
|
|
expect(Number.isNaN(bonus)).toBe(false)
|
|
expect(bonus).toBeGreaterThanOrEqual(0)
|
|
}
|
|
expect(calculateFireMasteryBonus(0)).toBe(0)
|
|
expect(calculateFireMasteryBonus(-1)).toBe(0)
|
|
expect(calculateFireMasteryBonus(NaN)).toBe(0)
|
|
expect(calculateFireMasteryBonus(undefined as any)).toBe(0)
|
|
expect(calculateFireMasteryBonus(1)).toBe(30)
|
|
expect(calculateFireMasteryBonus(20)).toBe(30 + 19 * 7) // 163%
|
|
expect(calculateFireMasteryBonus(99)).toBe(30 + 98 * 7) // 716%
|
|
expect(calculateFireMasteryBonus(1000)).toBe(30 + 999 * 7) // 7023%
|
|
})
|
|
|
|
it('calculateEnchantAttackRating: clamps to 0 for non-positive or non-finite inputs', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateEnchantAttackRating(slvl)).not.toThrow()
|
|
const ar = calculateEnchantAttackRating(slvl)
|
|
expect(Number.isFinite(ar)).toBe(true)
|
|
expect(Number.isNaN(ar)).toBe(false)
|
|
expect(ar).toBeGreaterThanOrEqual(0)
|
|
}
|
|
expect(calculateEnchantAttackRating(0)).toBe(0)
|
|
expect(calculateEnchantAttackRating(-1)).toBe(0)
|
|
expect(calculateEnchantAttackRating(NaN)).toBe(0)
|
|
expect(calculateEnchantAttackRating(undefined as any)).toBe(0)
|
|
expect(calculateEnchantAttackRating(1)).toBe(20)
|
|
expect(calculateEnchantAttackRating(20)).toBe(20 + 19 * 9) // 191%
|
|
expect(calculateEnchantAttackRating(99)).toBe(20 + 98 * 9) // 902%
|
|
expect(calculateEnchantAttackRating(1000)).toBe(20 + 999 * 9) // 9011%
|
|
})
|
|
|
|
it('calculateEnchantDuration: clamps to 0 for non-positive or non-finite inputs', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateEnchantDuration(slvl)).not.toThrow()
|
|
const dur = calculateEnchantDuration(slvl)
|
|
expect(Number.isFinite(dur)).toBe(true)
|
|
expect(Number.isNaN(dur)).toBe(false)
|
|
expect(dur).toBeGreaterThanOrEqual(0)
|
|
}
|
|
expect(calculateEnchantDuration(0)).toBe(0)
|
|
expect(calculateEnchantDuration(-1)).toBe(0)
|
|
expect(calculateEnchantDuration(NaN)).toBe(0)
|
|
expect(calculateEnchantDuration(undefined as any)).toBe(0)
|
|
expect(calculateEnchantDuration(1)).toBe(3600) // 144s
|
|
expect(calculateEnchantDuration(20)).toBe(3600 + 19 * 600) // 15000 frames
|
|
expect(calculateEnchantDuration(99)).toBe(3600 + 98 * 600) // 62400 frames
|
|
expect(calculateEnchantDuration(1000)).toBe(3600 + 999 * 600) // 603000 frames
|
|
})
|
|
|
|
it('calculateInfernoContinuousMana256: safely handles degenerate and extreme inputs', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateInfernoContinuousMana256(slvl)).not.toThrow()
|
|
const mana256 = calculateInfernoContinuousMana256(slvl)
|
|
expect(Number.isFinite(mana256)).toBe(true)
|
|
expect(Number.isNaN(mana256)).toBe(false)
|
|
expect(mana256).toBeGreaterThanOrEqual(144) // Level 1 drain
|
|
}
|
|
expect(calculateInfernoContinuousMana256(0)).toBe(144)
|
|
expect(calculateInfernoContinuousMana256(-1)).toBe(144)
|
|
expect(calculateInfernoContinuousMana256(NaN)).toBe(144)
|
|
expect(calculateInfernoContinuousMana256(undefined as any)).toBe(144)
|
|
expect(calculateInfernoContinuousMana256(1)).toBe(144)
|
|
expect(calculateInfernoContinuousMana256(20)).toBe((36 + 19) * 4) // 220
|
|
expect(calculateInfernoContinuousMana256(99)).toBe((36 + 98) * 4) // 536
|
|
expect(calculateInfernoContinuousMana256(1000)).toBe((36 + 999) * 4) // 4140
|
|
})
|
|
})
|
|
|
|
describe('Cold Armor & Cold Projectile Damage and Freeze Calculations', () => {
|
|
it('calculateShiverArmorDamage: safe fallbacks, finite ranges, min <= max', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateShiverArmorDamage(slvl)).not.toThrow()
|
|
const dmg = calculateShiverArmorDamage(slvl)
|
|
expect(Number.isFinite(dmg.min)).toBe(true)
|
|
expect(Number.isFinite(dmg.max)).toBe(true)
|
|
expect(dmg.min).toBeLessThanOrEqual(dmg.max)
|
|
expect(dmg.min).toBeGreaterThanOrEqual(6)
|
|
}
|
|
expect(calculateShiverArmorDamage(0)).toEqual({ min: 6, max: 8 })
|
|
expect(calculateShiverArmorDamage(-1)).toEqual({ min: 6, max: 8 })
|
|
expect(calculateShiverArmorDamage(NaN)).toEqual({ min: 6, max: 8 })
|
|
expect(calculateShiverArmorDamage(undefined as any)).toEqual({ min: 6, max: 8 })
|
|
expect(calculateShiverArmorDamage(99).min).toBeGreaterThan(calculateShiverArmorDamage(20).min)
|
|
expect(calculateShiverArmorDamage(1000).min).toBeGreaterThan(calculateShiverArmorDamage(99).min)
|
|
})
|
|
|
|
it('calculateChillingArmorDamage: safe fallbacks, finite ranges, min <= max', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateChillingArmorDamage(slvl)).not.toThrow()
|
|
const dmg = calculateChillingArmorDamage(slvl)
|
|
expect(Number.isFinite(dmg.min)).toBe(true)
|
|
expect(Number.isFinite(dmg.max)).toBe(true)
|
|
expect(dmg.min).toBeLessThanOrEqual(dmg.max)
|
|
expect(dmg.min).toBeGreaterThanOrEqual(4)
|
|
}
|
|
expect(calculateChillingArmorDamage(0)).toEqual({ min: 4, max: 6 })
|
|
expect(calculateChillingArmorDamage(-1)).toEqual({ min: 4, max: 6 })
|
|
expect(calculateChillingArmorDamage(NaN)).toEqual({ min: 4, max: 6 })
|
|
expect(calculateChillingArmorDamage(undefined as any)).toEqual({ min: 4, max: 6 })
|
|
expect(calculateChillingArmorDamage(99).min).toBeGreaterThan(calculateChillingArmorDamage(20).min)
|
|
expect(calculateChillingArmorDamage(1000).min).toBeGreaterThan(calculateChillingArmorDamage(99).min)
|
|
})
|
|
|
|
it('calculateIceBlastDamage: safe fallbacks, finite ranges, min <= max', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateIceBlastDamage(slvl)).not.toThrow()
|
|
const dmg = calculateIceBlastDamage(slvl)
|
|
expect(Number.isFinite(dmg.min)).toBe(true)
|
|
expect(Number.isFinite(dmg.max)).toBe(true)
|
|
expect(dmg.min).toBeLessThanOrEqual(dmg.max)
|
|
expect(dmg.min).toBeGreaterThanOrEqual(8)
|
|
}
|
|
expect(calculateIceBlastDamage(0)).toEqual({ min: 8, max: 12 })
|
|
expect(calculateIceBlastDamage(-1)).toEqual({ min: 8, max: 12 })
|
|
expect(calculateIceBlastDamage(NaN)).toEqual({ min: 8, max: 12 })
|
|
expect(calculateIceBlastDamage(undefined as any)).toEqual({ min: 8, max: 12 })
|
|
expect(calculateIceBlastDamage(99).min).toBeGreaterThan(calculateIceBlastDamage(20).min)
|
|
expect(calculateIceBlastDamage(1000).min).toBeGreaterThan(calculateIceBlastDamage(99).min)
|
|
})
|
|
|
|
it('calculateIceBlastFreezeLength: safe fallbacks, positive finite frames', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateIceBlastFreezeLength(slvl)).not.toThrow()
|
|
const frz = calculateIceBlastFreezeLength(slvl)
|
|
expect(Number.isFinite(frz)).toBe(true)
|
|
expect(frz).toBeGreaterThanOrEqual(75) // Level 1 freeze length
|
|
}
|
|
expect(calculateIceBlastFreezeLength(0)).toBe(75)
|
|
expect(calculateIceBlastFreezeLength(-1)).toBe(75)
|
|
expect(calculateIceBlastFreezeLength(NaN)).toBe(75)
|
|
expect(calculateIceBlastFreezeLength(undefined as any)).toBe(75)
|
|
expect(calculateIceBlastFreezeLength(99)).toBe(75 + 98 * 5) // 565 frames
|
|
expect(calculateIceBlastFreezeLength(1000)).toBe(75 + 999 * 5) // 5070 frames
|
|
})
|
|
|
|
it('calculateGlacialSpikeDamage: safe fallbacks, finite ranges, min <= max', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateGlacialSpikeDamage(slvl)).not.toThrow()
|
|
const dmg = calculateGlacialSpikeDamage(slvl)
|
|
expect(Number.isFinite(dmg.min)).toBe(true)
|
|
expect(Number.isFinite(dmg.max)).toBe(true)
|
|
expect(dmg.min).toBeLessThanOrEqual(dmg.max)
|
|
expect(dmg.min).toBeGreaterThanOrEqual(16)
|
|
}
|
|
expect(calculateGlacialSpikeDamage(0)).toEqual({ min: 16, max: 24 })
|
|
expect(calculateGlacialSpikeDamage(-1)).toEqual({ min: 16, max: 24 })
|
|
expect(calculateGlacialSpikeDamage(NaN)).toEqual({ min: 16, max: 24 })
|
|
expect(calculateGlacialSpikeDamage(undefined as any)).toEqual({ min: 16, max: 24 })
|
|
expect(calculateGlacialSpikeDamage(99).min).toBeGreaterThan(calculateGlacialSpikeDamage(20).min)
|
|
expect(calculateGlacialSpikeDamage(1000).min).toBeGreaterThan(calculateGlacialSpikeDamage(99).min)
|
|
})
|
|
|
|
it('calculateGlacialSpikeFreezeLength: safe fallbacks, positive finite frames', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateGlacialSpikeFreezeLength(slvl)).not.toThrow()
|
|
const frz = calculateGlacialSpikeFreezeLength(slvl)
|
|
expect(Number.isFinite(frz)).toBe(true)
|
|
expect(frz).toBeGreaterThanOrEqual(50) // Level 1 freeze length
|
|
}
|
|
expect(calculateGlacialSpikeFreezeLength(0)).toBe(50)
|
|
expect(calculateGlacialSpikeFreezeLength(-1)).toBe(50)
|
|
expect(calculateGlacialSpikeFreezeLength(NaN)).toBe(50)
|
|
expect(calculateGlacialSpikeFreezeLength(undefined as any)).toBe(50)
|
|
expect(calculateGlacialSpikeFreezeLength(99)).toBe(50 + 98 * 3) // 344 frames
|
|
expect(calculateGlacialSpikeFreezeLength(1000)).toBe(50 + 999 * 3) // 3047 frames
|
|
})
|
|
})
|
|
|
|
describe('Fire Ground, Stream & Buff Calculations', () => {
|
|
it('calculateFireWallDamage: safe fallbacks, finite ranges and 256ths precision', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateFireWallDamage(slvl)).not.toThrow()
|
|
const dmg = calculateFireWallDamage(slvl)
|
|
expect(Number.isFinite(dmg.min)).toBe(true)
|
|
expect(Number.isFinite(dmg.max)).toBe(true)
|
|
expect(Number.isFinite(dmg.min256)).toBe(true)
|
|
expect(Number.isFinite(dmg.max256)).toBe(true)
|
|
expect(Number.isFinite(dmg.perSecMin)).toBe(true)
|
|
expect(Number.isFinite(dmg.perSecMax)).toBe(true)
|
|
expect(dmg.min).toBeLessThanOrEqual(dmg.max)
|
|
expect(dmg.min256).toBeLessThanOrEqual(dmg.max256)
|
|
expect(dmg.perSecMin).toBeLessThanOrEqual(dmg.perSecMax)
|
|
}
|
|
const fallback = calculateFireWallDamage(0)
|
|
expect(fallback.min).toBe(15)
|
|
expect(fallback.max).toBe(20)
|
|
expect(fallback.min256).toBe(240)
|
|
expect(fallback.max256).toBe(320)
|
|
expect(calculateFireWallDamage(-1)).toEqual(fallback)
|
|
expect(calculateFireWallDamage(NaN)).toEqual(fallback)
|
|
expect(calculateFireWallDamage(undefined as any)).toEqual(fallback)
|
|
})
|
|
|
|
it('calculateBlazeDamage: safe fallbacks, finite ranges and 256ths precision', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateBlazeDamage(slvl)).not.toThrow()
|
|
const dmg = calculateBlazeDamage(slvl)
|
|
expect(Number.isFinite(dmg.min)).toBe(true)
|
|
expect(Number.isFinite(dmg.max)).toBe(true)
|
|
expect(Number.isFinite(dmg.min256)).toBe(true)
|
|
expect(Number.isFinite(dmg.max256)).toBe(true)
|
|
expect(Number.isFinite(dmg.perSecMin)).toBe(true)
|
|
expect(Number.isFinite(dmg.perSecMax)).toBe(true)
|
|
expect(dmg.min).toBeLessThanOrEqual(dmg.max)
|
|
expect(dmg.min256).toBeLessThanOrEqual(dmg.max256)
|
|
expect(dmg.perSecMin).toBeLessThanOrEqual(dmg.perSecMax)
|
|
}
|
|
const fallback = calculateBlazeDamage(0)
|
|
expect(fallback.min).toBe(4)
|
|
expect(fallback.max).toBe(8)
|
|
expect(fallback.min256).toBe(64)
|
|
expect(fallback.max256).toBe(128)
|
|
expect(calculateBlazeDamage(-1)).toEqual(fallback)
|
|
expect(calculateBlazeDamage(NaN)).toEqual(fallback)
|
|
expect(calculateBlazeDamage(undefined as any)).toEqual(fallback)
|
|
})
|
|
|
|
it('calculateInfernoDamage: safe fallbacks, finite ranges and 256ths precision', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateInfernoDamage(slvl)).not.toThrow()
|
|
const dmg = calculateInfernoDamage(slvl)
|
|
expect(Number.isFinite(dmg.min)).toBe(true)
|
|
expect(Number.isFinite(dmg.max)).toBe(true)
|
|
expect(Number.isFinite(dmg.min256)).toBe(true)
|
|
expect(Number.isFinite(dmg.max256)).toBe(true)
|
|
expect(Number.isFinite(dmg.perSecMin)).toBe(true)
|
|
expect(Number.isFinite(dmg.perSecMax)).toBe(true)
|
|
expect(dmg.min).toBeLessThanOrEqual(dmg.max)
|
|
expect(dmg.min256).toBeLessThanOrEqual(dmg.max256)
|
|
expect(dmg.perSecMin).toBeLessThanOrEqual(dmg.perSecMax)
|
|
}
|
|
const fallback = calculateInfernoDamage(0)
|
|
expect(fallback.min).toBe(32)
|
|
expect(fallback.max).toBe(64)
|
|
expect(fallback.min256).toBe(128)
|
|
expect(fallback.max256).toBe(256)
|
|
expect(calculateInfernoDamage(-1)).toEqual(fallback)
|
|
expect(calculateInfernoDamage(NaN)).toEqual(fallback)
|
|
expect(calculateInfernoDamage(undefined as any)).toEqual(fallback)
|
|
})
|
|
|
|
it('calculateEnchantDamage: safe fallbacks, finite ranges and 256ths precision', () => {
|
|
for (const slvl of testLevels) {
|
|
expect(() => calculateEnchantDamage(slvl)).not.toThrow()
|
|
const dmg = calculateEnchantDamage(slvl)
|
|
expect(Number.isFinite(dmg.min)).toBe(true)
|
|
expect(Number.isFinite(dmg.max)).toBe(true)
|
|
expect(Number.isFinite(dmg.min256)).toBe(true)
|
|
expect(Number.isFinite(dmg.max256)).toBe(true)
|
|
expect(dmg.min).toBeLessThanOrEqual(dmg.max)
|
|
expect(dmg.min256).toBeLessThanOrEqual(dmg.max256)
|
|
}
|
|
const fallback = calculateEnchantDamage(0)
|
|
expect(fallback.min).toBe(8)
|
|
expect(fallback.max).toBe(10)
|
|
expect(fallback.min256).toBe(2048)
|
|
expect(fallback.max256).toBe(2560)
|
|
expect(calculateEnchantDamage(-1)).toEqual(fallback)
|
|
expect(calculateEnchantDamage(NaN)).toEqual(fallback)
|
|
expect(calculateEnchantDamage(undefined as any)).toEqual(fallback)
|
|
})
|
|
})
|
|
|
|
describe('Synergy Degenerate Inputs Fuzzing', () => {
|
|
it('handles negative, null, empty object, and partial synergies without throwing or propagating NaN', () => {
|
|
const degenerateSynergies = [
|
|
null,
|
|
undefined,
|
|
0,
|
|
-5,
|
|
-999,
|
|
{},
|
|
{ warmth: -10, fireMastery: -20 },
|
|
{ frozenArmor: -1, chillingArmor: -5 },
|
|
{ iceBolt: -5, blizzard: 0, frozenOrb: -10, glacialSpike: undefined },
|
|
{ synMultiplier: 0.5 },
|
|
{ synMultiplier: 1.0 },
|
|
]
|
|
|
|
for (const syn of degenerateSynergies) {
|
|
expect(() => calculateShiverArmorDamage(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateShiverArmorDamage(10, syn as any).min)).toBe(false)
|
|
|
|
expect(() => calculateChillingArmorDamage(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateChillingArmorDamage(10, syn as any).min)).toBe(false)
|
|
|
|
expect(() => calculateIceBlastDamage(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateIceBlastDamage(10, syn as any).min)).toBe(false)
|
|
|
|
expect(() => calculateIceBlastFreezeLength(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateIceBlastFreezeLength(10, syn as any))).toBe(false)
|
|
|
|
expect(() => calculateGlacialSpikeDamage(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateGlacialSpikeDamage(10, syn as any).min)).toBe(false)
|
|
|
|
expect(() => calculateGlacialSpikeFreezeLength(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateGlacialSpikeFreezeLength(10, syn as any))).toBe(false)
|
|
|
|
expect(() => calculateFireWallDamage(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateFireWallDamage(10, syn as any).min)).toBe(false)
|
|
|
|
expect(() => calculateBlazeDamage(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateBlazeDamage(10, syn as any).min)).toBe(false)
|
|
|
|
expect(() => calculateInfernoDamage(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateInfernoDamage(10, syn as any).min)).toBe(false)
|
|
|
|
expect(() => calculateEnchantDamage(10, syn as any)).not.toThrow()
|
|
expect(Number.isNaN(calculateEnchantDamage(10, syn as any).min)).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Dimension 2: Mana Depletion and Channeling Exhaustion
|
|
// ==========================================================================
|
|
describe('Dimension 2: Mana Depletion and Channeling Exhaustion', () => {
|
|
it('Inferno channeled stream: halts immediately when mana reaches 0 mid-stream and cleans up resources', () => {
|
|
const player = {
|
|
x: 100,
|
|
y: 100,
|
|
facing: 0,
|
|
cooldown: 0,
|
|
mana: 10,
|
|
maxMana: 100,
|
|
infernoChanneling: false,
|
|
infernoManaRemainder256: 0,
|
|
}
|
|
const engine: any = {
|
|
world: {
|
|
player,
|
|
events: [],
|
|
},
|
|
projectiles: [],
|
|
spawnOverlay: () => {},
|
|
metrics: { casts: 0 },
|
|
}
|
|
const status = { textContent: '' } as any
|
|
const context = {
|
|
engine,
|
|
runtime: {} as any,
|
|
hudManager: null,
|
|
status,
|
|
}
|
|
|
|
// Step 1: Initial cast consumes 6 mana
|
|
const cast1 = castSkill(41, 200, 100, context)
|
|
expect(cast1).toBe(true)
|
|
expect(player.mana).toBe(4) // 10 - 6 = 4
|
|
expect(player.infernoChanneling).toBe(true)
|
|
expect(player.infernoManaRemainder256).toBe(0)
|
|
expect(engine.projectiles).toHaveLength(1)
|
|
expect(engine.metrics.casts).toBe(1)
|
|
|
|
// Step 2: Continuous channeling frames (slvl 1 drains 144 / 256 per frame)
|
|
// Call 2: frame 1: accumulator 144, wholeMana 0, mana remains 4
|
|
const cast2 = castSkill(41, 200, 100, context)
|
|
expect(cast2).toBe(true)
|
|
expect(player.mana).toBe(4)
|
|
expect(player.infernoManaRemainder256).toBe(144)
|
|
expect(engine.projectiles).toHaveLength(2)
|
|
|
|
// Call 3: frame 2: accumulator 144+144 = 288, wholeMana 1, remainder 32, mana becomes 3
|
|
const cast3 = castSkill(41, 200, 100, context)
|
|
expect(cast3).toBe(true)
|
|
expect(player.mana).toBe(3)
|
|
expect(player.infernoManaRemainder256).toBe(32)
|
|
expect(engine.projectiles).toHaveLength(3)
|
|
|
|
// Step 3: Deplete player mana to 0 mid-stream
|
|
player.mana = 0
|
|
player.infernoManaRemainder256 = 100
|
|
|
|
// Call 4: Next frame while mana is 0 -> MUST halt stream and reject cast
|
|
const castEmpty = castSkill(41, 200, 100, context)
|
|
expect(castEmpty).toBe(false)
|
|
expect(status.textContent).toBe('法力不足!')
|
|
expect(player.infernoChanneling).toBe(false)
|
|
expect(player.infernoManaRemainder256).toBe(0)
|
|
// Ensure no new projectile was spawned when mana exhausted
|
|
expect(engine.projectiles).toHaveLength(3)
|
|
expect(engine.world.events).toContainEqual(
|
|
expect.objectContaining({ kind: 'noMana' }),
|
|
)
|
|
|
|
// Step 4: Subsequent call while exhausted continues to reject cleanly
|
|
const castSubsequent = castSkill(41, 200, 100, context)
|
|
expect(castSubsequent).toBe(false)
|
|
expect(player.infernoChanneling).toBe(false)
|
|
expect(engine.projectiles).toHaveLength(3)
|
|
})
|
|
|
|
it('Inferno: starting cast is rejected immediately when mana < 6 without setting channeling state', () => {
|
|
const player = {
|
|
x: 100,
|
|
y: 100,
|
|
facing: 0,
|
|
cooldown: 0,
|
|
mana: 5, // Insufficient (needs 6)
|
|
maxMana: 100,
|
|
infernoChanneling: false,
|
|
infernoManaRemainder256: 0,
|
|
}
|
|
const engine: any = {
|
|
world: {
|
|
player,
|
|
events: [],
|
|
},
|
|
projectiles: [],
|
|
spawnOverlay: () => {},
|
|
metrics: { casts: 0 },
|
|
}
|
|
const status = { textContent: '' } as any
|
|
const context = {
|
|
engine,
|
|
runtime: {} as any,
|
|
hudManager: null,
|
|
status,
|
|
}
|
|
|
|
const res = castSkill(41, 200, 100, context)
|
|
expect(res).toBe(false)
|
|
expect(player.mana).toBe(5) // Untouched
|
|
expect(player.infernoChanneling).toBe(false)
|
|
expect(player.infernoManaRemainder256).toBe(0)
|
|
expect(engine.projectiles).toHaveLength(0)
|
|
expect(engine.world.events).toContainEqual(
|
|
expect.objectContaining({ kind: 'noMana' }),
|
|
)
|
|
})
|
|
|
|
it('rejects casting spells when mana < cost across all Cold and Fire skills without state corruption', () => {
|
|
const skillsToTest = [
|
|
{ id: 40, name: 'Frozen Armor' },
|
|
{ id: 50, name: 'Shiver Armor' },
|
|
{ id: 60, name: 'Chilling Armor' },
|
|
{ id: 45, name: 'Ice Blast' },
|
|
{ id: 55, name: 'Glacial Spike' },
|
|
{ id: 51, name: 'Fire Wall' },
|
|
{ id: 46, name: 'Blaze' },
|
|
{ id: 41, name: 'Inferno' },
|
|
{ id: 52, name: 'Enchant' },
|
|
{ id: 59, name: 'Blizzard' },
|
|
{ id: 56, name: 'Meteor' },
|
|
]
|
|
|
|
for (const skill of skillsToTest) {
|
|
const manaCost = getSkillMana(skill.id, null)
|
|
const insufficientMana = Math.max(0, manaCost - 1)
|
|
const player = {
|
|
x: 100,
|
|
y: 100,
|
|
facing: 0,
|
|
cooldown: 0,
|
|
mana: insufficientMana,
|
|
maxMana: 100,
|
|
infernoChanneling: false,
|
|
infernoManaRemainder256: 0,
|
|
blazeActive: false,
|
|
enchantActive: false,
|
|
}
|
|
const engine: any = {
|
|
world: {
|
|
player,
|
|
events: [],
|
|
},
|
|
projectiles: [],
|
|
overlays: [],
|
|
spawnOverlay: () => {},
|
|
metrics: { casts: 0 },
|
|
}
|
|
const status = { textContent: '' } as any
|
|
const context = {
|
|
engine,
|
|
runtime: {} as any,
|
|
hudManager: null,
|
|
status,
|
|
}
|
|
|
|
const success = castSkill(skill.id, 200, 100, context)
|
|
expect(success).toBe(false)
|
|
// INVARIANTS: Zero resource/state corruption
|
|
expect(player.mana).toBe(insufficientMana)
|
|
expect(player.cooldown).toBe(0)
|
|
expect(engine.metrics.casts).toBe(0)
|
|
expect(engine.projectiles).toHaveLength(0)
|
|
expect(engine.overlays).toHaveLength(0)
|
|
expect(status.textContent).toBe('法力不足!')
|
|
expect(engine.world.events).toContainEqual(
|
|
expect.objectContaining({ kind: 'noMana' }),
|
|
)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Dimension 3: Cooldown & Cast Delay Integrity
|
|
// ==========================================================================
|
|
describe('Dimension 3: Cooldown & Cast Delay Integrity', () => {
|
|
it('verifies exact 1.13c cast delay values across delayed skills and zero cooldown for non-delayed skills', () => {
|
|
// Delayed skills:
|
|
expect(getSkillCooldownTicks(51)).toBe(35) // Fire Wall: 35 frames (1.40s)
|
|
expect(getSkillCooldownTicks(56)).toBe(30) // Meteor: 30 frames (1.20s)
|
|
expect(getSkillCooldownTicks(59)).toBe(45) // Blizzard: 45 frames (1.80s)
|
|
expect(getSkillCooldownTicks(62)).toBe(40) // Hydra: 40 frames (1.60s)
|
|
expect(getSkillCooldownTicks(64)).toBe(25) // Frozen Orb: 25 frames (1.00s)
|
|
|
|
// Non-delayed Cold & Fire skills (strictly 0 frames):
|
|
const nonDelayedColdFireSkills = [
|
|
40, // Frozen Armor
|
|
50, // Shiver Armor
|
|
60, // Chilling Armor
|
|
45, // Ice Blast
|
|
55, // Glacial Spike
|
|
65, // Cold Mastery
|
|
36, // Fire Bolt
|
|
47, // Fire Ball
|
|
37, // Warmth
|
|
41, // Inferno
|
|
46, // Blaze
|
|
52, // Enchant
|
|
61, // Fire Mastery
|
|
]
|
|
for (const skillId of nonDelayedColdFireSkills) {
|
|
expect(getSkillCooldownTicks(skillId)).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('executeSkill: cast delay locks global cast delay group while allowing non-delayed spells', () => {
|
|
const caster = createPlayer(100, 100, 100, 100)
|
|
const context: SkillExecutionContext = {
|
|
caster,
|
|
skillLevel: 1,
|
|
}
|
|
|
|
// Step 1: Simulate caster on Blizzard cast delay (45 ticks remaining)
|
|
caster.cooldown = 45
|
|
|
|
// Step 2: Attempting to cast any other delayed spell MUST be rejected by cooldown check
|
|
// Werewolf (223, delay 25)
|
|
const delayedCast1 = executeSkill(223, context)
|
|
expect(delayedCast1.kind).toBe('cooldown')
|
|
if (delayedCast1.kind === 'cooldown') {
|
|
expect(delayedCast1.ticksLeft).toBe(45)
|
|
}
|
|
|
|
// Firestorm (225, delay 15)
|
|
const delayedCast2 = executeSkill(225, context)
|
|
expect(delayedCast2.kind).toBe('cooldown')
|
|
if (delayedCast2.kind === 'cooldown') {
|
|
expect(delayedCast2.ticksLeft).toBe(45)
|
|
}
|
|
|
|
// Molten Boulder (229, delay 50)
|
|
const delayedCast3 = executeSkill(229, context)
|
|
expect(delayedCast3.kind).toBe('cooldown')
|
|
if (delayedCast3.kind === 'cooldown') {
|
|
expect(delayedCast3.ticksLeft).toBe(45)
|
|
}
|
|
|
|
// Step 3: Under the exact same cooldown (cooldown = 45), casting non-delayed spells MUST succeed!
|
|
// Ice Bolt (39, delay 0)
|
|
const nonDelayed1 = executeSkill(39, context)
|
|
expect(nonDelayed1.kind).toBe('projectile')
|
|
if (nonDelayed1.kind === 'projectile') {
|
|
expect(nonDelayed1.cooldownTicks).toBe(0)
|
|
}
|
|
|
|
// Fire Bolt (36, delay 0)
|
|
const nonDelayed2 = executeSkill(36, context)
|
|
expect(nonDelayed2.kind).toBe('projectile')
|
|
if (nonDelayed2.kind === 'projectile') {
|
|
expect(nonDelayed2.cooldownTicks).toBe(0)
|
|
}
|
|
|
|
// Fire Ball (47, delay 0)
|
|
const nonDelayed3 = executeSkill(47, context)
|
|
expect(nonDelayed3.kind).toBe('projectile')
|
|
if (nonDelayed3.kind === 'projectile') {
|
|
expect(nonDelayed3.cooldownTicks).toBe(0)
|
|
}
|
|
|
|
// Frost Nova (44, delay 0)
|
|
const nonDelayed4 = executeSkill(44, context)
|
|
expect(nonDelayed4.kind).toBe('area')
|
|
if (nonDelayed4.kind === 'area') {
|
|
expect(nonDelayed4.cooldownTicks).toBe(0)
|
|
}
|
|
|
|
// Teleport (54, delay 0)
|
|
const nonDelayed5 = executeSkill(54, context)
|
|
expect(nonDelayed5.kind).toBe('movement')
|
|
if (nonDelayed5.kind === 'movement') {
|
|
expect(nonDelayed5.cooldownTicks).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('castSkill: verifies cooldown duration application and tick countdown unlocking', () => {
|
|
const player = {
|
|
x: 100,
|
|
y: 100,
|
|
facing: 0,
|
|
cooldown: 0,
|
|
mana: 100,
|
|
maxMana: 100,
|
|
infernoChanneling: false,
|
|
infernoManaRemainder256: 0,
|
|
}
|
|
const engine: any = {
|
|
world: {
|
|
player,
|
|
events: [],
|
|
},
|
|
projectiles: [],
|
|
spawnOverlay: () => {},
|
|
metrics: { casts: 0 },
|
|
}
|
|
const status = { textContent: '' } as any
|
|
const context = {
|
|
engine,
|
|
runtime: {} as any,
|
|
hudManager: null,
|
|
status,
|
|
}
|
|
|
|
// Cast Fire Wall (51): sets cooldown to exactly 35
|
|
const fwCast = castSkill(51, 200, 100, context)
|
|
expect(fwCast).toBe(true)
|
|
expect(player.cooldown).toBe(35)
|
|
|
|
// While cooldown > 0, immediate second cast is blocked
|
|
const blockedCast = castSkill(51, 200, 100, context)
|
|
expect(blockedCast).toBe(false)
|
|
|
|
// Countdown ticks from 35 down to 1
|
|
for (let t = 35; t > 0; t--) {
|
|
expect(player.cooldown).toBe(t)
|
|
const rejected = castSkill(51, 200, 100, context)
|
|
expect(rejected).toBe(false)
|
|
player.cooldown -= 1
|
|
}
|
|
|
|
// Once at 0, casting succeeds again
|
|
expect(player.cooldown).toBe(0)
|
|
const unblockedCast = castSkill(51, 200, 100, context)
|
|
expect(unblockedCast).toBe(true)
|
|
expect(player.cooldown).toBe(35)
|
|
|
|
// Meteor (56): sets cooldown to exactly 30
|
|
player.cooldown = 0
|
|
const meteorCast = castSkill(56, 200, 100, context)
|
|
expect(meteorCast).toBe(true)
|
|
expect(player.cooldown).toBe(30)
|
|
|
|
// Blizzard (59): sets cooldown to exactly 45
|
|
player.cooldown = 0
|
|
const blizzardCast = castSkill(59, 200, 100, context)
|
|
expect(blizzardCast).toBe(true)
|
|
expect(player.cooldown).toBe(45)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Dimension 4: Screen & Spatial Collision Boundaries
|
|
// ==========================================================================
|
|
describe('Dimension 4: Screen & Spatial Collision Boundaries', () => {
|
|
describe('Raycasting (hasLineOfSight) Boundary & Stress Tests', () => {
|
|
it('hasLineOfSight: handles extreme coordinates (-9999 to +9999) without deadlocks, NaN or stack overflows', () => {
|
|
const extremeVectors = [
|
|
{ x0: -9999, y0: -9999, x1: 9999, y1: 9999 },
|
|
{ x0: 9999, y0: -9999, x1: -9999, y1: 9999 },
|
|
{ x0: -9999, y0: 0, x1: 9999, y1: 0 },
|
|
{ x0: 0, y0: -9999, x1: 0, y1: 9999 },
|
|
{ x0: -9999, y0: 9999, x1: 9999, y1: -9999 },
|
|
]
|
|
|
|
// Open terrain (no obstacles) -> completes rapidly
|
|
for (const vec of extremeVectors) {
|
|
const t0 = Date.now()
|
|
const result = hasLineOfSight(vec.x0, vec.y0, vec.x1, vec.y1, undefined)
|
|
const elapsed = Date.now() - t0
|
|
expect(result).toBe(true)
|
|
expect(elapsed).toBeLessThan(100) // Must not hang/deadlock
|
|
}
|
|
|
|
// Obstacle in path at (0, 0)
|
|
const wallTerrain = (x: number, y: number) => Math.hypot(x, y) < 16
|
|
expect(hasLineOfSight(-100, 0, 100, 0, wallTerrain)).toBe(false)
|
|
// Clear trajectory above obstacle
|
|
expect(hasLineOfSight(-100, 50, 100, 50, wallTerrain)).toBe(true)
|
|
})
|
|
|
|
it('hasLineOfSight: handles sub-pixel fractions and degenerate zero distance', () => {
|
|
// Zero distance (x0 === x1, y0 === y1)
|
|
expect(hasLineOfSight(500, 500, 500, 500)).toBe(true)
|
|
expect(hasLineOfSight(-9999.123, 9999.456, -9999.123, 9999.456)).toBe(true)
|
|
|
|
// Sub-pixel micro increments
|
|
expect(hasLineOfSight(100.123456, 200.654321, 100.123457, 200.654322)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('MissileEngine Projectile & Collision Stress', () => {
|
|
it('spawns and steps Glacial Spike & Ice Blast at extreme coordinates without NaN or crashes', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const engine = new MissileEngine(registry)
|
|
const attacker = createMockCombatUnit('sorceress')
|
|
const target = createMockCombatUnit('far_target')
|
|
|
|
// Spawn Glacial Spike across extreme boundary: start (-9999, -9999), target (+9999, +9999)
|
|
const mGlacial = engine.spawnMissile({
|
|
missileNameOrId: 'glacialspike',
|
|
sourceSkillId: 55,
|
|
slvl: 20,
|
|
owner: attacker,
|
|
startX: -9999,
|
|
startY: -9999,
|
|
targetX: 9999,
|
|
targetY: 9999,
|
|
dmgPacket: {
|
|
skillId: 55,
|
|
elemType: 'cold',
|
|
elemMin256: 1000 * 256,
|
|
elemMax256: 1000 * 256,
|
|
elemDurationFrames: 100,
|
|
},
|
|
})
|
|
expect(mGlacial).not.toBeNull()
|
|
expect(Number.isFinite(mGlacial!.vx)).toBe(true)
|
|
expect(Number.isFinite(mGlacial!.vy)).toBe(true)
|
|
expect(Number.isNaN(mGlacial!.vx)).toBe(false)
|
|
expect(Number.isNaN(mGlacial!.vy)).toBe(false)
|
|
|
|
// Spawn Ice Blast at reverse extreme coordinates (+9999, -9999) -> (-9999, +9999)
|
|
const mBlast = engine.spawnMissile({
|
|
missileNameOrId: 'iceblast',
|
|
sourceSkillId: 45,
|
|
slvl: 20,
|
|
owner: attacker,
|
|
startX: 9999,
|
|
startY: -9999,
|
|
targetX: -9999,
|
|
targetY: 9999,
|
|
dmgPacket: {
|
|
skillId: 45,
|
|
elemType: 'cold',
|
|
elemMin256: 1000 * 256,
|
|
elemMax256: 1000 * 256,
|
|
elemDurationFrames: 100,
|
|
},
|
|
})
|
|
expect(mBlast).not.toBeNull()
|
|
expect(Number.isFinite(mBlast!.vx)).toBe(true)
|
|
expect(Number.isFinite(mBlast!.vy)).toBe(true)
|
|
|
|
// Update loop with target at extreme coordinate
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
targetPositions.set('far_target', { x: 9999, y: 9999 })
|
|
expect(() => engine.tick(1, [target], targetPositions)).not.toThrow()
|
|
})
|
|
|
|
it('Glacial Spike AoE burst: strictly respects 64px radial boundary on wall collision at sub-pixel coordinates', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const attacker = createMockCombatUnit('sorceress')
|
|
|
|
// Target exactly at wall impact point (0px offset)
|
|
const tDirect = createMockCombatUnit('t_direct')
|
|
// Target at 64.0px radius (inside burst boundary)
|
|
const tEdgeIn = createMockCombatUnit('t_64px')
|
|
// Target at 64.1px radius (outside burst boundary)
|
|
const tEdgeOut = createMockCombatUnit('t_64_1px')
|
|
// Target at 100px radius (far outside)
|
|
const tFar = createMockCombatUnit('t_100px')
|
|
|
|
const targets = [tDirect, tEdgeIn, tEdgeOut, tFar]
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
|
|
// Sub-pixel wall impact coordinate: (100.25, 200.75)
|
|
const impactX = 100.25
|
|
const impactY = 200.75
|
|
|
|
targetPositions.set(tDirect.id, { x: impactX, y: impactY })
|
|
targetPositions.set(tEdgeIn.id, { x: impactX, y: impactY + 64.0 })
|
|
targetPositions.set(tEdgeOut.id, { x: impactX, y: impactY + 64.1 })
|
|
targetPositions.set(tFar.id, { x: impactX, y: impactY + 100.0 })
|
|
|
|
// Terrain with blocking obstacle at impactX
|
|
const terrain = {
|
|
isMissileBlocked: (x: number) => x >= impactX,
|
|
}
|
|
|
|
const engine = new MissileEngine(registry, terrain)
|
|
engine.spawnMissile({
|
|
missileNameOrId: 'glacialspike',
|
|
sourceSkillId: 55,
|
|
slvl: 20,
|
|
owner: attacker,
|
|
startX: impactX - 24,
|
|
startY: impactY,
|
|
targetX: impactX + 24,
|
|
targetY: impactY,
|
|
vx: 24,
|
|
vy: 0,
|
|
dmgPacket: {
|
|
skillId: 55,
|
|
elemType: 'cold',
|
|
elemMin256: 500 * 256,
|
|
elemMax256: 500 * 256,
|
|
elemDurationFrames: 100,
|
|
},
|
|
})
|
|
|
|
const updateResult = engine.tick(1, targets, targetPositions, terrain)
|
|
|
|
// Sub-missiles spawned on detonation
|
|
expect(updateResult.subMissilesSpawned).toContain('glacialspikeejecta')
|
|
|
|
// Verify splash hits: tDirect and tEdgeIn hit, tEdgeOut and tFar NOT hit
|
|
const hitTargetIds = updateResult.hits.map(h => h.targetId)
|
|
expect(hitTargetIds).toContain(tDirect.id)
|
|
expect(hitTargetIds).toContain(tEdgeIn.id)
|
|
expect(hitTargetIds).not.toContain(tEdgeOut.id)
|
|
expect(hitTargetIds).not.toContain(tFar.id)
|
|
|
|
// Freeze state applied to units within 64px
|
|
expect(tDirect.stateBus.hasState('freeze')).toBe(true)
|
|
expect(tEdgeIn.stateBus.hasState('freeze')).toBe(true)
|
|
expect(tEdgeOut.stateBus.hasState('freeze')).toBe(false)
|
|
expect(tFar.stateBus.hasState('freeze')).toBe(false)
|
|
})
|
|
|
|
it('calculateFireWallPatches: produces robust integer layouts at extreme coordinates and zero distance', () => {
|
|
// Extreme coordinates
|
|
const patchesExtreme = calculateFireWallPatches(-9999, -9999, 9999, 9999)
|
|
expect(patchesExtreme).toHaveLength(15)
|
|
for (const p of patchesExtreme) {
|
|
expect(Number.isFinite(p.x)).toBe(true)
|
|
expect(Number.isFinite(p.y)).toBe(true)
|
|
expect(Number.isNaN(p.x)).toBe(false)
|
|
expect(Number.isNaN(p.y)).toBe(false)
|
|
}
|
|
|
|
// Degenerate zero distance: caster === target
|
|
const patchesZero = calculateFireWallPatches(150, 150, 150, 150)
|
|
expect(patchesZero).toHaveLength(15)
|
|
for (const p of patchesZero) {
|
|
expect(Number.isFinite(p.x)).toBe(true)
|
|
expect(Number.isFinite(p.y)).toBe(true)
|
|
}
|
|
|
|
// Sub-pixel positions
|
|
const patchesSub = calculateFireWallPatches(10.123, 20.456, 30.789, 40.012)
|
|
expect(patchesSub).toHaveLength(15)
|
|
for (const p of patchesSub) {
|
|
expect(Number.isInteger(p.x)).toBe(true)
|
|
expect(Number.isInteger(p.y)).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|