diablo2-web/tests/e2e-netproto/tier1-feature-coverage.test.ts

1866 lines
71 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.

/**
* Tier 1 — Positive Feature Coverage E2E Tests (F1–F20, 5 tests per feature = 100 tests).
*
* Every test is deterministic, hermetic, and validates primary happy-path behavior
* against 1.13c ground truth specifications (`D2Client.dll`, `D2Common.dll`, `D2Net.dll`,
* `Fog.dll`, `PROJECT.md`).
*/
import path from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import {
asSubTile,
asUnitId,
bytesToHex,
CHAR_CLASS_NAMES,
createBrowserTextCodec,
createD2OnlineFlow,
createMemoryStreamPair,
createMixedResolver,
createTcpResolver,
createWsBridgeResolver,
defaultTextCodec,
formatD2CapEntry,
formatD2CapHeader,
hexToBytes,
InMemoryPacketTap,
parseD2CapJsonl,
unitTypeFromId,
unitTypeToId,
type ClientCommand,
type CloseReason,
} from '../../src/netproto/index.ts'
import { resetActiveWssBnetConnectionsForTesting } from '../../src/netproto/transport/endpoint.ts'
import { BitReader } from '../../src/netproto/core/bit-reader.ts'
import { BitWriter } from '../../src/netproto/core/bit-writer.ts'
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
import { FakeClock } from '../../src/netproto/core/clock.ts'
import { doubleHash, hashBnetPassword, hashRealmPassword } from '../../src/netproto/crypto/bnet-hash.ts'
import { createSyntheticCdKeyPair, decodeD2CdKey, hashD2CdKey } from '../../src/netproto/crypto/cdkey.ts'
import { D2XP_113C_VERSION_CHECK, getCheckRevision113c } from '../../src/netproto/crypto/check-revision.ts'
import { xsha1, xsha1Ascii } from '../../src/netproto/crypto/xsha1.ts'
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
import {
BncsOpcode,
decodeBncsS2cPacket,
encodeBncsAuthCheck,
encodeBncsAuthInfo,
encodeBncsPing,
} from '../../src/netproto/bncs/packets.ts'
import { BncsSession } from '../../src/netproto/bncs/session.ts'
import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts'
import {
encodeMcpCharCreate,
encodeMcpCharLogon,
encodeMcpCreateGame,
encodeMcpJoinGame,
encodeMcpStartup,
McpOpcode,
parseMcpCharStatString,
} from '../../src/netproto/mcp/packets.ts'
import { McpSession } from '../../src/netproto/mcp/session.ts'
import {
defaultD2gsHuffmanCodec,
parseD2gsBlockHeader,
wrapD2gsCompressedBlock,
} from '../../src/netproto/d2gs/compression.ts'
import { validateC2sPacket } from '../../src/netproto/d2gs/framing-c2s.ts'
import { D2gsS2cFramer } from '../../src/netproto/d2gs/framing-s2c.ts'
import {
D2GS_S2C_REGISTRY,
decodeD2gsS2cPacket,
encodeD2gsClientCommand,
} from '../../src/netproto/d2gs/registry.ts'
import {
HUFFMAN_BIT_LENGTHS,
HUFFMAN_BIT_MASKS,
HUFFMAN_CODES,
} from '../../src/netproto/d2gs/tables/huffman.ts'
import {
C2S_PACKET_SIZES,
D2CLIENT_S2C_HANDLERS,
S2C_PACKET_SIZES,
} from '../../src/netproto/d2gs/tables/s2c-sizes.ts'
import {
decode0x0DPlayerStop,
decode0x15ReassignPlayer,
} from '../../src/netproto/d2gs/s2c/movement.ts'
import { decode0x8FPong } from '../../src/netproto/d2gs/s2c/misc.ts'
import { decodeD2gsItemPacket } from '../../src/netproto/d2gs/items/item-bitstream.ts'
import { ITEM_FLAG_COMPACT } from '../../src/netproto/d2gs/items/item-types.ts'
import { encodeC2sGameLogon } from '../../src/netproto/d2gs/c2s/session.ts'
import { D2gsSession } from '../../src/netproto/d2gs/session.ts'
import { D2gsAdapter } from '../../src/netproto/d2gs/adapter.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { getCanonicalItemDataTables } from '../../src/client/world/item-tables-provider.ts'
import {
MapService,
localSubTileToOrtho,
localToWorldSubTile,
orthoToLocalSubTile,
worldToLocalSubTile,
} from '../../src/client/map/map-service.ts'
import type { TileAtlas } from '../../src/client/map/tile-atlas.ts'
import { findPathLocal } from '../../src/client/motion/pathfind.ts'
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
import {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_SERVER_HOST,
deriveRealmWsEndpoints,
validateServerHost,
} from '../../src/client/settings/client-settings.ts'
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
import {
applyViewportProfileToDom,
computeViewportAnchors,
getViewportProfile,
screenToSubTile,
screenToWorld,
subTileToScreen,
VIEWPORT_1024x768,
VIEWPORT_800x600,
worldToScreen,
} from '../../src/client/view/viewport-profile.ts'
import {
ClientWorldSceneSource,
type ClientSceneWorldAdapter,
} from '../../src/client/view/scene-source.ts'
import {
buildSceneDrawables,
type RenderableUnitInput,
} from '../../src/client/view/unit-drawables.ts'
import { AutomapView } from '../../src/client/automap/automap-view.ts'
import {
computeSpatialAudioMetrics,
resolveSoundEnvironmentForLevel,
SoundService,
type AudioBackendLike,
} from '../../src/client/audio/sound-service.ts'
import {
HttpRangeMpqSource,
InMemoryChunkCache,
OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts'
import {
classifyClientCommand,
OutboundRateLimiter,
} from '../../src/client/session/rate-limiter.ts'
import {
formatHexDump,
PacketInspector,
} from '../../src/client/inspector/packet-inspector.ts'
import {
D2S_MAGIC,
D2S_QUEST_OFFSET,
D2S_VERSION_113C,
D2S_WAYPOINT_OFFSET,
unlockD2sBuffer,
validateBotCharacterName,
verifyD2sUnlocked,
writeD2sChecksum,
} from '../../tools/d2s-unlock.ts'
import { d2sChecksum } from '../../src/server/save/save.ts'
import { replayD2CapFile } from '../../tools/netproto-replay.ts'
import { getLang, setLang, tToolbar } from '../../src/client/i18n/lang.ts'
import { LightGrid } from '../../src/common/drlg/light-grid.ts'
import {
applyLightingInputs,
LIGHTING_PRESETS,
} from '../../src/client/scene/lighting-presets.ts'
import { translateScreenClickToClientCommand } from '../../src/client/scene/mouse-controller.ts'
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import { computeDockingLayout, computeHudLayout } from '../../src/client/ui/hud-manager.ts'
class MemoryStorage implements StorageLike {
readonly data = new Map<string, string>()
getItem(key: string): string | null {
return this.data.get(key) ?? null
}
setItem(key: string, value: string): void {
this.data.set(key, value)
}
removeItem(key: string): void {
this.data.delete(key)
}
}
const itemTables = getCanonicalItemDataTables()
let sharedMapService: MapService
let sharedTileAtlas: TileAtlas
beforeAll(async () => {
sharedMapService = await MapService.fromPackedDir(path.resolve('public/act-packs/tiles'))
sharedTileAtlas = sharedMapService.getTileAtlas()!
}, 60_000)
function buildSynthetic0x9CWorldItemPacket(): Uint8Array {
const bw = new BitWriter()
bw.writeBits(ITEM_FLAG_COMPACT, 32)
bw.writeBits(101, 10) // version
bw.writeBits(3, 3) // location = 3 (ground)
bw.writeBits(100, 16) // worldX = 100
bw.writeBits(200, 16) // worldY = 200
bw.writeAscii('hp1 ')
const bitData = bw.toUint8Array()
const w = new ByteWriter(8 + bitData.length)
w.u8(0x9c)
w.u8(0x00)
w.u8(8 + bitData.length)
w.u8(0x00)
w.u32LE(42)
w.bytes(bitData)
return w.toUint8Array()
}
function createSyntheticD2sBuffer(charName: string): Uint8Array {
const buf = new Uint8Array(765)
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
view.setUint32(0x00, D2S_MAGIC, true)
view.setUint32(0x04, D2S_VERSION_113C, true)
view.setUint32(0x08, buf.byteLength, true)
for (let i = 0; i < Math.min(15, charName.length); i++) {
buf[0x14 + i] = charName.charCodeAt(i) & 0x7f
}
buf[0x24] = 0x20 // Expansion
buf[0x25] = 0x00 // Progression 0
buf[0x2b] = 1 // Level 1
buf[D2S_QUEST_OFFSET] = 0x57 // 'W'
buf[D2S_QUEST_OFFSET + 1] = 0x6f // 'o'
buf[D2S_QUEST_OFFSET + 2] = 0x6f // 'o'
buf[D2S_QUEST_OFFSET + 3] = 0x21 // '!'
view.setUint32(D2S_QUEST_OFFSET + 4, 6, true)
view.setUint16(D2S_QUEST_OFFSET + 8, 298, true)
buf[D2S_WAYPOINT_OFFSET] = 0x57 // 'W'
buf[D2S_WAYPOINT_OFFSET + 1] = 0x53 // 'S'
view.setUint32(D2S_WAYPOINT_OFFSET + 2, 1, true)
view.setUint16(D2S_WAYPOINT_OFFSET + 6, 80, true)
for (let diff = 0; diff < 3; diff++) {
const wpBase = 0x281 + diff * 24
buf[wpBase] = 0x02
buf[wpBase + 1] = 0x01
buf[wpBase + 2] = 0x01
}
writeD2sChecksum(buf)
return buf
}
// ============================================================================
// F1: Core Binary Primitives, Text Codec & Packet Tap (5 tests)
// ============================================================================
describe('Tier 1 — F1: Core Binary Primitives, Text Codec & Packet Tap', () => {
it('F1.1: ByteReader and ByteWriter round-trip u8, u16LE, u32LE, i32LE, cstring, and fixedCString', () => {
const w = new ByteWriter()
w.u8(0xfe)
w.u16LE(0x1234)
w.u32LE(0xdeadbeef)
w.i32LE(-42)
w.cstring('Sanctuary')
w.fixedCString('D2XP', 5)
const r = new ByteReader(w.toUint8Array())
expect(r.u8()).toBe(0xfe)
expect(r.u16LE()).toBe(0x1234)
expect(r.u32LE()).toBe(0xdeadbeef)
expect(r.i32LE()).toBe(-42)
expect(r.cstring()).toBe('Sanctuary')
expect(r.fixedCString(5)).toBe('D2XP')
expect(r.remaining).toBe(0)
})
it('F1.2: BitReader and BitWriter round-trip non-byte-aligned LSB-first fields and signed bits', () => {
const bw = new BitWriter()
bw.writeBits(0b101, 3)
bw.writeBits(0x1ff, 9)
bw.writeSignedBits(-42, 8)
bw.writeBit(1)
bw.alignToByte()
bw.writeBits(0xab, 8)
const br = new BitReader(bw.toUint8Array())
expect(br.readBits(3)).toBe(0b101)
expect(br.readBits(9)).toBe(0x1ff)
expect(br.readSignedBits(8)).toBe(-42)
expect(br.readBit()).toBe(1)
br.alignToByte()
expect(br.readBits(8)).toBe(0xab)
expect(br.bitsRemaining).toBe(0)
})
it('F1.3: createBrowserTextCodec and defaultTextCodec encode and decode UTF-8 and GBK strings', () => {
const utf8 = createBrowserTextCodec('utf-8')
const encodedUtf8 = utf8.encode('暗黑破坏神II')
expect(utf8.decode(encodedUtf8)).toBe('暗黑破坏神II')
const gbkBytes = new Uint8Array([0xb0, 0xb5, 0xba, 0xda]) // "暗黑" in GBK
expect(defaultTextCodec.decode(gbkBytes, 'gbk')).toBe('暗黑')
})
it('F1.4: InMemoryPacketTap records packets and formats/parses .d2cap JSONL round-trip', () => {
const tap = new InMemoryPacketTap()
tap.record({
t: 12.5,
conn: 'bnet',
dir: 'c2s',
raw: new Uint8Array([0xff, 0x50, 0x04, 0x00]),
packetId: 0x50,
packetName: 'SID_AUTH_INFO',
})
expect(tap.entries.length).toBe(1)
const header = formatD2CapHeader({
format: 'd2cap-v1',
createdAt: '2026-09-29T00:00:00Z',
server: '101.37.117.183',
scenario: 'unit-test',
})
const line = formatD2CapEntry(tap.entries[0]!)
const parsed = parseD2CapJsonl(`${header}\n${line}\n`)
expect(parsed.header?.server).toBe('101.37.117.183')
expect(parsed.records.length).toBe(1)
expect(parsed.records[0]!.packetName).toBe('SID_AUTH_INFO')
expect(parsed.records[0]!.hex).toBe('ff500400')
})
it('F1.5: Domain ID helpers and unitTypeFromId/unitTypeToId map canonical D2 unit types', () => {
expect(asUnitId(102)).toBe(102)
expect(asSubTile(2500)).toBe(2500)
expect(unitTypeFromId(0)).toBe('player')
expect(unitTypeFromId(1)).toBe('monster')
expect(unitTypeToId('object')).toBe(2)
expect(CHAR_CLASS_NAMES[1]).toBe('Sorceress')
})
})
// ============================================================================
// F2: Transport Abstraction & Endpoint Resolvers (5 tests)
// ============================================================================
describe('Tier 1 — F2: Transport Abstraction & Endpoint Resolvers', () => {
it('F2.1: createMemoryStreamPair delivers bidirectional chunks in FIFO order', () => {
const [client, server] = createMemoryStreamPair()
const clientRx: Uint8Array[] = []
const serverRx: Uint8Array[] = []
client.onData((chunk) => clientRx.push(chunk))
server.onData((chunk) => serverRx.push(chunk))
client.write(new Uint8Array([1, 2, 3]))
server.write(new Uint8Array([4, 5]))
expect(Array.from(serverRx[0]!)).toEqual([1, 2, 3])
expect(Array.from(clientRx[0]!)).toEqual([4, 5])
})
it('F2.2: MemoryStream close notifies onClose listeners with CloseReason', () => {
const [client, server] = createMemoryStreamPair()
let closedReason: CloseReason | undefined
server.onClose((r) => {
closedReason = r
})
client.close(1000)
expect(closedReason).toEqual({ kind: 'local', code: 1000 })
})
it('F2.3: createWsBridgeResolver maps bnet/realm/game endpoints to /d2net/{bnet,realm,game}', async () => {
resetActiveWssBnetConnectionsForTesting()
const openedUrls: string[] = []
const resolver = createWsBridgeResolver({
host: 'www.laiseek.xyz',
scheme: 'wss',
streamFactory: async (_kind, url) => {
openedUrls.push(url)
const [c] = createMemoryStreamPair()
return c
},
})
const s1 = await resolver.open('bnet', '101.37.117.183', 6112)
const s2 = await resolver.open('realm', '101.37.117.183', 6113)
const s3 = await resolver.open('game', '101.37.117.183', 4000)
expect(openedUrls).toEqual([
'wss://www.laiseek.xyz/d2net/bnet',
'wss://www.laiseek.xyz/d2net/realm',
'wss://www.laiseek.xyz/d2net/game',
])
s1.close()
s2.close()
s3.close()
})
it('F2.4: createTcpResolver delegates endpoint opening to openTcp callback with host and port', async () => {
const calls: Array<{ kind: string; host: string; port: number }> = []
const resolver = createTcpResolver({
openTcp: async (host, port, kind) => {
calls.push({ kind, host, port })
const [c] = createMemoryStreamPair()
return c
},
})
await resolver.open('bnet', '101.37.117.183', 6112)
expect(calls).toEqual([{ kind: 'bnet', host: '101.37.117.183', port: 6112 }])
})
it('F2.5: createMixedResolver routes bnet/realm/game to custom per-role resolvers', async () => {
const routed: string[] = []
const makeRoleResolver = (tag: string) => ({
open: async () => {
routed.push(tag)
const [c] = createMemoryStreamPair()
return c
},
})
const mixed = createMixedResolver({
bnet: makeRoleResolver('bnet-ws'),
realm: makeRoleResolver('realm-tcp'),
game: makeRoleResolver('game-ws'),
})
await mixed.open('bnet', 'h', 6112)
await mixed.open('realm', 'h', 6113)
await mixed.open('game', 'h', 4000)
expect(routed).toEqual(['bnet-ws', 'realm-tcp', 'game-ws'])
})
})
// ============================================================================
// F3: XSha1, CD-Key Shuffle & 1.13c CheckRevision (5 tests)
// ============================================================================
describe('Tier 1 — F3: XSha1, CD-Key Shuffle & 1.13c CheckRevision', () => {
it('F3.1: xsha1 and xsha1Ascii produce deterministic 20-byte digest matching PvPGN Broken SHA-1', () => {
const digest = xsha1(new TextEncoder().encode('password'))
expect(digest).toBeInstanceOf(Uint8Array)
expect(digest.length).toBe(20)
expect(Array.from(digest)).toEqual(Array.from(xsha1Ascii('password')))
})
it('F3.2: hashBnetPassword lowercases input and doubleHash computes 20-byte token proof', () => {
const hUpper = bytesToHex(hashBnetPassword('SecretPass123'))
const hLower = bytesToHex(hashBnetPassword('secretpass123'))
expect(hUpper).toBe(hLower)
const proof = doubleHash(0x11223344, 0x55667788, hashBnetPassword('secretpass123'))
expect(proof.byteLength).toBe(20)
expect(hashRealmPassword(0xb4b89c50, 0x24d6da71).byteLength).toBe(20)
})
it('F3.3: decodeD2CdKey and hashD2CdKey decode 16-char D2 CD-Keys into product, publicValue, and 20-byte hash', () => {
const decoded = decodeD2CdKey('2678-4682-7468-2648', 0x06)
expect(decoded.keyLength).toBe(16)
expect(decoded.productValue).toBe(0x06)
const hashed = hashD2CdKey(0x12345678, 0x9abcdef0, '2678-4682-7468-2648', 0x06)
expect(hashed.hashedKeyData).toHaveLength(20)
})
it('F3.4: createSyntheticCdKeyPair generates distinct valid 16-char Classic (0x06) and Expansion (0x0A) keys', () => {
const pair = createSyntheticCdKeyPair(1)
const h1 = hashD2CdKey(1, 2, pair.classic)
const h2 = hashD2CdKey(1, 2, pair.expansion)
expect(h1.productValue).toBe(0x06)
expect(h2.productValue).toBe(0x0a)
})
it('F3.5: getCheckRevision113c returns authentic 1.13c version check constants (0x01000d00)', () => {
const cr = getCheckRevision113c()
expect(cr.exeVersion).toBe(0x01000d00)
expect(cr.versionByte).toBe(0x0d)
expect(cr.exeInfo).toContain('Game.exe')
expect(D2XP_113C_VERSION_CHECK.exeHash).toBe(cr.exeHash)
})
})
// ============================================================================
// F4: BNCS Protocol Framing, Packets & Session State Machine (5 tests)
// ============================================================================
describe('Tier 1 — F4: BNCS Protocol Framing, Packets & Session State Machine', () => {
it('F4.1: BncsFramer frames and deframes 0xFF-prefixed packets across arbitrary chunk boundaries', () => {
const frame = encodeBncsFrame(BncsOpcode.SID_PING, new Uint8Array([0xde, 0xad, 0xbe, 0xef]))
const framer = new BncsFramer()
expect(framer.push(frame.subarray(0, 3))).toHaveLength(0)
const pkts = framer.push(frame.subarray(3))
expect(pkts).toHaveLength(1)
expect(pkts[0]!.id).toBe(BncsOpcode.SID_PING)
expect(Array.from(pkts[0]!.payload)).toEqual([0xde, 0xad, 0xbe, 0xef])
})
it('F4.2: encodeBncsAuthInfo builds SID_AUTH_INFO (0x50) with D2XP product and versionByte 0x0D', () => {
const frame = encodeBncsAuthInfo({ versionByte: 0x0d })
expect(frame[0]).toBe(0xff)
expect(frame[1]).toBe(0x50)
const r = new ByteReader(frame.subarray(4))
expect(r.u32LE()).toBe(0)
expect(r.fixedCString(4)).toBe('68XI')
expect(r.fixedCString(4)).toBe('PX2D')
expect(r.u32LE()).toBe(0x0d)
})
it('F4.3: encodeBncsAuthCheck builds SID_AUTH_CHECK (0x51) with 2 CD-Keys (D2 + D2XP)', () => {
const pair = createSyntheticCdKeyPair(1)
const k1 = hashD2CdKey(100, 200, pair.classic)
const k2 = hashD2CdKey(100, 200, pair.expansion)
const frame = encodeBncsAuthCheck({
clientToken: 100,
exeVersion: D2XP_113C_VERSION_CHECK.exeVersion,
exeHash: D2XP_113C_VERSION_CHECK.exeHash,
keys: [k1, k2],
spawnCdKey: false,
exeInfo: D2XP_113C_VERSION_CHECK.exeInfo,
ownerName: 'Bot',
})
expect(frame[0]).toBe(0xff)
expect(frame[1]).toBe(0x51)
const r = new ByteReader(frame.subarray(4))
expect(r.u32LE()).toBe(100)
expect(r.u32LE()).toBe(D2XP_113C_VERSION_CHECK.exeVersion)
expect(r.u32LE()).toBe(D2XP_113C_VERSION_CHECK.exeHash)
expect(r.u32LE()).toBe(2)
})
it('F4.4: decodeBncsS2cPacket decodes SID_AUTH_CHECK, SID_LOGONRESPONSE2, and SID_LOGONREALMEX', () => {
const framer = new BncsFramer()
const [authCheckPkt] = framer.push(encodeBncsFrame(0x51, new Uint8Array([0, 0, 0, 0, 0])))
const authCheck = decodeBncsS2cPacket(authCheckPkt!)
expect(authCheck.type).toBe('SID_AUTH_CHECK')
if (authCheck.type === 'SID_AUTH_CHECK') expect(authCheck.data.result).toBe(0)
const [logon2Pkt] = framer.push(encodeBncsFrame(0x3a, new Uint8Array([0, 0, 0, 0])))
const logon2 = decodeBncsS2cPacket(logon2Pkt!)
expect(logon2.type).toBe('SID_LOGONRESPONSE2')
if (logon2.type === 'SID_LOGONRESPONSE2') expect(logon2.data.status).toBe(0)
})
it('F4.5: BncsSession auto-echoes SID_PING and completes handshake + login over MemoryStream', async () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const bncs = new BncsSession(clientStream, { clientToken: 0x11223344 })
const serverFramer = new BncsFramer()
let initByteSeen = false
serverStream.onData((chunk) => {
let data = chunk
if (!initByteSeen && data[0] === 0x01) {
initByteSeen = true
data = data.subarray(1)
}
for (const pkt of serverFramer.push(data)) {
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
serverStream.write(encodeBncsPing(0xdeadbeef))
const w50 = new ByteWriter()
w50.u32LE(0)
w50.u32LE(0x22334455)
w50.u32LE(0)
w50.u32LE(0)
w50.u32LE(0)
w50.cstring('ver-IX86-1.mpq')
w50.cstring('A=1 B=2 C=3 4 A=A^S B=B^C C=C^A A=A^B')
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w50.toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0])))
} else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
serverStream.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0])))
}
}
})
await bncs.handshake()
const loginRes = await bncs.login('testbot', 'pass123')
expect(loginRes.status).toBe(0)
bncs.close()
})
})
// ============================================================================
// F5: MCP Realm Protocol Framing, Packets & Session (5 tests)
// ============================================================================
describe('Tier 1 — F5: MCP Realm Protocol Framing, Packets & Session', () => {
it('F5.1: McpFramer frames and deframes [u16LE size][u8 id][payload] across chunks', () => {
const frame = encodeMcpFrame(McpOpcode.MCP_CHARLIST2, new Uint8Array([8, 0]))
const framer = new McpFramer()
expect(framer.push(frame.subarray(0, 2))).toHaveLength(0)
const pkts = framer.push(frame.subarray(2))
expect(pkts).toHaveLength(1)
expect(pkts[0]!.id).toBe(McpOpcode.MCP_CHARLIST2)
expect(Array.from(pkts[0]!.payload)).toEqual([8, 0])
})
it('F5.2: parseMcpCharStatString extracts expansion, hardcore, ladder, and character class/level from statString', () => {
const sorcStat = new Uint8Array(33).fill(0xff)
sorcStat[0] = 0x84
sorcStat[1] = 0x80
sorcStat[13] = 0x02 // Sorceress (1 + 1)
sorcStat[25] = 45
sorcStat[26] = 0xe4 // 0x80 | LADDER | EXPANSION | HARDCORE
sorcStat[27] = 0x80
const summary = parseMcpCharStatString('TestSorc', 123456, sorcStat)
expect(summary.charClass).toBe(1)
expect(summary.level).toBe(45)
expect(summary.expansion).toBe(true)
expect(summary.hardcore).toBe(true)
expect(summary.ladder).toBe(true)
})
it('F5.3: decodeBncsS2cPacket SID_LOGONREALMEX and encodeMcpStartup convert 86B cookie to 77B MCP startup', () => {
const logonRealmHex =
'ff3e5600509cb8b4000000000000000010000000652575b717e1000071dad6240000000000000000505832440d0000000000000000000000cf16a2f62e712f5e640012aefa5d38214a478a866432776562626f743100'
const [pkt] = new BncsFramer().push(hexToBytes(logonRealmHex))
const decoded = decodeBncsS2cPacket(pkt!)
expect(decoded.type).toBe('SID_LOGONREALMEX')
if (decoded.type === 'SID_LOGONREALMEX') {
expect(decoded.data.mcpIp).toBe('101.37.117.183')
expect(decoded.data.mcpPort).toBe(6113)
const startup = encodeMcpStartup(decoded.data)
expect(startup.byteLength).toBe(77)
}
})
it('F5.4: encodeMcpCharCreate, encodeMcpCharLogon, encodeMcpCreateGame, encodeMcpJoinGame build valid frames', () => {
const cc = encodeMcpCharCreate('WebBotSorc', 1, { expansion: true, hardcore: false, ladder: true })
expect(cc[2]).toBe(McpOpcode.MCP_CHARCREATE)
const cl = encodeMcpCharLogon('WebBotSorc')
expect(cl[2]).toBe(McpOpcode.MCP_CHARLOGON)
const cg = encodeMcpCreateGame(1, { name: 'mygame', password: 'pw', description: 'desc', difficulty: 0 })
expect(cg[2]).toBe(McpOpcode.MCP_CREATEGAME)
const jg = encodeMcpJoinGame(2, 'mygame', 'pw')
expect(jg[2]).toBe(McpOpcode.MCP_JOINGAME)
})
it('F5.5: McpSession executes startup and charLogon over MemoryStream', async () => {
const [mcpClientStream, mcpServerStream] = createMemoryStreamPair()
const mcp = new McpSession(mcpClientStream)
mcpServerStream.onData((chunk) => {
if (chunk.length >= 3 && chunk[2] === McpOpcode.MCP_STARTUP) {
mcpServerStream.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0])))
} else if (chunk.length >= 3 && chunk[2] === McpOpcode.MCP_CHARLOGON) {
mcpServerStream.write(encodeMcpFrame(McpOpcode.MCP_CHARLOGON, new Uint8Array([0, 0, 0, 0])))
}
})
const status = await mcp.startup({
mcpCookie: 1,
mcpStatus: 0,
mcpChunk1: new Uint8Array(8),
mcpIp: '127.0.0.1',
mcpPort: 6113,
mcpChunk2: new Uint8Array(48),
uniqueName: 'bot',
})
expect(status).toBe(0)
expect(mcp.state).toBe('ready')
const logonStatus = await mcp.charLogon('WebBotOne')
expect(logonStatus).toBe(0)
expect(mcp.state).toBe('charLoggedIn')
mcp.close()
})
})
// ============================================================================
// F6: D2GS Huffman Decompression, Packet Size Table & Framer (5 tests)
// ============================================================================
describe('Tier 1 — F6: D2GS Huffman Decompression, Packet Size Table & Framer', () => {
it('F6.1: S2C_PACKET_SIZES (181) and C2S_PACKET_SIZES (113) match 1.13c D2Net.dll tables', () => {
expect(S2C_PACKET_SIZES).toHaveLength(181)
expect(C2S_PACKET_SIZES).toHaveLength(113)
expect(D2CLIENT_S2C_HANDLERS).toHaveLength(175)
expect(S2C_PACKET_SIZES[0x00]).toBe(1)
expect(S2C_PACKET_SIZES[0x01]).toBe(8)
expect(S2C_PACKET_SIZES[0x07]).toBe(6)
expect(S2C_PACKET_SIZES[0x15]).toBe(11)
expect(S2C_PACKET_SIZES[0x8f]).toBe(33)
})
it('F6.2: HUFFMAN_BIT_LENGTHS, HUFFMAN_CODES, and D2GS_S2C_REGISTRY cover all valid 1.13c S2C opcodes', () => {
expect(HUFFMAN_BIT_LENGTHS).toHaveLength(256)
expect(HUFFMAN_CODES).toHaveLength(256)
expect(HUFFMAN_BIT_MASKS).toHaveLength(16)
expect(D2GS_S2C_REGISTRY[0x07]?.name).toBeDefined()
expect(D2GS_S2C_REGISTRY[0x9c]?.name).toBeDefined()
})
it('F6.3: defaultD2gsHuffmanCodec compresses and decompresses arbitrary byte buffers losslessly', () => {
const sample = new Uint8Array(256)
for (let i = 0; i < 256; i++) sample[i] = i
const compressed = defaultD2gsHuffmanCodec.compress(sample)
const decompressed = defaultD2gsHuffmanCodec.decompress(compressed)
expect(Array.from(decompressed)).toEqual(Array.from(sample))
})
it('F6.4: wrapD2gsCompressedBlock and parseD2gsBlockHeader handle both 1-byte (<0xF0) and 2-byte (>=0xF0) headers', () => {
const small = wrapD2gsCompressedBlock(new Uint8Array(20).fill(0x33))
expect(parseD2gsBlockHeader(small, 0)?.headerLen).toBe(1)
const large = wrapD2gsCompressedBlock(new Uint8Array(300).fill(0x77))
expect(parseD2gsBlockHeader(large, 0)?.headerLen).toBe(2)
})
it('F6.5: D2gsS2cFramer frames uncompressed 0xAF greeting followed by multi-packet compressed blocks', () => {
const framer = new D2gsS2cFramer()
const g = framer.push(new Uint8Array([0xaf, 0x01]))
expect(g).toHaveLength(1)
expect(framer.isCompressedMode).toBe(true)
const rawBlock = new Uint8Array([0x00, 0x02, 0x04])
const compressedBlock = wrapD2gsCompressedBlock(defaultD2gsHuffmanCodec.compress(rawBlock))
const out = framer.push(compressedBlock)
expect(out.map((p) => p.packetId)).toEqual([0x00, 0x02, 0x04])
})
})
// ============================================================================
// F7: D2GS S→C Decoders (Handshake, Map, Entity, Movement, Combat, Attributes) (5 tests)
// ============================================================================
describe('Tier 1 — F7: D2GS S→C Decoders (Handshake, Map, Entity, Movement, Combat, Attributes)', () => {
it('F7.1: decode0x0DPlayerStop and decode0x15ReassignPlayer decode unit coordinates and snap flags', () => {
const w0d = new ByteWriter()
w0d.u8(0x0d)
w0d.u8(0x00)
w0d.u32LE(1)
w0d.u8(0x00)
w0d.u16LE(5100)
w0d.u16LE(5200)
w0d.u8(0x00)
w0d.u8(100)
const stopEv = decode0x0DPlayerStop(w0d.toUint8Array())
expect(stopEv.type).toBe('UnitMove')
if (stopEv.type === 'UnitMove') {
expect(stopEv.to).toEqual({ x: 5100, y: 5200 })
}
const w15 = new ByteWriter()
w15.u8(0x15)
w15.u8(0x00)
w15.u32LE(1)
w15.u16LE(5120)
w15.u16LE(5220)
w15.u8(1)
const reassignEv = decode0x15ReassignPlayer(w15.toUint8Array())
expect(reassignEv.type).toBe('UnitReassign')
if (reassignEv.type === 'UnitReassign') {
expect(reassignEv.id).toBe(1)
expect(reassignEv.at).toEqual({ x: 5120, y: 5220 })
}
})
it('F7.2: decodeD2gsS2cPacket decodes 0x51 AssignObject and 0xAC AssignNpc into UnitAssign events', () => {
const w51 = new ByteWriter()
w51.u8(0x51)
w51.u8(0x02)
w51.u32LE(500)
w51.u16LE(119)
w51.u16LE(5115)
w51.u16LE(5080)
w51.u8(0)
w51.u8(0)
const objEv = decodeD2gsS2cPacket(w51.toUint8Array(), { tables: itemTables })
expect(objEv.type).toBe('UnitAssign')
if (objEv.type === 'UnitAssign') {
expect(objEv.unit.id).toBe(500)
expect(objEv.unit.classId).toBe(119)
}
const wac = new ByteWriter()
wac.u8(0xac)
wac.u32LE(777)
wac.u16LE(148)
wac.u16LE(5140)
wac.u16LE(5050)
wac.u8(128)
wac.u8(13)
const npcEv = decodeD2gsS2cPacket(wac.toUint8Array(), { tables: itemTables })
expect(npcEv.type).toBe('UnitAssign')
if (npcEv.type === 'UnitAssign') {
expect(npcEv.unit.id).toBe(777)
expect(npcEv.unit.classId).toBe(148)
}
})
it('F7.3: decodeD2gsS2cPacket decodes 0x7A, 0xA7, 0xA8, 0xA9, and 0xAA combat and state packets', () => {
const e7a = decodeD2gsS2cPacket(new Uint8Array([0x7a, 0, 0, 1, 0, 0, 0, 10, 0, 1, 0, 0, 0]), { tables: itemTables })
expect(e7a.type).toBeDefined()
const ea7 = decodeD2gsS2cPacket(new Uint8Array([0xa7, 0, 1, 0, 0, 0, 23]), { tables: itemTables })
expect(ea7.type).toBe('StateSet')
const ea8 = decodeD2gsS2cPacket(new Uint8Array([0xa8, 0, 1, 0, 0, 0, 9, 32, 0xff]), { tables: itemTables })
expect(ea8.type).toBe('StateSet')
const ea9 = decodeD2gsS2cPacket(new Uint8Array([0xa9, 0, 1, 0, 0, 0, 32]), { tables: itemTables })
expect(ea9.type).toBe('StateEnd')
const eaa = decodeD2gsS2cPacket(new Uint8Array([0xaa, 0, 1, 0, 0, 0, 7]), { tables: itemTables })
expect(eaa.type).toBe('StateSet')
})
it('F7.4: decodeD2gsS2cPacket decodes 0x1D AttributeByte and 0x1E AttributeWord into StatSet events', () => {
const e1d = decodeD2gsS2cPacket(new Uint8Array([0x1d, 0, 95]), { tables: itemTables })
expect(e1d.type).toBe('StatSet')
if (e1d.type === 'StatSet') {
expect(e1d.stat).toBe(0)
expect(e1d.value).toBe(95)
}
})
it('F7.5: decode0x8FPong decodes 33-byte S2C Pong packet into Pong ServerEvent', () => {
const raw = new Uint8Array(33)
raw[0] = 0x8f
const ev = decode0x8FPong(raw)
expect(ev.type).toBe('Pong')
})
})
// ============================================================================
// F8: D2GS S→C Item Bitstream (`0x9C` / `0x9D`) & Quest/Container/Chat/Party (5 tests)
// ============================================================================
describe('Tier 1 — F8: D2GS S→C Item Bitstream (0x9C/0x9D) & Quest/Container/Chat/Party', () => {
it('F8.1: getCanonicalItemDataTables loads authentic ItemStatCost, Armor, Weapons, and Misc tables', () => {
expect(itemTables.itemStatCost.size).toBeGreaterThan(300)
expect(itemTables.getItemBaseMeta('hp1')?.kind).toBe('misc')
expect(itemTables.getItemBaseMeta('cap')?.kind).toBe('armor')
expect(itemTables.getItemBaseMeta('hax')?.kind).toBe('weapon')
})
it('F8.2: decodeD2gsItemPacket decodes a 0x9C simple ground item (hp1 Minor Healing Potion)', () => {
const pkt = buildSynthetic0x9CWorldItemPacket()
const item = decodeD2gsItemPacket(pkt, itemTables)
expect(item.id).toBe(42)
expect(item.code).toBe('hp1')
})
it('F8.3: decodeD2gsS2cPacket decodes 0x28 QuestInfo (103B) into QuestState event', () => {
const pkt = new Uint8Array(103)
pkt[0] = 0x28
pkt[1] = 0
pkt[2] = 1
const ev = decodeD2gsS2cPacket(pkt, { tables: itemTables })
expect(ev.type).toBe('QuestState')
})
it('F8.4: decodeD2gsS2cPacket decodes 0x23 AssignSkill (13B) into ActiveSkill event', () => {
const p23 = new ByteWriter()
p23.u8(0x23)
p23.u8(0x00)
p23.u32LE(1)
p23.u8(0)
p23.u16LE(54)
p23.u32LE(0xffffffff)
const ev23 = decodeD2gsS2cPacket(p23.toUint8Array(), { tables: itemTables })
expect(ev23.type).toBe('ActiveSkill')
if (ev23.type === 'ActiveSkill') {
expect(ev23.skill).toBe(54)
expect(ev23.hand).toBe('right')
}
})
it('F8.5: decodeD2gsS2cPacket decodes 0x26 ChatMessage with from and text', () => {
const w = new ByteWriter()
w.u8(0x26)
w.u8(0x01)
w.u8(0x00)
w.u8(0x00)
w.u32LE(1)
w.u8(0x00)
w.u8(0x00)
w.cstring('DeckardCain')
w.cstring('Stay awhile and listen!')
const ev = decodeD2gsS2cPacket(w.toUint8Array(), { tables: itemTables, encoding: 'utf-8' })
expect(ev.type).toBe('Chat')
if (ev.type === 'Chat') {
expect(ev.from).toBe('DeckardCain')
expect(ev.text).toBe('Stay awhile and listen!')
}
})
})
// ============================================================================
// F9: D2GS C→S Packet Encoders (5 tests)
// ============================================================================
describe('Tier 1 — F9: D2GS C→S Packet Encoders', () => {
it('F9.1: encodeC2sGameLogon produces exact 37-byte 1.13c D2GS join request packet', () => {
const pkt = encodeC2sGameLogon({
gameHash: 0x12345678,
gameToken: 0x0042,
charClass: 1,
charName: 'WebBotOne',
})
expect(pkt.length).toBe(37)
expect(pkt[0]).toBe(0x68)
expect(() => validateC2sPacket(pkt)).not.toThrow()
})
it('F9.2: encodeD2gsClientCommand encodes Move walk (0x01) and run (0x03) as 5-byte packets', () => {
const [walk] = encodeD2gsClientCommand({ type: 'Move', run: false, to: { x: 5100, y: 5200 } })
expect(walk!.length).toBe(5)
expect(walk![0]).toBe(0x01)
const [run] = encodeD2gsClientCommand({ type: 'Move', run: true, to: { x: 5100, y: 5200 } })
expect(run!.length).toBe(5)
expect(run![0]).toBe(0x03)
})
it('F9.3: encodeD2gsClientCommand encodes SkillAt right (0x0C) and SelectSkill (0x3C)', () => {
const [cast] = encodeD2gsClientCommand({
type: 'SkillAt',
hand: 'right',
at: { x: 5110, y: 5210 },
})
expect(cast!.length).toBe(5)
expect(cast![0]).toBe(0x0c)
const [sel] = encodeD2gsClientCommand({
type: 'SelectSkill',
skill: 54,
hand: 'right',
})
expect(sel!.length).toBe(9)
expect(sel![0]).toBe(0x3c)
})
it('F9.4: encodeD2gsClientCommand encodes Interact (0x13) and WaypointGo (0x49)', () => {
const [interact] = encodeD2gsClientCommand({
type: 'Interact',
target: { unitType: 2, id: 500 },
})
expect(interact!.length).toBe(9)
expect(interact![0]).toBe(0x13)
const [wp] = encodeD2gsClientCommand({
type: 'WaypointGo',
waypointId: 500,
destAreaId: 3,
})
expect(wp!.length).toBe(9)
expect(wp![0]).toBe(0x49)
})
it('F9.5: encodeD2gsClientCommand encodes Ping (0x6D, 13B) and LeaveGame (0x69, 1B)', () => {
const [ping] = encodeD2gsClientCommand({ type: 'Ping' }, 'gbk', 12345)
expect(ping!.length).toBe(13)
expect(ping![0]).toBe(0x6d)
const [exit] = encodeD2gsClientCommand({ type: 'LeaveGame' })
expect(exit!.length).toBe(1)
expect(exit![0]).toBe(0x69)
})
})
// ============================================================================
// F10: D2GS Session, Typed Adapter & Full Online Flow Orchestrator (5 tests)
// ============================================================================
describe('Tier 1 — F10: D2GS Session, Typed Adapter & Full Online Flow Orchestrator', () => {
it('F10.1: D2gsSession completes 0xAF -> 0x68 -> 0x01/0x02/0x04 -> 0x6B handshake over MemoryStream', () => {
const [client, server] = createMemoryStreamPair()
const serverReceived: Uint8Array[] = []
server.onData((chunk) => {
serverReceived.push(chunk)
if (chunk[0] === 0x68) {
const block = wrapD2gsCompressedBlock(
defaultD2gsHuffmanCodec.compress(
new Uint8Array([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04]),
),
)
server.write(block)
}
})
const session = new D2gsSession({
stream: client,
logon: {
gameHash: 0x11223344,
gameToken: 0x0055,
charClass: 1,
charName: 'WebBotOne',
},
pingIntervalMs: 5000,
})
server.write(new Uint8Array([0xaf, 0x01]))
expect(session.state).toBe('ingame')
expect(serverReceived.some((c) => c[0] === 0x68)).toBe(true)
expect(serverReceived.some((c) => c[0] === 0x6b)).toBe(true)
session.leaveAndClose()
})
it('F10.2: D2gsSession sendPing and S2C 0x8F Pong update rttMs', () => {
const [client, server] = createMemoryStreamPair()
const clock = new FakeClock(1000)
server.onData((chunk) => {
if (chunk[0] === 0x68) {
server.write(
wrapD2gsCompressedBlock(
defaultD2gsHuffmanCodec.compress(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04])),
),
)
}
})
const session = new D2gsSession({
stream: client,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'WebBotOne' },
clock,
})
server.write(new Uint8Array([0xaf, 0x01]))
session.sendPing()
clock.advance(42)
const pong = new Uint8Array(33)
pong[0] = 0x8f
server.write(wrapD2gsCompressedBlock(defaultD2gsHuffmanCodec.compress(pong)))
expect(session.stats.rttMs).toBe(42)
session.leaveAndClose()
})
it('F10.3: D2gsAdapter emits typed ServerEvent objects from D2gsSession packets', async () => {
const [client, server] = createMemoryStreamPair()
server.onData((chunk) => {
if (chunk[0] === 0x68) {
server.write(
wrapD2gsCompressedBlock(
defaultD2gsHuffmanCodec.compress(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04])),
),
)
}
})
const session = new D2gsSession({
stream: client,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'WebBotOne' },
})
const adapter = new D2gsAdapter({ session, tables: itemTables })
const events: string[] = []
adapter.onEvent((ev) => events.push(ev.type))
server.write(new Uint8Array([0xaf, 0x01]))
expect(events).toContain('GameFlags')
expect(events).toContain('LoadComplete')
await adapter.close()
})
it('F10.4: D2gsAdapter.send encodes and writes ClientCommand bytes to D2gsSession', async () => {
const [client, server] = createMemoryStreamPair()
const serverRx: Uint8Array[] = []
server.onData((chunk) => {
serverRx.push(chunk)
if (chunk[0] === 0x68) {
server.write(
wrapD2gsCompressedBlock(
defaultD2gsHuffmanCodec.compress(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04])),
),
)
}
})
const session = new D2gsSession({
stream: client,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'WebBotOne' },
})
const adapter = new D2gsAdapter({ session, tables: itemTables })
server.write(new Uint8Array([0xaf, 0x01]))
adapter.send({ type: 'Move', run: true, to: { x: 5100, y: 5200 } })
expect(serverRx.some((p) => p[0] === 0x03 && p.length === 5)).toBe(true)
await adapter.close()
})
it('F10.5: createD2OnlineFlow initializes in idle state and exposes full BNCS->MCP->D2GS lifecycle methods', () => {
const flow = createD2OnlineFlow({
bnetHost: '101.37.117.183',
resolver: {
async open() {
const [c] = createMemoryStreamPair()
return c
},
},
itemTables,
})
expect(flow.state).toBe('idle')
expect(typeof flow.connectBnet).toBe('function')
expect(typeof flow.login).toBe('function')
expect(typeof flow.enterRealm).toBe('function')
expect(typeof flow.selectCharacter).toBe('function')
expect(typeof flow.createGame).toBe('function')
expect(typeof flow.joinGame).toBe('function')
})
})
// ============================================================================
// F11: ClientWorld State Mirror (`src/client/world/`) (5 tests)
// ============================================================================
describe('Tier 1 — F11: ClientWorld State Mirror', () => {
it('F11.1: ClientWorld replays 02-d2gs-town-walk-chat.d2cap into self player, mapSeed, and areaId', () => {
const res = replayD2CapFile(path.resolve('tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap'), {
strict: true,
})
expect(res.world.mapSeed).toBeGreaterThan(0)
expect(res.world.areaId).toBe(1)
expect(res.world.self.x).toBeGreaterThan(0)
expect(res.world.self.y).toBeGreaterThan(0)
})
it('F11.2: ClientWorld tracks RoomReveal and RoomHide in world.revealedRooms', () => {
const world = new ClientWorld()
world.apply({ type: 'RoomReveal', areaId: 1, tileX: 1000, tileY: 2000 })
expect(world.revealedRooms.size).toBe(1)
world.apply({ type: 'RoomHide', areaId: 1, tileX: 1000, tileY: 2000 })
expect(world.revealedRooms.size).toBe(0)
})
it('F11.3: ClientWorld tracks UnitAssign, UnitMove, and UnitRemove', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: 200,
classId: 148,
x: 5110,
y: 5050,
lifePct: 100,
},
})
expect(world.getUnit('monster', 200)?.classId).toBe(148)
world.apply({
type: 'UnitMove',
unitType: 'monster',
id: 200,
mode: 'walk',
to: { x: 5120, y: 5060 },
})
expect(world.getUnit('monster', 200)?.x).toBe(5120)
world.apply({ type: 'UnitRemove', unitType: 'monster', id: 200 })
expect(world.getUnit('monster', 200)).toBeUndefined()
})
it('F11.4: ClientWorld applies StatSet, Experience, and LifeMana', () => {
const world = new ClientWorld()
world.apply({ type: 'StatSet', stat: 0, value: 95 })
expect(world.self.stats.get(0)).toBe(95)
world.apply({ type: 'Experience', value: 125000, isDelta: false })
expect(world.self.experience).toBe(125000)
world.apply({
type: 'LifeMana',
life: 320,
mana: 450,
stamina: 180,
at: { x: 5105, y: 5205 },
})
expect(world.self.life).toBe(320)
expect(world.self.mana).toBe(450)
expect(world.self.stamina).toBe(180)
})
it('F11.5: ClientWorld tracks ItemEvent ground items and ActiveSkill', () => {
const world = new ClientWorld()
const itemEv = decodeD2gsS2cPacket(buildSynthetic0x9CWorldItemPacket(), { tables: itemTables })
world.apply(itemEv)
expect(world.getUnit('item', 42)).toBeDefined()
world.apply({
type: 'ActiveSkill',
unitId: 0,
hand: 'right',
skill: 54,
itemId: 0xffffffff,
})
expect(world.self.rightSkill).toBe(54)
})
})
// ============================================================================
// F12: DRLG Seeded Map Generation & Room/Collision Alignment (5 tests)
// ============================================================================
describe('Tier 1 — F12: DRLG Seeded Map Generation & Room/Collision Alignment', () => {
it('F12.1: TileAtlas verifies the 3 universal DT1s (Blank, InvisWal 49:7, Warp) per DRLGROOM_LoadDt1Files', () => {
const v = sharedTileAtlas.verifyUniversalDt1s()
expect(v.blankFound).toBe(true)
expect(v.invisWalFound).toBe(true)
expect(v.invisWalFloor49_7Found).toBe(true)
expect(v.warpFound).toBe(true)
expect(v.allValid).toBe(true)
})
it('F12.2: MapService generates deterministic Act 1 Rogue Encampment (areaId 1) for a fixed seed', () => {
const view1 = sharedMapService.loadAct({
act: 0,
seed: 0x00abcdef,
difficulty: 0,
areaId: 1,
})!
const view2 = sharedMapService.loadAct({
act: 0,
seed: 0x00abcdef,
difficulty: 0,
areaId: 1,
})!
expect(view1.subWidth).toBe(view2.subWidth)
expect(view1.subHeight).toBe(view2.subHeight)
expect(Array.from(view1.collision.slice(0, 200))).toEqual(Array.from(view2.collision.slice(0, 200)))
})
it('F12.3: localToWorldSubTile and worldToLocalSubTile round-trip exact sub-tile coordinates', () => {
const view = sharedMapService.loadAct({ act: 0, seed: 0x12345, areaId: 1 })!
const worldCoord = localToWorldSubTile(view, 25, 30)
const localCoord = worldToLocalSubTile(view, worldCoord.x, worldCoord.y)
expect(localCoord.x).toBe(25)
expect(localCoord.y).toBe(30)
})
it('F12.4: localSubTileToOrtho and orthoToLocalSubTile round-trip isometric screen coordinates', () => {
const ortho = localSubTileToOrtho(40, 20)
const back = orthoToLocalSubTile(ortho.x, ortho.y)
expect(Math.round(back.x)).toBe(40)
expect(Math.round(back.y)).toBe(20)
})
it('F12.5: MapService onAddRoomDataPacket and onRemoveRoomDataPacket reveal and unreveal rooms', () => {
sharedMapService.onLoadActPacket({ act: 0, mapSeed: 0x55aa55aa, areaId: 1 })
const view = sharedMapService.getActiveLevelView()!
const firstRoom = view.rooms[0]!
const added = sharedMapService.onAddRoomDataPacket(
{ areaId: 1, roomX: firstRoom.tileX, roomY: firstRoom.tileY },
{ rebuildView: true, revealMode: 'revealed-only' },
)
expect(added.room).not.toBeNull()
})
})
// ============================================================================
// F13: Client Movement Prediction, Server Reconciliation & Pathfinding (5 tests)
// ============================================================================
describe('Tier 1 — F13: Client Movement Prediction, Server Reconciliation & Pathfinding', () => {
it('F13.1: findPathLocal finds an obstacle-free 8-directional path across a walkable sub-tile grid', () => {
const subWidth = 20
const subHeight = 20
const walkable = new Uint8Array(subWidth * subHeight).fill(1)
const res = findPathLocal({ subWidth, subHeight, walkable }, 2, 2, 15, 15, { smooth: true })
expect(res.reachedGoal).toBe(true)
expect(res.path.length).toBeGreaterThanOrEqual(2)
const last = res.path[res.path.length - 1]!
expect(last.x).toBe(15)
expect(last.y).toBe(15)
})
it('F13.2: LocalMovementPredictor advances predicted position smoothly along a move command', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
predictor.setPath([{ x: 100, y: 100 }, { x: 120, y: 100 }], true)
const snap = predictor.stepTicks(10)
expect(snap.x).toBeGreaterThan(100)
expect(snap.x).toBeLessThanOrEqual(120)
})
it('F13.3: LocalMovementPredictor reconciles small server drift (<=5 sub-tiles) via rubber-band', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
const res = predictor.reconcileServerPosition({ x: 103, y: 100 })
expect(res.mode).toBe('rubber-band')
})
it('F13.4: LocalMovementPredictor reconciles large server drift (>5 sub-tiles) or 0x15 snap via hard-snap', () => {
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
const res = predictor.reconcileServerPosition({ x: 200, y: 200 }, true)
expect(res.mode).toBe('hard-snap')
expect(predictor.stepTicks(0).x).toBe(200)
})
it('F13.5: RemoteEntityInterpolator interpolates remote unit positions between server updates', () => {
const interp = new RemoteEntityInterpolator()
interp.spawnOrSnap({ unitType: 1, unitId: 50, x: 500, y: 500 })
interp.startMove({
unitType: 1,
unitId: 50,
currentX: 500,
currentY: 500,
targetX: 509,
targetY: 500,
moveType: 0x01,
})
interp.update(500)
const entity = interp.getEntityState(1, 50)!
expect(entity.x).toBeGreaterThan(500)
expect(entity.x).toBeLessThanOrEqual(509)
})
})
// ============================================================================
// F14: `.d2s` Bot Progression Unlocker & Checksum (`scripts/d2s-unlock.ts`) (5 tests)
// ============================================================================
describe('Tier 1 — F14: .d2s Bot Progression Unlocker & Checksum', () => {
it('F14.1: validateBotCharacterName accepts d2webbot-prefixed character names', () => {
expect(() => validateBotCharacterName('d2webbot_sorc')).not.toThrow()
expect(() => validateBotCharacterName('D2WebBotAlpha')).not.toThrow()
})
it('F14.2: writeD2sChecksum and d2sChecksum produce deterministic 32-bit rotate-left-add checksum', () => {
const buf = createSyntheticD2sBuffer('d2webbot_sorc')
const c1 = writeD2sChecksum(buf)
const c2 = writeD2sChecksum(buf)
expect(c1).toBe(c2)
expect(typeof d2sChecksum(buf)).toBe('number')
})
it('F14.3: unlockD2sBuffer unlocks all 3 difficulties, all 39 waypoints, and updates checksum', () => {
const buf = createSyntheticD2sBuffer('d2webbot_sorc')
const unlocked = unlockD2sBuffer(buf)
const verify = verifyD2sUnlocked(unlocked)
expect(verify.validMagic).toBe(true)
expect(verify.checksumValid).toBe(true)
expect(verify.allUnlocked).toBe(true)
})
it('F14.4: verifyD2sUnlocked reports characterName, progression, and all 39 waypoints per difficulty', () => {
const buf = createSyntheticD2sBuffer('d2webbot_pal')
const unlocked = unlockD2sBuffer(buf)
const verify = verifyD2sUnlocked(unlocked)
expect(verify.characterName).toBe('d2webbot_pal')
expect(verify.checksumValid).toBe(true)
expect(verify.progression).toBe(15)
expect(verify.difficulties[0]!.all39WaypointsUnlocked).toBe(true)
expect(verify.difficulties[1]!.all39WaypointsUnlocked).toBe(true)
expect(verify.difficulties[2]!.all39WaypointsUnlocked).toBe(true)
})
it('F14.5: unlockD2sBuffer is idempotent when applied twice to the same buffer', () => {
const buf = createSyntheticD2sBuffer('d2webbot_nec')
const once = unlockD2sBuffer(buf)
const twice = unlockD2sBuffer(once)
expect(Array.from(once)).toEqual(Array.from(twice))
})
})
// ============================================================================
// F15: Client Settings Store & Persistence (5 tests)
// ============================================================================
describe('Tier 1 — F15: Client Settings Store & Persistence', () => {
it('F15.1: SettingsStore initializes with DEFAULT_CLIENT_SETTINGS', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const s = store.get()
expect(s.serverHost).toBe(DEFAULT_SERVER_HOST)
expect(s.viewport).toBe(DEFAULT_CLIENT_SETTINGS.viewport)
expect(s.lightingPreset).toBe('auto')
expect(s.lang).toBe('en')
})
it('F15.2: SettingsStore.set persists changes to storage and reloads across instances', () => {
const storage = new MemoryStorage()
const store1 = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
store1.set({ serverHost: 'play.laiseek.xyz', viewport: '1024x768', audioMuted: true })
const store2 = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
expect(store2.get().serverHost).toBe('play.laiseek.xyz')
expect(store2.get().viewport).toBe('1024x768')
expect(store2.get().audioMuted).toBe(true)
})
it('F15.3: deriveRealmWsEndpoints maps serverHost and protocol to wss/ws BNCS, MCP, and D2GS URLs', () => {
const httpsUrls = deriveRealmWsEndpoints('www.laiseek.xyz', 'https:')
expect(httpsUrls.bncsUrl).toBe('wss://www.laiseek.xyz/ws/bncs')
expect(httpsUrls.mcpUrl).toBe('wss://www.laiseek.xyz/ws/mcp')
expect(httpsUrls.d2gsUrl).toBe('wss://www.laiseek.xyz/ws/d2gs')
const httpUrls = deriveRealmWsEndpoints('127.0.0.1:8080', 'http:')
expect(httpUrls.bncsUrl).toBe('ws://127.0.0.1:8080/ws/bncs')
})
it('F15.4: SettingsStore.subscribe notifies listeners on set and reset', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const events: string[] = []
const unsub = store.subscribe((next) => events.push(next.lang))
store.set({ lang: 'zh' })
store.reset()
unsub()
store.set({ lang: 'zh' })
expect(events).toEqual(['zh', 'en'])
})
it('F15.5: SettingsStore clamps masterVolume to [0, 1] and validates lightingPreset and serverHost', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
store.set({ masterVolume: 0.75, lightingPreset: 'fullbright' })
expect(store.get().masterVolume).toBe(0.75)
expect(store.get().lightingPreset).toBe('fullbright')
expect(validateServerHost('www.laiseek.xyz', 'https:').valid).toBe(true)
})
})
// ============================================================================
// F16: Top Control Toolbar & i18n (`en`/`zh`) (5 tests)
// ============================================================================
describe('Tier 1 — F16: Top Control Toolbar & i18n (en/zh)', () => {
it('F16.1: Toolbar snapshot reflects connection phase and RTT badge', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const toolbar = new Toolbar({ store })
toolbar.setConnectionState({ phase: 'd2gs', rttMs: 34 })
const snap = toolbar.getSnapshot()
expect(snap.statusLabel).toBe(tToolbar('toolbar.status.d2gs', 'en'))
expect(snap.rttLabel).toBe('34 ms')
toolbar.destroy()
})
it('F16.2: Toolbar toggles viewport between 800x600 and 1024x768', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const toolbar = new Toolbar({ store })
expect(store.get().viewport).toBe('800x600')
const next = toolbar.toggleViewport()
expect(next).toEqual(VIEWPORT_1024x768)
expect(store.get().viewport).toBe('1024x768')
toolbar.destroy()
})
it('F16.3: Toolbar updates lightingPreset (auto/torch/fullbright) and automapReveal (off/level/act)', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const toolbar = new Toolbar({ store })
toolbar.setLightingPreset('torch')
expect(store.get().lightingPreset).toBe('torch')
toolbar.setLightingPreset('fullbright')
expect(store.get().lightingPreset).toBe('fullbright')
toolbar.setAutomapReveal('act')
expect(store.get().automapReveal).toBe('act')
toolbar.destroy()
})
it('F16.4: Toolbar switches language between en and zh and tToolbar returns localized strings', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'zh',
syncGlobalLang: true,
})
const toolbar = new Toolbar({ store })
toolbar.toggleLanguage()
expect(store.get().lang).toBe('en')
expect(getLang()).toBe('en')
expect(tToolbar('toolbar.status.d2gs', 'zh')).not.toBe(tToolbar('toolbar.status.d2gs', 'en'))
toolbar.destroy()
setLang('zh')
})
it('F16.5: Toolbar submitServerHost updates serverHost and invokes reconnect callback when connected', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const reconnects: Array<{ next: string; prev: string }> = []
const toolbar = new Toolbar({
store,
confirmReconnect: () => true,
callbacks: {
onServerHostReconnect: (next, prev) => reconnects.push({ next, prev }),
},
})
toolbar.setConnectionState({ phase: 'd2gs', rttMs: 20 })
const res = toolbar.submitServerHost('eu.laiseek.xyz')
expect(res.valid).toBe(true)
expect(res.applied).toBe(true)
expect(store.get().serverHost).toBe('eu.laiseek.xyz')
expect(reconnects).toEqual([{ next: 'eu.laiseek.xyz', prev: 'www.laiseek.xyz' }])
toolbar.destroy()
})
})
// ============================================================================
// F17: Viewport, Camera, Lighting & World Scene Integration (5 tests)
// ============================================================================
describe('Tier 1 — F17: Viewport, Camera, Lighting & World Scene Integration', () => {
it('F17.1: VIEWPORT_800x600 and VIEWPORT_1024x768 expose authentic D2 resolution dimensions and anchors', () => {
expect(VIEWPORT_800x600.width).toBe(800)
expect(VIEWPORT_800x600.height).toBe(600)
expect(VIEWPORT_1024x768.width).toBe(1024)
expect(VIEWPORT_1024x768.height).toBe(768)
const anchors1024 = computeViewportAnchors('1024x768')
expect(anchors1024.centerGapWidth).toBe(224)
})
it('F17.2: worldToScreen/screenToWorld and subTileToScreen/screenToSubTile round-trip coordinates', () => {
const screen = worldToScreen(1440, 720, 1280, 640, VIEWPORT_1024x768)
const back = screenToWorld(screen.x, screen.y, 1280, 640, VIEWPORT_1024x768)
expect(back.x).toBeCloseTo(1440, 5)
expect(back.y).toBeCloseTo(720, 5)
const sSub = subTileToScreen(110, 95, 100, 100, VIEWPORT_800x600)
const bSub = screenToSubTile(sSub.x, sSub.y, 100, 100, VIEWPORT_800x600)
expect(bSub.x).toBeCloseTo(110, 5)
expect(bSub.y).toBeCloseTo(95, 5)
})
it('F17.3: buildSceneDrawables builds depth-sorted unit, additive missile, and overlay drawables', () => {
const units: RenderableUnitInput[] = [
{ kind: 'player', id: 1, classId: 1, x: 100, y: 100, states: [32] },
{ kind: 'monster', id: 50, classId: 10, x: 104, y: 100, lifePct: 80, states: [1] },
{ kind: 'missile', id: 200, classId: 29, x: 102, y: 100, targetX: 110, targetY: 100 },
]
const drawables = buildSceneDrawables(units, { tick: 5 })
expect(drawables.unitDrawables.length).toBe(2)
expect(drawables.missileDrawables.length).toBe(1)
expect(drawables.missileDrawables[0]!.blendMode).toBe('additive')
})
it('F17.4: ClientWorldSceneSource projects world state and translates screen clicks to ClientCommands', () => {
const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(0x12345678, 0, 1)
const units: RenderableUnitInput[] = [
{ kind: 'player', id: 1, classId: 1, x: 100, y: 100 },
{ kind: 'monster', id: 50, classId: 10, x: 104, y: 100, lifePct: 80, name: 'Fallen' },
]
const source = new ClientWorldSceneSource(
(): ClientSceneWorldAdapter => ({
tick: 10,
actId: 1,
levelId: 1,
playerSubX: 100,
playerSubY: 100,
units,
levelView,
}),
'1024x768',
)
const monsterScreen = subTileToScreen(104, 100, 100, 100, VIEWPORT_1024x768)
const cmd = translateScreenClickToClientCommand(
monsterScreen.x,
monsterScreen.y - 16,
source,
{ button: 'left', runMode: true },
)
expect(cmd).toEqual({
type: 'RunToTarget',
unitKind: 'monster',
unitId: asUnitId(50),
})
})
it('F17.5: renderSceneSourceFrame and applyLightingInputs configure torch vs fullbright lighting', () => {
const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(0x12345678, 0, 1)
const source = new ClientWorldSceneSource(
(): ClientSceneWorldAdapter => ({
tick: 1,
actId: 1,
levelId: 1,
playerSubX: 100,
playerSubY: 100,
units: [{ kind: 'player', id: 1, classId: 1, x: 100, y: 100 }],
levelView,
}),
'1024x768',
)
const lightGrid = new LightGrid()
let lastLighting: unknown = 'unset'
const mockRenderer = {
setLighting(params: unknown) {
lastLighting = params
},
}
const snap = renderSceneSourceFrame({
source,
lightingPreset: 'torch',
lightingTargets: { lightGrid, renderer: mockRenderer },
})
expect(snap.viewportProfile.width).toBe(1024)
expect(lastLighting).not.toBeNull()
applyLightingInputs(source.buildLightingInputs('fullbright'), {
lightGrid,
renderer: mockRenderer,
})
expect(lastLighting).toBeNull()
expect(LIGHTING_PRESETS.fullbright.enabled).toBe(false)
})
})
// ============================================================================
// F18: Automap & HUD Safe-Math (`MAX_STAT_U32`) (5 tests)
// ============================================================================
describe('Tier 1 — F18: Automap & HUD Safe-Math (MAX_STAT_U32)', () => {
it('F18.1: AutomapView starts in off mode with 0 visible rooms before server 0x07 reveal', () => {
const automap = new AutomapView({ initialMode: 'off' })
const view = automap.setContext(0xdeadbeef, 0, 1)
expect(view.rooms.length).toBeGreaterThan(0)
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
})
it('F18.2: AutomapView onServerAddRoom and onServerRemoveRoom reveal and hide rooms in off mode', () => {
const automap = new AutomapView({ initialMode: 'off' })
const view = automap.setContext(0xdeadbeef, 0, 1)
const firstRoom = view.rooms[0]!
automap.onServerAddRoom(firstRoom.tileX, firstRoom.tileY, 1)
expect(automap.buildSnapshot().visibleRoomCount).toBeGreaterThanOrEqual(1)
automap.onServerRemoveRoom(firstRoom.tileX, firstRoom.tileY, 1)
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
})
it('F18.3: AutomapView level mode reveals all rooms in the current area', () => {
const automap = new AutomapView({ initialMode: 'level' })
const view = automap.setContext(0xdeadbeef, 0, 1)
const snap = automap.buildSnapshot()
expect(snap.visibleAreaIds).toEqual([1])
expect(snap.visibleRoomCount).toBe(view.rooms.length)
})
it('F18.4: AutomapView act mode reveals multiple levels and warp markers across the Act', () => {
const automap = new AutomapView({
initialMode: 'act',
actRevealLevelIds: { 1: [1, 2, 3], 2: [40], 3: [75], 4: [103], 5: [109] },
})
automap.setContext(0xdeadbeef, 0, 1)
const snap = automap.buildSnapshot()
expect(snap.visibleAreaIds).toEqual([1, 2, 3])
expect(snap.warpMarkers.length).toBeGreaterThanOrEqual(3)
})
it('F18.5: computeDockingLayout and computeHudLayout preserve 1x HUD scale and 224px center channel in 1024x768', () => {
const dock = computeDockingLayout(1024, 768, 1, getViewportProfile('1024x768'))
expect(dock.uiScale).toBe(1)
expect(dock.channelWidthLogical).toBe(224)
const layout800 = computeHudLayout(800, 600, 1)
expect(layout800.uiScale).toBe(1)
const stageEl = { style: { width: '', height: '' } }
const glCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
const hudCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
applyViewportProfileToDom('1024x768', { stageEl, glCanvas, hudCanvas })
expect(glCanvas.width).toBe(1024)
})
})
// ============================================================================
// F19: Audio Engine (`SoundService`) & OPFS MPQ Asset Manager (5 tests)
// ============================================================================
describe('Tier 1 — F19: Audio Engine (SoundService) & OPFS MPQ Asset Manager', () => {
function createMockBackend(playedBgm: number[], playedSfx: string[]): AudioBackendLike {
return {
async init() {},
setMuted() {},
async playBGM(actOrId) {
playedBgm.push(actOrId)
},
playSFX(id) {
playedSfx.push(id)
},
playButtonClick() {
playedSfx.push('button')
},
playFootstep() {
playedSfx.push('step')
},
playPortalEnter() {
playedSfx.push('portal')
},
playWaypointActivate() {
playedSfx.push('wp')
},
}
}
it('F19.1: SoundService plays Act BGM on onEnterArea and resolves SoundEnviron.txt metadata', async () => {
const playedBgm: number[] = []
const playedSfx: string[] = []
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const svc = new SoundService({ store, backend: createMockBackend(playedBgm, playedSfx) })
await svc.init()
const envAct4 = await svc.onEnterArea(103)
expect(envAct4.actId).toBe(4)
expect(playedBgm).toEqual([4])
expect(resolveSoundEnvironmentForLevel(109).actId).toBe(5)
svc.destroy()
})
it('F19.2: SoundService plays spatial SFX by Sounds.txt ID and computeSpatialAudioMetrics culls distant sounds', async () => {
const playedBgm: number[] = []
const playedSfx: string[] = []
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const svc = new SoundService({ store, backend: createMockBackend(playedBgm, playedSfx) })
await svc.init()
const rec = svc.playSoundById(47, {
sourceSubX: 105,
sourceSubY: 100,
listenerSubX: 100,
listenerSubY: 100,
})
expect(rec).not.toBeNull()
expect(rec!.sfxId).toBe('skill_fire')
expect(playedSfx).toContain('skill_fire')
const culled = computeSpatialAudioMetrics(200, 200, 100, 100)
expect(culled.culled).toBe(true)
svc.destroy()
})
it('F19.3: SoundService syncs mute state with SettingsStore and suppresses SFX when muted', async () => {
const playedBgm: number[] = []
const playedSfx: string[] = []
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const svc = new SoundService({ store, backend: createMockBackend(playedBgm, playedSfx) })
await svc.init()
svc.setMuted(true)
expect(svc.playSoundById(47)).toBeNull()
svc.destroy()
})
it('F19.4: HttpRangeMpqSource fetches byte ranges via HTTP Range header and caches chunks in InMemoryChunkCache', async () => {
const fakeArchive = new Uint8Array(1024)
for (let i = 0; i < fakeArchive.length; i++) fakeArchive[i] = i & 0xff
const cache = new InMemoryChunkCache()
const source = new HttpRangeMpqSource({
url: '/diablo2/data/d2sfx.mpq',
label: 'd2sfx.mpq',
cache,
fetchFn: async (_url, init) => {
const range = init?.headers?.Range ?? 'bytes=0-31'
const m = /^bytes=(\d+)-(\d+)$/.exec(range)!
const start = Number(m[1])
const end = Number(m[2])
const slice = fakeArchive.slice(start, end + 1)
return {
ok: true,
status: 206,
headers: {
get(name: string) {
return name.toLowerCase() === 'content-range'
? `bytes ${start}-${end}/${fakeArchive.length}`
: null
},
},
arrayBuffer: async () => slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength),
}
},
})
const first = await source.read(0, 32)
const second = await source.read(0, 32)
expect(first).toHaveLength(32)
expect(second).toEqual(first)
expect(source.rangeRequestsCount).toBe(1)
expect(source.cacheHitsCount).toBe(1)
})
it('F19.5: OpfsMpqManager resolves and caches named MPQ sources under baseUrl', () => {
const cache = new InMemoryChunkCache()
const mgr = new OpfsMpqManager({ baseUrl: '/diablo2/data', cache })
const s1 = mgr.getSource('d2sfx.mpq')
const s2 = mgr.getSource('d2sfx.mpq')
expect(s1).toBe(s2)
expect(s1.url).toBe('/diablo2/data/d2sfx.mpq')
})
})
// ============================================================================
// F20: Outbound Rate Limiter, ProtocolSession & Packet Inspector / Replay (5 tests)
// ============================================================================
describe('Tier 1 — F20: Outbound Rate Limiter, ProtocolSession & Packet Inspector / Replay', () => {
it('F20.1: classifyClientCommand assigns movement, skill, chat, heartbeat, and interaction categories', () => {
expect(classifyClientCommand({ type: 'RunToLocation', x: asSubTile(1), y: asSubTile(2) })).toBe('movement')
expect(classifyClientCommand({ type: 'CastRightSkillOnLocation', x: asSubTile(1), y: asSubTile(2) })).toBe('skill')
expect(classifyClientCommand({ type: 'Chat', text: 'hi' } as unknown as ClientCommand)).toBe('chat')
expect(classifyClientCommand({ type: 'Ping' })).toBe('heartbeat')
expect(classifyClientCommand({ type: 'Interact', target: { unitType: 1, id: 1 } })).toBe('interaction')
})
it('F20.2: OutboundRateLimiter allows initial commands, coalesces rapid moves, and flushes pending move', () => {
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
const d1 = limiter.tryAcquire({ type: 'RunToLocation', x: asSubTile(100), y: asSubTile(100) }, 1000)
expect(d1.allowed).toBe(true)
const d2 = limiter.tryAcquire({ type: 'RunToLocation', x: asSubTile(110), y: asSubTile(110) }, 1050)
expect(d2.allowed).toBe(false)
expect(d2.coalesced).toBe(true)
const flushed = limiter.flushPendingMove(1130)
expect(flushed).not.toBeNull()
if (flushed?.type === 'RunToLocation') {
expect(flushed.x).toBe(110)
}
})
it('F20.3: OutboundRateLimiter suppresses identical duplicate move within 350ms and never throttles Ping', () => {
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
const move = { type: 'RunToLocation', x: asSubTile(100), y: asSubTile(100) } as const
expect(limiter.tryAcquire(move, 1000).allowed).toBe(true)
const dup = limiter.tryAcquire(move, 1050)
expect(dup.allowed).toBe(false)
expect(dup.reason).toBe('duplicate_move')
expect(limiter.tryAcquire({ type: 'Ping' }, 1055).allowed).toBe(true)
})
it('F20.4: PacketInspector captures PacketTap entries, filters by phase/direction/opcode, and formats hex dumps', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap()
const inspector = new PacketInspector({ store, tap })
tap.record({
t: 10,
conn: 'bnet',
dir: 'c2s',
packetId: 0x50,
raw: new Uint8Array([0xff, 0x50, 0x04, 0x00]),
})
tap.record({
t: 20,
conn: 'game',
dir: 's2c',
packetId: 0x15,
raw: new Uint8Array([0x15, 0x00, 0x01, 0x00, 0x00, 0x00]),
})
expect(inspector.getAllRows()).toHaveLength(2)
expect(formatHexDump(new Uint8Array([0x15, 0x41, 0x42]))).toContain('15 41 42')
inspector.setFilter({ phase: 'd2gs', direction: 's2c' })
expect(inspector.getFilteredRows()).toHaveLength(1)
expect(inspector.getFilteredRows()[0]!.opcode).toBe(0x15)
inspector.destroy()
})
it('F20.5: PacketInspector exports and replays .d2cap JSONL traces via stepReplay and replayAll', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap()
const replayed: number[] = []
const inspector = new PacketInspector({
store,
tap,
onReplayRecord: (frame) => replayed.push(frame.opcode),
})
tap.record({ t: 5, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) })
tap.record({ t: 15, conn: 'game', dir: 's2c', packetId: 0x02, raw: new Uint8Array([0x02]) })
const jsonl = inspector.exportD2CapJsonl()
inspector.clear()
const loaded = inspector.loadD2CapJsonl(jsonl)
expect(loaded).toHaveLength(2)
expect(inspector.stepReplay()?.opcode).toBe(0x00)
expect(inspector.replayAll()).toBe(1)
expect(replayed).toEqual([0x00, 0x02])
inspector.destroy()
})
})