482 lines
20 KiB
TypeScript
482 lines
20 KiB
TypeScript
/**
|
|
* Tier 5 — Adversarial Hardening & Challenger Regression Suite
|
|
* (`tests/e2e-module-split/tier5-adversarial-hardening.test.ts`).
|
|
*
|
|
* Covers white-box regression guards and stress probes from Iteration 1:
|
|
* - adv_BUG_02: zero `.test.ts`/`.spec.ts` files under `src/**` & `tests/frontend/flow-state.test.ts` relocation
|
|
* - adv_BUG_03: `src/netproto/transport/endpoint.ts` zero `new Function` dynamic imports & fail-fast `NetprotoError` when TCP opener is unregistered
|
|
* - adv_F1_01..06: `D2DataRegistry` & `getSharedDataRegistry` fail-fast & cache-poisoning guards
|
|
* - adv_F2_01..04: `validateUniversalDt1Libraries` & `validatePackManifest` mutation rejection
|
|
* - adv_F4_01..03: `runBakerCli` & `runBakerVerify` adversarial target/directory checks
|
|
* - adv_server_determinism & hardening: 200-step twin `ServerWorld` & `GameEngine` fuzzing, `NaN`/`Infinity` movement sanitization, `[0, 0xffff]` coordinate clamping, and `computeLockstepHash` `Map` monster sensitivity
|
|
* - adv_online_session: `OnlineSession` + `LocalLoopbackAdapter` + `ClientWorld` entity lifecycle, post-`leaveGame()` command guard, and 20x rapid reconnect cycles
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { runBakerCli, runBakerVerify } from '../../src/baker/cli.ts'
|
|
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
|
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
|
import {
|
|
D2DataRegistry,
|
|
getSharedDataRegistry,
|
|
getSharedMountedArchives,
|
|
resetSharedDataRegistryCache,
|
|
} from '../../src/common/data/data-registry.ts'
|
|
import {
|
|
UNIVERSAL_DT1_PATHS,
|
|
validatePackManifest,
|
|
validateUniversalDt1Libraries,
|
|
} from '../../src/common/pack-contract/index.ts'
|
|
import {
|
|
NetprotoError,
|
|
createTcpResolver,
|
|
registerDefaultTcpOpener,
|
|
type ClientCommand,
|
|
type ServerEvent,
|
|
} from '../../src/netproto/index.ts'
|
|
import { LocalLoopbackAdapter } from '../../src/server/adapter/LocalLoopbackAdapter.ts'
|
|
import { GameEngine, type GameEngineOptions } from '../../src/server/engine/engine.ts'
|
|
import { computeLockstepHash, computeStateHash } from '../../src/server/engine/state-hash.ts'
|
|
import { ServerWorld } from '../../src/server/world/server-world.ts'
|
|
import {
|
|
ROOT_DIR,
|
|
collectTsFiles,
|
|
existsInRepo,
|
|
readJsonFile,
|
|
readRepoFile,
|
|
stripComments,
|
|
} from './helpers.ts'
|
|
|
|
describe('Tier 5 — Adversarial Hardening & Challenger Regression Suite', () => {
|
|
describe('1. White-Box Boundary & Architecture Regression Guards (adv_BUG_02..03)', () => {
|
|
|
|
it('adv_BUG_02: zero .test.ts or .spec.ts files exist under src/** and tests/frontend/flow-state.test.ts is present', () => {
|
|
const srcTestFiles = collectTsFiles('src').filter(
|
|
(f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts') || f.includes('__tests__'),
|
|
)
|
|
expect(srcTestFiles).toEqual([])
|
|
expect(existsInRepo('src/client/frontend/__tests__/flow-state.test.ts')).toBe(false)
|
|
expect(existsInRepo('tests/frontend/flow-state.test.ts')).toBe(true)
|
|
|
|
for (const cfgName of [
|
|
'tsconfig.common.json',
|
|
'tsconfig.netproto.json',
|
|
'tsconfig.server.json',
|
|
'tsconfig.client.json',
|
|
'tsconfig.baker.json',
|
|
]) {
|
|
const cfg = readJsonFile(cfgName)
|
|
const ex = (cfg.exclude as string[]) ?? []
|
|
expect(ex).not.toContain('**/*.test.ts')
|
|
expect(ex).not.toContain('src/netproto/transport/node-tcp-stream.ts')
|
|
}
|
|
})
|
|
|
|
it('adv_BUG_03: src/netproto/transport/endpoint.ts has zero new Function/node-tcp-stream references and throws NetprotoError when unregistered', async () => {
|
|
const code = stripComments(readRepoFile('src/netproto/transport/endpoint.ts'))
|
|
expect(code).not.toContain('new Function')
|
|
expect(code).not.toContain('node-tcp-stream')
|
|
|
|
registerDefaultTcpOpener(null)
|
|
const resolver = createTcpResolver()
|
|
let caught: unknown = null
|
|
try {
|
|
await resolver.open('bnet', '127.0.0.1', 6112)
|
|
} catch (err) {
|
|
caught = err
|
|
}
|
|
expect(caught).toBeInstanceOf(NetprotoError)
|
|
const netErr = caught as NetprotoError
|
|
expect(netErr.code).toBe('ERR_TRANSPORT')
|
|
expect(netErr.channel).toBe('bnet')
|
|
expect(netErr.message).toMatch(/No TCP opener registered/i)
|
|
})
|
|
})
|
|
|
|
describe('2. D2DataRegistry & ArchiveSource Adversarial Stress (adv_F1_01..06)', () => {
|
|
it('adv_F1_01..03: getSharedDataRegistry and getSharedMountedArchives reject missing, null, and primitive inputs fail-fast', () => {
|
|
resetSharedDataRegistryCache()
|
|
expect(() => getSharedDataRegistry()).toThrow(/ArchiveSource/i)
|
|
expect(() => getSharedMountedArchives()).toThrow(/ArchiveSource/i)
|
|
|
|
for (const bad of [null, 0, 42, '', 'd2data.mpq', false, true]) {
|
|
expect(() => getSharedDataRegistry(bad as never)).toThrow(/ArchiveSource/i)
|
|
expect(() => getSharedMountedArchives(bad as never)).toThrow(/ArchiveSource/i)
|
|
}
|
|
})
|
|
|
|
it('adv_F1_04..06: D2DataRegistry rejects empty/null-returning/corrupt ArchiveSources without poisoning shared cache', async () => {
|
|
resetSharedDataRegistryCache()
|
|
expect(() => new D2DataRegistry(null as never)).toThrow(/ArchiveSource/i)
|
|
expect(() => D2DataRegistry.fromArchiveSourceSync({} as never)).toThrow(
|
|
/synchronous reading/i,
|
|
)
|
|
await expect(D2DataRegistry.fromMountedArchives(null as never)).rejects.toThrow(
|
|
/ArchiveSource/i,
|
|
)
|
|
|
|
const nullReturningSource = {
|
|
readBytes: () => null,
|
|
readText: () => null,
|
|
}
|
|
expect(() => getSharedDataRegistry(nullReturningSource)).toThrow(/synchronous reading/i)
|
|
|
|
const corruptAnimSource = {
|
|
readBytes(p: string): Uint8Array | null {
|
|
if (p.toLowerCase().endsWith('animdata.d2')) {
|
|
return new Uint8Array([1, 2, 3])
|
|
}
|
|
return new Uint8Array(0)
|
|
},
|
|
readText(): string | null {
|
|
return 'Id\tskill\n0\tAttack\n'
|
|
},
|
|
}
|
|
expect(() => getSharedDataRegistry(corruptAnimSource)).toThrow()
|
|
// Shared cache must remain unpoisoned after failed initialization
|
|
expect(() => getSharedDataRegistry()).toThrow(/ArchiveSource/i)
|
|
})
|
|
})
|
|
|
|
describe('3. Pack Contract & Baker CLI Adversarial Stress (adv_F2_01..04, adv_F4_01..03)', () => {
|
|
const validBlank = {
|
|
path: UNIVERSAL_DT1_PATHS[0],
|
|
tiles: [{ type: 0, style: 0, sequence: 0 }],
|
|
}
|
|
const validInvisWal = {
|
|
path: UNIVERSAL_DT1_PATHS[1],
|
|
tiles: [{ type: 0, style: 49, sequence: 7 }],
|
|
}
|
|
const validWarp = {
|
|
path: UNIVERSAL_DT1_PATHS[2],
|
|
tiles: [{ type: 0, style: 0, sequence: 0 }],
|
|
}
|
|
|
|
it('adv_F2_01..03: validateUniversalDt1Libraries rejects missing universal DT1s and style/sequence/orientation mutations', () => {
|
|
expect(() => validateUniversalDt1Libraries([])).toThrow(/Blank\.dt1/i)
|
|
expect(() => validateUniversalDt1Libraries(null as never)).toThrow(/Blank\.dt1/i)
|
|
expect(() => validateUniversalDt1Libraries([validInvisWal, validWarp])).toThrow(/Blank\.dt1/i)
|
|
expect(() => validateUniversalDt1Libraries([validBlank, validWarp])).toThrow(/InvisWal\.dt1/i)
|
|
expect(() =>
|
|
validateUniversalDt1Libraries([
|
|
validBlank,
|
|
{ path: UNIVERSAL_DT1_PATHS[1], tiles: [{ type: 0, style: 48, sequence: 7 }] },
|
|
validWarp,
|
|
]),
|
|
).toThrow(/style=49, sequence=7/i)
|
|
expect(() =>
|
|
validateUniversalDt1Libraries([
|
|
validBlank,
|
|
{ path: UNIVERSAL_DT1_PATHS[1], tiles: [{ type: 0, style: 49, sequence: 6 }] },
|
|
validWarp,
|
|
]),
|
|
).toThrow(/style=49, sequence=7/i)
|
|
expect(() =>
|
|
validateUniversalDt1Libraries([
|
|
validBlank,
|
|
{ path: UNIVERSAL_DT1_PATHS[1], tiles: [{ type: 1, style: 49, sequence: 7 }] },
|
|
validWarp,
|
|
]),
|
|
).toThrow(/style=49, sequence=7/i)
|
|
expect(() => validateUniversalDt1Libraries([validBlank, validInvisWal])).toThrow(/Warp\.dt1/i)
|
|
expect(() =>
|
|
validateUniversalDt1Libraries([
|
|
validBlank,
|
|
validInvisWal,
|
|
{ path: UNIVERSAL_DT1_PATHS[2], tiles: [] },
|
|
]),
|
|
).toThrow(/Warp\.dt1 has 0 decoded tiles/i)
|
|
})
|
|
|
|
it('adv_F2_04: validatePackManifest rejects null, missingFiles, missingTiles, invisWalFloorVerified=false, and incomplete libraries', () => {
|
|
expect(() => validatePackManifest(null as never)).toThrow(/Invalid pack manifest/i)
|
|
expect(() => validatePackManifest({ missingFiles: 1, missingTiles: 0 })).toThrow(
|
|
/missingFiles=1/i,
|
|
)
|
|
expect(() =>
|
|
validatePackManifest({ missingFiles: ['Act1\\Town\\foo.dt1'], missingTiles: 0 }),
|
|
).toThrow(/missingFiles=1/i)
|
|
expect(() => validatePackManifest({ missingFiles: 0, missingTiles: 2 })).toThrow(
|
|
/missingTiles=2/i,
|
|
)
|
|
expect(() =>
|
|
validatePackManifest({
|
|
missingFiles: 0,
|
|
missingTiles: 0,
|
|
invisWalFloorVerified: false,
|
|
}),
|
|
).toThrow(/invisWalFloorVerified is false/i)
|
|
expect(() =>
|
|
validatePackManifest({
|
|
missingFiles: 0,
|
|
missingTiles: 0,
|
|
invisWalFloorVerified: true,
|
|
libraries: { onlyBlank: validBlank },
|
|
}),
|
|
).toThrow(/InvisWal\.dt1/i)
|
|
})
|
|
|
|
it('adv_F4_01..03: runBakerCli handles help/invalid targets and runBakerVerify validates 136 levels and fails fast on missing catalog', async () => {
|
|
await expect(runBakerCli(['--help'])).resolves.toBeUndefined()
|
|
await expect(runBakerCli(['-h'])).resolves.toBeUndefined()
|
|
await expect(runBakerCli([])).resolves.toBeUndefined()
|
|
await expect(runBakerCli(['invalid-target-xyz'])).rejects.toThrow(
|
|
/Unknown bake target "invalid-target-xyz"/i,
|
|
)
|
|
|
|
const summary = await runBakerVerify(ROOT_DIR)
|
|
expect(summary.tileManifestVerified).toBe(true)
|
|
expect(summary.levelsVerified).toBe(136)
|
|
expect(summary.dropParityVerified).toBe(true)
|
|
|
|
await expect(runBakerVerify('/tmp/nonexistent-root-dir-12345')).rejects.toThrow(
|
|
/Tile catalog index\.json not found/i,
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('4. ServerWorld & GameEngine Hardening & 200-Step Twin Determinism', () => {
|
|
const SEED = 0x12345678
|
|
|
|
function makeEngineOpts(): GameEngineOptions {
|
|
return {
|
|
spawn: { x: 200, y: 200 },
|
|
stats: [
|
|
{
|
|
id: 'fallen1',
|
|
name: 'Fallen',
|
|
hp: 30,
|
|
damage: 4,
|
|
cooldownTicks: 25,
|
|
reach: 40,
|
|
aggroRadius: 220,
|
|
speed: 90,
|
|
xp: 15,
|
|
},
|
|
],
|
|
xpTable: [0, 0, 500, 1500, 3750],
|
|
skills: [
|
|
{
|
|
id: '0',
|
|
name: 'Attack',
|
|
manaCost: 0,
|
|
cooldownTicks: 5,
|
|
range: 48,
|
|
projectile: false,
|
|
speed: 0,
|
|
baseMinDamage: 10,
|
|
baseMaxDamage: 10,
|
|
damagePerLevel: 0,
|
|
radius: 48,
|
|
},
|
|
{
|
|
id: '36',
|
|
name: 'Fire Bolt',
|
|
manaCost: 2,
|
|
cooldownTicks: 8,
|
|
range: 300,
|
|
projectile: true,
|
|
speed: 200,
|
|
baseMinDamage: 12,
|
|
baseMaxDamage: 12,
|
|
damagePerLevel: 0,
|
|
radius: 20,
|
|
},
|
|
],
|
|
npcDefs: [],
|
|
questDefs: [],
|
|
combatOptions: {
|
|
playerSpeed: 170,
|
|
playerReach: 48,
|
|
playerCooldownTicks: 5,
|
|
playerDamage: 10,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 50,
|
|
},
|
|
lootSeed: SEED,
|
|
talkRadius: 64,
|
|
pickupRadius: 64,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
monsterCount: 6,
|
|
}
|
|
}
|
|
|
|
it('adv_server_hardening: clamps ServerWorld target coordinates to [0, 0xffff], sanitizes NaN/Infinity in GameEngine.tick, and hashes Map monsters in computeLockstepHash', () => {
|
|
// 1. ServerWorld [0, 0xffff] coordinate clamping
|
|
const coordWorld = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
|
|
coordWorld.handleCommand({ type: 'WalkToLocation', x: -500, y: -500 })
|
|
expect(coordWorld.player.targetX).toBe(0)
|
|
expect(coordWorld.player.targetY).toBe(0)
|
|
coordWorld.handleCommand({ type: 'WalkToLocation', x: 1e9, y: 1e9 })
|
|
expect(coordWorld.player.targetX).toBe(0xffff)
|
|
expect(coordWorld.player.targetY).toBe(0xffff)
|
|
|
|
// 2. computeLockstepHash sensitivity to ServerWorld.monsters (Map)
|
|
const probeWorld1 = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
|
|
const probeWorld2 = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
|
|
probeWorld1.monsters.set(100, {
|
|
unitId: 100,
|
|
classId: 0,
|
|
code: 'fallen1',
|
|
name: 'Fallen',
|
|
x: 110,
|
|
y: 110,
|
|
hp: 50,
|
|
maxHp: 50,
|
|
})
|
|
probeWorld2.monsters.set(100, {
|
|
unitId: 100,
|
|
classId: 0,
|
|
code: 'fallen1',
|
|
name: 'Fallen',
|
|
x: 110,
|
|
y: 110,
|
|
hp: 50,
|
|
maxHp: 50,
|
|
})
|
|
probeWorld2.handleCommand({ type: 'PlayerAttack', targetId: 100 })
|
|
expect(probeWorld2.monsters.get(100)?.hp).toBeLessThan(50)
|
|
const lockstep1 = computeLockstepHash(probeWorld1 as unknown as Parameters<typeof computeLockstepHash>[0])
|
|
const lockstep2 = computeLockstepHash(probeWorld2 as unknown as Parameters<typeof computeLockstepHash>[0])
|
|
expect(lockstep1).not.toBe(lockstep2)
|
|
|
|
// 3. GameEngine.tick NaN/Infinity movement sanitization
|
|
const nanEngine = new GameEngine('sor', makeEngineOpts())
|
|
nanEngine.tick({
|
|
movement: { x: Number.NaN, y: Number.POSITIVE_INFINITY },
|
|
attacking: false,
|
|
pickingUp: false,
|
|
talking: false,
|
|
digits: [],
|
|
saving: false,
|
|
loading: false,
|
|
})
|
|
expect(Number.isFinite(nanEngine.world.player.x)).toBe(true)
|
|
expect(Number.isFinite(nanEngine.world.player.y)).toBe(true)
|
|
expect(Number.isFinite(nanEngine.world.player.facing)).toBe(true)
|
|
expect(nanEngine.world.player.x).toBe(200)
|
|
expect(nanEngine.world.player.y).toBe(200)
|
|
})
|
|
|
|
it('adv_server_world_200_step_determinism: twin ServerWorld + LocalLoopbackAdapter instances produce identical hashes across 200 valid + adversarial steps', async () => {
|
|
const worldA = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
|
|
const worldB = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
|
|
for (const w of [worldA, worldB]) {
|
|
w.monsters.set(100, { unitId: 100, classId: 0, code: 'fallen1', name: 'Fallen', x: 115, y: 115, hp: 50, maxHp: 50 })
|
|
w.monsters.set(101, { unitId: 101, classId: 1, code: 'zombie1', name: 'Zombie', x: 130, y: 125, hp: 80, maxHp: 80 })
|
|
}
|
|
const adapterA = new LocalLoopbackAdapter(worldA)
|
|
const adapterB = new LocalLoopbackAdapter(worldB)
|
|
await adapterA.connect()
|
|
await adapterB.connect()
|
|
|
|
for (let i = 0; i < 200; i += 1) {
|
|
const mode = i % 10
|
|
let cmd: Record<string, unknown> = { type: 'WalkToLocation', x: 100 + (i % 30), y: 100 + ((i * 3) % 30) }
|
|
if (mode === 1) cmd = { type: 'WalkToLocation', x: -500 - i, y: -999999 }
|
|
else if (mode === 2) cmd = { type: 'RunToLocation', x: Number.NaN, y: Number.POSITIVE_INFINITY }
|
|
else if (mode === 3) cmd = { type: 'SelectSkill', skillId: -1, hand: 'right' }
|
|
else if (mode === 4) cmd = { type: 'CastSkill', skillId: 99999, x: 105, y: 105 }
|
|
else if (mode === 5) cmd = { type: 'WalkToUnit', unitType: 1, unitId: 999999 }
|
|
else if (mode === 6) cmd = { type: 'PlayerAttack', targetId: i < 100 ? 100 : 101, skillId: 0, x: 115, y: 115 }
|
|
else if (mode === 7) cmd = { type: 'PlayerAttack', targetId: 555555, skillId: -1, x: -50, y: Number.NaN }
|
|
else if (mode === 8) {
|
|
adapterA.disconnect()
|
|
adapterB.disconnect()
|
|
await adapterA.connect()
|
|
await adapterB.connect()
|
|
}
|
|
|
|
adapterA.send(cmd as ClientCommand)
|
|
adapterB.send(cmd as ClientCommand)
|
|
adapterA.stepTick(40)
|
|
adapterB.stepTick(40)
|
|
|
|
const hashA = computeStateHash(worldA as unknown as Parameters<typeof computeStateHash>[0])
|
|
const hashB = computeStateHash(worldB as unknown as Parameters<typeof computeStateHash>[0])
|
|
expect(hashA).toBe(hashB)
|
|
}
|
|
})
|
|
|
|
it('adv_game_engine_200_step_determinism: twin GameEngine instances produce identical state hashes across 200 valid + adversarial steps', () => {
|
|
const engineA = new GameEngine('sor', makeEngineOpts())
|
|
const engineB = new GameEngine('sor', makeEngineOpts())
|
|
|
|
for (let i = 0; i < 200; i += 1) {
|
|
const mode = i % 6
|
|
let mx = mode === 0 ? (i % 3) - 1 : mode === 1 ? Number.NaN : 1
|
|
let my = mode === 0 ? ((i * 2) % 3) - 1 : mode === 1 ? Number.NEGATIVE_INFINITY : 0
|
|
const digits = mode === 2 ? [2] : mode === 3 ? [-1, 99999] : []
|
|
|
|
if (mode === 4) {
|
|
engineA.enqueueCommand({ type: 'allocateStat', stat: 'nonexistent_stat' })
|
|
engineB.enqueueCommand({ type: 'allocateStat', stat: 'nonexistent_stat' })
|
|
engineA.enqueueCommand({ type: 'useBeltSlot', col: -5 })
|
|
engineB.enqueueCommand({ type: 'useBeltSlot', col: -5 })
|
|
} else if (mode === 5) {
|
|
engineA.updateActiveAura(-1, 3)
|
|
engineB.updateActiveAura(-1, 3)
|
|
}
|
|
|
|
const input = {
|
|
movement: { x: mx, y: my },
|
|
attacking: mode === 2 || mode === 3,
|
|
pickingUp: i % 5 === 0,
|
|
talking: i % 15 === 0,
|
|
digits,
|
|
saving: false,
|
|
loading: false,
|
|
}
|
|
engineA.tick(input)
|
|
engineB.tick(input)
|
|
|
|
expect(Number.isFinite(engineA.world.player.x)).toBe(true)
|
|
expect(Number.isFinite(engineA.world.player.y)).toBe(true)
|
|
expect(engineA.computeStateHash()).toBe(engineB.computeStateHash())
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('5. OnlineSession + LocalLoopbackAdapter + ClientWorld Adversarial Lifecycle', () => {
|
|
it('adv_online_session_lifecycle: handles adversarial ClientWorld events, post-leaveGame() command guard, and 20x rapid reconnect cycles', async () => {
|
|
const serverWorld = new ServerWorld({ act: 1, levelId: 1, seed: 0x12345678, spawnX: 100, spawnY: 100 })
|
|
const adapter = new LocalLoopbackAdapter(serverWorld)
|
|
const clientWorld = new ClientWorld()
|
|
const session = new OnlineSession({ world: clientWorld })
|
|
|
|
session.attachGameServer(adapter)
|
|
expect(session.phase).toBe('ingame')
|
|
expect(clientWorld.selfId).toBe(1)
|
|
|
|
// Adversarial events for nonexistent units must not throw
|
|
expect(() => {
|
|
session.handleServerEvent({ type: 'EntityRemove', unitType: 1, unitId: 999999 } as unknown as ServerEvent)
|
|
session.handleServerEvent({ type: 'UnitMove', unitType: 1, unitId: 999999, x: 50, y: 60 } as unknown as ServerEvent)
|
|
}).not.toThrow()
|
|
|
|
// Post-leaveGame() command guard
|
|
const packetsOutBeforeLeave = adapter.stats.packetsOut
|
|
await session.leaveGame()
|
|
expect(session.phase).toBe('idle')
|
|
expect(adapter.isConnected).toBe(false)
|
|
|
|
let clockMs = 2000
|
|
session.dispatchCommand({ type: 'PlayerMove', targetX: 200, targetY: 200 }, (clockMs += 200))
|
|
session.dispatchCommand({ type: 'RunToLocation', x: 210, y: 210 }, (clockMs += 200))
|
|
expect(adapter.stats.packetsOut).toBe(packetsOutBeforeLeave)
|
|
|
|
// 20x rapid reconnect cycles
|
|
for (let c = 0; c < 20; c += 1) {
|
|
await adapter.connect()
|
|
session.attachGameServer(adapter)
|
|
expect(session.phase).toBe('ingame')
|
|
session.dispatchCommand({ type: 'RunToLocation', x: 100 + c, y: 100 + c }, (clockMs += 200))
|
|
adapter.stepTick(40)
|
|
await session.leaveGame()
|
|
expect(session.phase).toBe('idle')
|
|
}
|
|
})
|
|
})
|
|
})
|