709 lines
27 KiB
TypeScript
709 lines
27 KiB
TypeScript
/**
|
||
* Adversarial Stress Test Harness — Batch 1 Gate (Issues #562–#576)
|
||
*
|
||
* Authored by Empirical Challenger (Challenger 1)
|
||
*
|
||
* Areas Stress-Tested:
|
||
* 1. Missiles & Combat (#562, #568, #571, #572, #574, #576):
|
||
* - 0-range and stationary missiles lifecycle, maxTicks calculation, and clean expiration
|
||
* - Extreme attack rating vs defense edge cases (alvl=1 vs 99, 99 vs 1, effAr=0, effDef=0, large negative mods)
|
||
* - 10,000-case randomized fuzzing against 1.13c integer division oracle
|
||
* - Multiple Shot projectile count cap at slvl >= 22 (slvl 22..99 -> exactly 24 projectiles)
|
||
* - Multiple Shot weapon ammunition branching (bow -> multipleshotarrow 213, crossbow -> multipleshotbolt 214)
|
||
* - 1000ms deduplication window under simulated burst traffic and out-of-window packet handling
|
||
* - Unknown missile classId fail-fast verification (#574)
|
||
* 2. Timing & Rate Limiting (#565, #566, #573, #575):
|
||
* - 200ms tick accumulator cap under sudden frame lag (dtMs = 1000ms, 5000ms, 100000ms)
|
||
* - Sustained frame lag resilience (no death spiral, simulation step bounds)
|
||
* - 100-packet non-movement command flood bypass (100% allowed, zero dropped)
|
||
* - Movement rate-limiting preservation (120ms throttle)
|
||
*/
|
||
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import { ClientWorld } from '../src/client/world/client-world.ts'
|
||
import { OnlineSession } from '../src/client/session/online-session.ts'
|
||
import { OutboundRateLimiter, DEFAULT_RATE_LIMITS, classifyClientCommand } from '../src/client/session/rate-limiter.ts'
|
||
import { CANONICAL_113C_MISSILES } from '../src/common/skills/missiles-data.ts'
|
||
import { computeToHitChance } from '../src/server/engine/combat-pipeline.ts'
|
||
import {
|
||
createInitialState,
|
||
calculateDetailedDamage,
|
||
evaluateLevelBand,
|
||
} from '../src/common/skills/skill-calc-engine.ts'
|
||
import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts'
|
||
import type { ClientCommand } from '../src/netproto/domain/client-command.ts'
|
||
|
||
describe('Empirical Challenger: Batch 1 Gate Stress Harness', () => {
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// 1. MISSILES & COMBAT FORMULAS (#562, #568, #571, #572, #574, #576)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
describe('1. Stationary & 0-Range Missiles (#568)', () => {
|
||
it('accurately computes maxTicks from animation parameters for 0-velocity, 0-range missiles', () => {
|
||
const world = new ClientWorld()
|
||
|
||
// Find all missiles in CANONICAL_113C_MISSILES with vel === 0 and range === 0
|
||
const zeroVelRangeMissiles = Object.entries(CANONICAL_113C_MISSILES).filter(
|
||
([_, data]) => data.vel === 0 && data.range === 0
|
||
)
|
||
|
||
expect(zeroVelRangeMissiles.length).toBeGreaterThan(0)
|
||
|
||
for (const [key, data] of zeroVelRangeMissiles) {
|
||
const unit = world.spawnMissileByKey(key, 100, 100, 100, 100)
|
||
expect(unit).toBeDefined()
|
||
expect(unit.x).toBe(100)
|
||
expect(unit.y).toBe(100)
|
||
expect(unit.vx).toBe(0)
|
||
expect(unit.vy).toBe(0)
|
||
|
||
// For !loopAnim && vel === 0: maxTicks = Math.max(1, Math.ceil((animLen * 16) / animSpeed))
|
||
const animLen = Math.max(1, data.animLen || 1)
|
||
const animSpeed = Math.max(1, data.animSpeed || 16)
|
||
const expectedTicks = Math.max(1, Math.ceil((animLen * 16) / animSpeed))
|
||
expect(unit.maxTicks).toBe(expectedTicks)
|
||
}
|
||
})
|
||
|
||
it('stationary missiles do not drift or check obstacle collision, and expire cleanly without leaking', () => {
|
||
const world = new ClientWorld()
|
||
|
||
// Spawn explosive potion debris (vel = 0, range = 0, animLen = 29, animSpeed = 16 -> maxTicks = 29)
|
||
const debris = world.spawnMissileByKey('explosivepotiondebris1', 50, 50, 50, 50)
|
||
const key = debris.key
|
||
expect(world.units.has(key)).toBe(true)
|
||
expect(debris.maxTicks).toBe(29)
|
||
|
||
// Step world 28 ticks (1 tick before expiry)
|
||
for (let i = 0; i < 28; i++) {
|
||
world.tick(40, 1000 + i * 40)
|
||
const current = world.units.get(key)
|
||
expect(current).toBeDefined()
|
||
expect(current?.x).toBe(50) // No movement
|
||
expect(current?.y).toBe(50)
|
||
expect(current?.ageTicks).toBe(i + 1)
|
||
}
|
||
|
||
// Step 29th tick: ageTicks reaches maxTicks (29), unit must be deleted
|
||
world.tick(40, 1000 + 28 * 40)
|
||
expect(world.units.has(key)).toBe(false)
|
||
})
|
||
|
||
it('spawning a moving missile with startX === targetX and startY === targetY handles 0-distance safely', () => {
|
||
const world = new ClientWorld()
|
||
|
||
// Arrow (vel = 24, range = 40) spawned at point blank (0 distance)
|
||
const arrow = world.spawnMissileByKey('arrow', 200, 200, 200, 200)
|
||
expect(arrow).toBeDefined()
|
||
// When dist < 1e-5, speedSubTilesPerTick = 24 / 8 = 3, vx = 3, vy = 0 (defaults to East)
|
||
expect(arrow.vx).toBe(3)
|
||
expect(arrow.vy).toBe(0)
|
||
expect(Number.isFinite(arrow.vx)).toBe(true)
|
||
expect(Number.isFinite(arrow.vy)).toBe(true)
|
||
expect(Number.isNaN(arrow.vx)).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('2. Attack Rating vs Defense Edge Cases (#571)', () => {
|
||
// Independent Diablo II v1.13c ToHit oracle
|
||
function oracleToHit(params: {
|
||
ar: number
|
||
def: number
|
||
alvl: number
|
||
dlvl: number
|
||
arBonus?: number
|
||
defMod?: number
|
||
autoHit?: boolean
|
||
}): number {
|
||
if (params.autoHit) return 100
|
||
const arBonus = params.arBonus ?? 0
|
||
const effAr = Math.max(1, Math.trunc((params.ar * (100 + arBonus)) / 100))
|
||
const defMod = params.defMod ?? 0
|
||
const effDef = Math.max(0, Math.trunc((params.def * Math.max(0, 100 + defMod)) / 100))
|
||
const alvl = Math.max(1, params.alvl)
|
||
const dlvl = Math.max(1, params.dlvl)
|
||
const divisor = effAr + effDef
|
||
const toHitFactor = divisor > 0 ? Math.trunc((100 * effAr) / divisor) : 100
|
||
const raw = Math.trunc((2 * alvl * toHitFactor) / (alvl + dlvl))
|
||
return Math.max(5, Math.min(95, raw))
|
||
}
|
||
|
||
it('handles extreme level differentials (alvl=1 vs dlvl=99 and alvl=99 vs dlvl=1)', () => {
|
||
// Level 1 attacking Level 99 with equal AR and Def
|
||
const lowAttacker = computeToHitChance({
|
||
attackerAr: 1000,
|
||
defenderDef: 1000,
|
||
attackerLvl: 1,
|
||
defenderLvl: 99,
|
||
})
|
||
// effAr = 1000, effDef = 1000 -> toHitFactor = 50. raw = (2 * 1 * 50) / 100 = 1 -> clamped to 5
|
||
expect(lowAttacker).toBe(5)
|
||
expect(lowAttacker).toBe(oracleToHit({ ar: 1000, def: 1000, alvl: 1, dlvl: 99 }))
|
||
|
||
// Level 99 attacking Level 1 with equal AR and Def
|
||
const highAttacker = computeToHitChance({
|
||
attackerAr: 1000,
|
||
defenderDef: 1000,
|
||
attackerLvl: 99,
|
||
defenderLvl: 1,
|
||
})
|
||
// effAr = 1000, effDef = 1000 -> toHitFactor = 50. raw = (2 * 99 * 50) / 100 = 99 -> clamped to 95
|
||
expect(highAttacker).toBe(95)
|
||
expect(highAttacker).toBe(oracleToHit({ ar: 1000, def: 1000, alvl: 99, dlvl: 1 }))
|
||
})
|
||
|
||
it('handles zero AR (effAr=0 clamped to 1) without division by zero', () => {
|
||
const zeroAr = computeToHitChance({
|
||
attackerAr: 0,
|
||
defenderDef: 1000,
|
||
attackerLvl: 50,
|
||
defenderLvl: 50,
|
||
})
|
||
// effAr clamps to 1. divisor = 1001. toHitFactor = Math.trunc(100/1001) = 0. raw = 0 -> clamped to 5
|
||
expect(zeroAr).toBe(5)
|
||
expect(zeroAr).toBe(oracleToHit({ ar: 0, def: 1000, alvl: 50, dlvl: 50 }))
|
||
})
|
||
|
||
it('handles zero Defense (effDef=0) cleanly', () => {
|
||
const zeroDef = computeToHitChance({
|
||
attackerAr: 500,
|
||
defenderDef: 0,
|
||
attackerLvl: 50,
|
||
defenderLvl: 50,
|
||
})
|
||
// effDef = 0. divisor = 500. toHitFactor = 100. raw = (2 * 50 * 100) / 100 = 100 -> clamped to 95
|
||
expect(zeroDef).toBe(95)
|
||
expect(zeroDef).toBe(oracleToHit({ ar: 500, def: 0, alvl: 50, dlvl: 50 }))
|
||
})
|
||
|
||
it('handles both AR=0 and Def=0 simultaneously', () => {
|
||
const bothZero = computeToHitChance({
|
||
attackerAr: 0,
|
||
defenderDef: 0,
|
||
attackerLvl: 30,
|
||
defenderLvl: 30,
|
||
})
|
||
// effAr=1, effDef=0 -> divisor=1 -> toHitFactor=100 -> raw=100 -> clamped to 95
|
||
expect(bothZero).toBe(95)
|
||
expect(bothZero).toBe(oracleToHit({ ar: 0, def: 0, alvl: 30, dlvl: 30 }))
|
||
})
|
||
|
||
it('handles extreme defense reduction (-100% or greater) without negative defense', () => {
|
||
const extremeDefReduction = computeToHitChance({
|
||
attackerAr: 200,
|
||
defenderDef: 5000,
|
||
attackerLvl: 20,
|
||
defenderLvl: 20,
|
||
targetDefPctMod: -150, // -150% Defense reduction
|
||
})
|
||
// effDef = max(0, 5000 * max(0, 100 - 150) / 100) = 0
|
||
// divisor = 200. toHitFactor = 100. raw = 100 -> clamped to 95
|
||
expect(extremeDefReduction).toBe(95)
|
||
expect(extremeDefReduction).toBe(oracleToHit({ ar: 200, def: 5000, alvl: 20, dlvl: 20, defMod: -150 }))
|
||
})
|
||
|
||
it('matches 1.13c integer division oracle across 10,000 randomized fuzzing inputs', () => {
|
||
// High-volume property fuzzing test
|
||
let seed = 1337
|
||
function nextRand(): number {
|
||
seed = (seed * 1664525 + 1013904223) >>> 0
|
||
return seed / 0xffffffff
|
||
}
|
||
|
||
for (let i = 0; i < 10000; i++) {
|
||
const ar = Math.floor(nextRand() * 20000)
|
||
const def = Math.floor(nextRand() * 20000)
|
||
const alvl = 1 + Math.floor(nextRand() * 99)
|
||
const dlvl = 1 + Math.floor(nextRand() * 99)
|
||
const arBonus = Math.floor(nextRand() * 500) - 50 // -50% to +450%
|
||
const defMod = Math.floor(nextRand() * 200) - 100 // -100% to +100%
|
||
const autoHit = nextRand() < 0.05
|
||
|
||
const actual = computeToHitChance({
|
||
attackerAr: ar,
|
||
defenderDef: def,
|
||
attackerLvl: alvl,
|
||
defenderLvl: dlvl,
|
||
skillArBonusPct: arBonus,
|
||
targetDefPctMod: defMod,
|
||
autoHit,
|
||
})
|
||
|
||
const expected = oracleToHit({
|
||
ar,
|
||
def,
|
||
alvl,
|
||
dlvl,
|
||
arBonus,
|
||
defMod,
|
||
autoHit,
|
||
})
|
||
|
||
if (actual !== expected) {
|
||
throw new Error(
|
||
`Fuzz mismatch at case ${i}: ar=${ar}, def=${def}, alvl=${alvl}, dlvl=${dlvl}, arBonus=${arBonus}, defMod=${defMod}, autoHit=${autoHit}. Expected ${expected}, got ${actual}`
|
||
)
|
||
}
|
||
expect(actual).toBeGreaterThanOrEqual(5)
|
||
expect(actual).toBeLessThanOrEqual(100)
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('3. Multiple Shot Projectile Cap & Ammunition Branching (#562)', () => {
|
||
it('strictly caps projectile count at 24 for slvl >= 22 (slvl 22..99 all spawn exactly 24 projectiles)', () => {
|
||
const testLevels = [1, 2, 5, 10, 20, 21, 22, 23, 24, 25, 30, 50, 99]
|
||
|
||
for (const slvl of testLevels) {
|
||
const world = new ClientWorld()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'player', id: 1, classId: 0, name: 'AmazonTest', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
caster: { unitType: 'player', id: 1 },
|
||
skill: 12,
|
||
skillId: 12,
|
||
level: slvl,
|
||
target: { x: 5020, y: 5000 },
|
||
})
|
||
|
||
const missiles = Array.from(world.units.values()).filter(
|
||
(u) => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow'
|
||
)
|
||
|
||
// Expected formula: Math.min(24, 2 + Math.max(1, slvl))
|
||
const expectedCount = Math.min(24, 2 + slvl)
|
||
expect(missiles.length).toBe(expectedCount)
|
||
expect(missiles.length).toBeLessThanOrEqual(24)
|
||
|
||
if (slvl >= 22) {
|
||
expect(missiles.length).toBe(24)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('branches ammunition to multipleshotbolt (classId 214) when crossbow is equipped', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'player', id: 1, classId: 0, name: 'AmazonXBow', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
world.selfId = 1
|
||
|
||
// Equip a Heavy Crossbow (code: 'hxb') in right-hand weapon slot (slot 4)
|
||
;(world.containers.equipmentBySlot as Map<number, any>).set(4, {
|
||
id: 99,
|
||
code: 'hxb',
|
||
name: 'Heavy Crossbow',
|
||
width: 2,
|
||
height: 4,
|
||
slot: 4,
|
||
} as any)
|
||
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
caster: { unitType: 'player', id: 1 },
|
||
skill: 12,
|
||
skillId: 12,
|
||
level: 10,
|
||
target: { x: 5020, y: 5000 },
|
||
})
|
||
|
||
const missiles = Array.from(world.units.values()).filter((u) => u.unitType === 'missile')
|
||
expect(missiles.length).toBe(12) // 2 + 10 = 12
|
||
|
||
// All missiles must be bolts, not arrows!
|
||
expect(missiles.every((m) => m.missileKey === 'multipleshotbolt')).toBe(true)
|
||
expect(missiles.every((m) => m.classId === 214)).toBe(true)
|
||
})
|
||
|
||
it('branches ammunition to multipleshotarrow (classId 213) when standard bow is equipped', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'player', id: 1, classId: 0, name: 'AmazonBow', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
world.selfId = 1
|
||
|
||
// Equip a Short Bow (code: 'sbw') in right-hand weapon slot (slot 4)
|
||
;(world.containers.equipmentBySlot as Map<number, any>).set(4, {
|
||
id: 98,
|
||
code: 'sbw',
|
||
name: 'Short Bow',
|
||
width: 2,
|
||
height: 3,
|
||
slot: 4,
|
||
} as any)
|
||
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
caster: { unitType: 'player', id: 1 },
|
||
skill: 12,
|
||
skillId: 12,
|
||
level: 10,
|
||
target: { x: 5020, y: 5000 },
|
||
})
|
||
|
||
const missiles = Array.from(world.units.values()).filter((u) => u.unitType === 'missile')
|
||
expect(missiles.length).toBe(12)
|
||
|
||
// All missiles must be arrows!
|
||
expect(missiles.every((m) => m.missileKey === 'multipleshotarrow')).toBe(true)
|
||
expect(missiles.every((m) => m.classId === 213)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('4. 1000ms Deduplication Window Under Simulated Packet Bursts (#562, #576)', () => {
|
||
it('deduplicates server echo within 1000ms window preventing duplicate visual entities', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'player', id: 1, classId: 1, name: 'Sorceress', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
world.selfId = 1
|
||
|
||
// Local player casts Fire Bolt (skillId 36) at t = 1000ms
|
||
const localCmd: ClientCommand = {
|
||
type: 'CastRightSkillOnLocation',
|
||
x: 5020,
|
||
y: 5000,
|
||
}
|
||
world.player.rightSkill = 36
|
||
world.noteOutgoingCommand(localCmd, 1000)
|
||
|
||
const initialMissiles = Array.from(world.units.values()).filter((u) => u.unitType === 'missile')
|
||
expect(initialMissiles.length).toBe(1)
|
||
const firstMissileId = initialMissiles[0].id
|
||
|
||
// Server echoes SkillCast at t = 1500ms (500ms later, within 1000ms window)
|
||
world.apply(
|
||
{
|
||
type: 'SkillCast',
|
||
caster: { unitType: 'player', id: 1 },
|
||
skill: 36,
|
||
skillId: 36,
|
||
level: 1,
|
||
target: { x: 5020, y: 5000 },
|
||
},
|
||
1500
|
||
)
|
||
|
||
// Must NOT have spawned a second missile!
|
||
const currentMissiles = Array.from(world.units.values()).filter((u) => u.unitType === 'missile')
|
||
expect(currentMissiles.length).toBe(1)
|
||
expect(currentMissiles[0].id).toBe(firstMissileId)
|
||
})
|
||
|
||
it('does NOT deduplicate if server SkillCast arrives past 1000ms window (> 1000ms)', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'player', id: 1, classId: 1, name: 'Sorceress', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
world.selfId = 1
|
||
|
||
// Local player casts Fire Bolt at t = 1000ms
|
||
world.player.rightSkill = 36
|
||
world.noteOutgoingCommand({ type: 'CastRightSkillOnLocation', x: 5020, y: 5000 }, 1000)
|
||
|
||
expect(Array.from(world.units.values()).filter((u) => u.unitType === 'missile').length).toBe(1)
|
||
|
||
// Server echoes SkillCast at t = 2050ms (1050ms later, past 1000ms window)
|
||
world.apply(
|
||
{
|
||
type: 'SkillCast',
|
||
caster: { unitType: 'player', id: 1 },
|
||
skill: 36,
|
||
skillId: 36,
|
||
level: 1,
|
||
target: { x: 5030, y: 5000 },
|
||
},
|
||
2050
|
||
)
|
||
|
||
// Past window: treated as a fresh/server cast, spawns second missile
|
||
const currentMissiles = Array.from(world.units.values()).filter((u) => u.unitType === 'missile')
|
||
expect(currentMissiles.length).toBe(2)
|
||
})
|
||
|
||
it('does NOT deduplicate casts from remote players even if they cast the same skill at the same timestamp', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'player', id: 1, classId: 1, name: 'SelfSorc', x: 5000, y: 5000 },
|
||
isSelf: true,
|
||
})
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'player', id: 2, classId: 1, name: 'RemoteSorc', x: 5010, y: 5000 },
|
||
isSelf: false,
|
||
})
|
||
world.selfId = 1
|
||
|
||
// Self casts Fire Bolt at t = 1000ms
|
||
world.player.rightSkill = 36
|
||
world.noteOutgoingCommand({ type: 'CastRightSkillOnLocation', x: 5020, y: 5000 }, 1000)
|
||
|
||
expect(Array.from(world.units.values()).filter((u) => u.unitType === 'missile').length).toBe(1)
|
||
|
||
// Remote player (id 2) casts Fire Bolt at t = 1020ms
|
||
world.apply(
|
||
{
|
||
type: 'SkillCast',
|
||
caster: { unitType: 'player', id: 2 },
|
||
skill: 36,
|
||
skillId: 36,
|
||
level: 1,
|
||
target: { x: 5030, y: 5000 },
|
||
},
|
||
1020
|
||
)
|
||
|
||
// Remote player cast MUST spawn its own missile!
|
||
const missiles = Array.from(world.units.values()).filter((u) => u.unitType === 'missile')
|
||
expect(missiles.length).toBe(2)
|
||
})
|
||
})
|
||
|
||
describe('5. Unknown Missile Fail-Fast Verification (#574)', () => {
|
||
it('throws error when receiving invalid missileClassId in 0x73 CreateMissile instead of silent fallback', () => {
|
||
const world = new ClientWorld()
|
||
|
||
// Invalid classId 99999
|
||
expect(() => {
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
caster: { unitType: 'missile', id: 100 }, // unitType 'missile' -> 0x73 CreateMissile
|
||
skill: 99999,
|
||
skillId: 99999, // missileClassId
|
||
level: 1,
|
||
target: { x: 100, y: 100 },
|
||
})
|
||
}).toThrow(/Unknown missile classId 99999 in Missiles.txt/)
|
||
|
||
// Negative classId -1
|
||
expect(() => {
|
||
world.apply({
|
||
type: 'SkillCast',
|
||
caster: { unitType: 'missile', id: 101 },
|
||
skill: -1,
|
||
skillId: -1,
|
||
level: 1,
|
||
target: { x: 100, y: 100 },
|
||
})
|
||
}).toThrow(/Unknown missile classId -1 in Missiles.txt/)
|
||
})
|
||
})
|
||
|
||
describe('6. Damage Calculation 5-Band Progression & HitShift (#572)', () => {
|
||
it('calculates 5-band damage progression across boundary levels (1, 8, 9, 16, 17, 22, 23, 28, 29, 99)', () => {
|
||
// Fire Ball (Skill 47): emin=18, emax=37, elev1=14, elev2=22, elev3=30, elev4=38, elev5=46, hitshift=8
|
||
const skill = SKILLS_BY_ID[47]
|
||
expect(skill).toBeDefined()
|
||
const state = createInitialState('sor')
|
||
|
||
const levels = [1, 8, 9, 16, 17, 22, 23, 28, 29, 99]
|
||
|
||
for (const lvl of levels) {
|
||
const dmg = calculateDetailedDamage(state, skill, lvl)
|
||
expect(dmg.hasDamage).toBe(true)
|
||
expect(dmg.baseMin).toBeGreaterThan(0)
|
||
expect(dmg.baseMax).toBeGreaterThanOrEqual(dmg.baseMin)
|
||
expect(Number.isInteger(dmg.baseMin)).toBe(true)
|
||
expect(Number.isInteger(dmg.baseMax)).toBe(true)
|
||
|
||
// Verify independent 5-band calculation
|
||
const expectedRawMin = evaluateLevelBand(skill.eMin, skill.eMinLev, lvl)
|
||
const expectedRawMax = evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl)
|
||
const shiftMult = 2 ** ((skill.hitshift ?? 8) - 8)
|
||
expect(dmg.baseMin).toBe(Math.round(expectedRawMin * shiftMult))
|
||
expect(dmg.baseMax).toBe(Math.round(expectedRawMax * shiftMult))
|
||
}
|
||
|
||
// Verify level monotonicity
|
||
for (let lvl = 2; lvl <= 50; lvl++) {
|
||
const prev = calculateDetailedDamage(state, skill, lvl - 1)
|
||
const curr = calculateDetailedDamage(state, skill, lvl)
|
||
expect(curr.baseMin).toBeGreaterThanOrEqual(prev.baseMin)
|
||
expect(curr.baseMax).toBeGreaterThanOrEqual(prev.baseMax)
|
||
}
|
||
})
|
||
})
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// 2. TIMING & RATE LIMITING (#565, #566, #573, #575)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
describe('7. Tick Accumulator Cap & Sudden Frame Lag (#566, #575)', () => {
|
||
it('caps tick accumulator to 200ms in ClientWorld even under massive lag spikes (1000ms, 5000ms, 100000ms)', () => {
|
||
const world = new ClientWorld()
|
||
|
||
// Normal tick: 40ms
|
||
world.tick(40, 1000)
|
||
expect((world as any)._tickAccumulatorMs).toBeLessThanOrEqual(40)
|
||
|
||
// Massive lag spike: 1000ms
|
||
world.tick(1000, 2000)
|
||
expect((world as any)._tickAccumulatorMs).toBeLessThanOrEqual(200)
|
||
|
||
// Catastrophic lag spike: 100,000ms (e.g. laptop sleep / resumed)
|
||
world.tick(100000, 102000)
|
||
expect((world as any)._tickAccumulatorMs).toBeLessThanOrEqual(200)
|
||
})
|
||
|
||
it('limits simulation steps executed in a single frame to at most 5 steps under 1000ms lag', () => {
|
||
const world = new ClientWorld()
|
||
const stepSpy = vi.spyOn(world as any, 'stepDiscreteSimulationTick')
|
||
|
||
// Tick with 1000ms lag
|
||
world.tick(1000, 2000)
|
||
|
||
// 200ms / 40ms per step = exactly 5 simulation steps executed!
|
||
expect(stepSpy).toHaveBeenCalledTimes(5)
|
||
})
|
||
|
||
it('OnlineSession.tick clamps delta time to 200ms and preserves predictor/interpolator stability', () => {
|
||
const session = new OnlineSession()
|
||
const worldTickSpy = vi.spyOn(session.world, 'tick')
|
||
const predictorSpy = vi.spyOn(session.predictor, 'update')
|
||
const interpolatorSpy = vi.spyOn(session.interpolator, 'update')
|
||
|
||
// 2500ms lag spike
|
||
session.tick(2500, 3000)
|
||
|
||
expect(worldTickSpy).toHaveBeenCalledWith(200, 3000)
|
||
expect(predictorSpy).toHaveBeenCalledWith(200)
|
||
expect(interpolatorSpy).toHaveBeenCalledWith(200)
|
||
|
||
session.destroy()
|
||
})
|
||
|
||
it('survives sustained frame lag without memory leak or accumulator explosion', () => {
|
||
const session = new OnlineSession()
|
||
|
||
// 20 consecutive lag frames of 500ms
|
||
for (let f = 0; f < 20; f++) {
|
||
expect(() => {
|
||
session.tick(500, 1000 + f * 500)
|
||
}).not.toThrow()
|
||
}
|
||
|
||
// World accumulator remains healthy (<= 200)
|
||
expect((session.world as any)._tickAccumulatorMs).toBeLessThanOrEqual(200)
|
||
session.destroy()
|
||
})
|
||
})
|
||
|
||
describe('8. Non-Movement Command Bypass & Rate Limiting (#575)', () => {
|
||
it('100-packet rapid burst of non-movement commands (Interact, Skill, UI) has 0% drop rate', () => {
|
||
const session = new OnlineSession()
|
||
|
||
// Rapidly dispatch 100 interaction commands at the exact same millisecond
|
||
let interactSuccess = 0
|
||
for (let i = 0; i < 100; i++) {
|
||
const cmd: ClientCommand = {
|
||
type: 'Interact',
|
||
target: { unitType: 2, id: 100 + i },
|
||
}
|
||
if (session.dispatchCommand(cmd, 5000)) {
|
||
interactSuccess++
|
||
}
|
||
}
|
||
expect(interactSuccess).toBe(100)
|
||
|
||
// Rapidly dispatch 100 skill commands
|
||
let skillSuccess = 0
|
||
for (let i = 0; i < 100; i++) {
|
||
const cmd: ClientCommand = {
|
||
type: 'SkillAt',
|
||
hand: 'right',
|
||
at: { x: 5000 + i, y: 5000 + i },
|
||
shift: false,
|
||
}
|
||
if (session.dispatchCommand(cmd, 5000)) {
|
||
skillSuccess++
|
||
}
|
||
}
|
||
expect(skillSuccess).toBe(100)
|
||
|
||
// Rapidly dispatch 100 UI commands
|
||
let uiSuccess = 0
|
||
for (let i = 0; i < 100; i++) {
|
||
const cmd: ClientCommand = {
|
||
type: 'ItemEquip',
|
||
itemId: 1,
|
||
slot: 1,
|
||
}
|
||
if (session.dispatchCommand(cmd, 5000)) {
|
||
uiSuccess++
|
||
}
|
||
}
|
||
expect(uiSuccess).toBe(100)
|
||
|
||
session.destroy()
|
||
})
|
||
|
||
it('preserves rate limiting for movement commands (RunToLocation) to prevent network flooding', () => {
|
||
const session = new OnlineSession()
|
||
|
||
// Dispatch 10 rapid movement commands within 10ms
|
||
let allowedMoves = 0
|
||
for (let i = 0; i < 10; i++) {
|
||
const cmd: ClientCommand = {
|
||
type: 'RunToLocation',
|
||
x: 5000 + i * 2,
|
||
y: 5000,
|
||
}
|
||
if (session.dispatchCommand(cmd, 1000 + i)) {
|
||
allowedMoves++
|
||
}
|
||
}
|
||
|
||
// First move is allowed; rapid subsequent moves within 120ms are throttled
|
||
expect(allowedMoves).toBe(1)
|
||
|
||
// After 125ms (> 120ms moveMinIntervalMs), the next move is allowed
|
||
const nextMove = session.dispatchCommand(
|
||
{ type: 'RunToLocation', x: 5050, y: 5000 },
|
||
1125
|
||
)
|
||
expect(nextMove).toBe(true)
|
||
|
||
session.destroy()
|
||
})
|
||
|
||
it('correctly classifies all command types into movement vs non-movement categories', () => {
|
||
// Movement
|
||
expect(classifyClientCommand({ type: 'RunToLocation', x: 0, y: 0 })).toBe('movement')
|
||
expect(classifyClientCommand({ type: 'WalkToLocation', x: 0, y: 0 })).toBe('movement')
|
||
expect(classifyClientCommand({ type: 'MoveToUnit', run: true, target: { unitType: 'monster', id: 2 } })).toBe('movement')
|
||
|
||
// Interaction
|
||
expect(classifyClientCommand({ type: 'Interact', target: { unitType: 'monster', id: 2 } })).toBe('interaction')
|
||
expect(classifyClientCommand({ type: 'PickUpItem', itemId: 5 })).toBe('interaction')
|
||
expect(classifyClientCommand({ type: 'TakeWarp', warpId: 5 })).toBe('interaction')
|
||
|
||
// Skill
|
||
expect(classifyClientCommand({ type: 'SkillAt', hand: 'left', at: { x: 0, y: 0 }, shift: false })).toBe('skill')
|
||
expect(classifyClientCommand({ type: 'SkillOn', hand: 'right', target: { unitType: 'monster', id: 2 }, shift: false })).toBe('skill')
|
||
|
||
// Chat
|
||
expect(classifyClientCommand({ type: 'Chat', text: 'hello' } as any)).toBe('chat')
|
||
expect(classifyClientCommand({ type: 'OverheadChat', text: 'hello' } as any)).toBe('chat')
|
||
|
||
// UI / Inventory commands fall under 'interaction' category (non-movement)
|
||
expect(classifyClientCommand({ type: 'ItemEquip', itemId: 1, slot: 1 })).toBe('interaction')
|
||
expect(classifyClientCommand({ type: 'BeltSwap', cursorItemId: 1, beltItemId: 2 })).toBe('interaction')
|
||
})
|
||
})
|
||
})
|