diablo2-web/tests/challenger-batch1-stress.te...

709 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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')
})
})
})