1866 lines
71 KiB
TypeScript
1866 lines
71 KiB
TypeScript
/**
|
||
* 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()
|
||
})
|
||
})
|