diablo2-web/tests/e2e-netproto/tier2-boundary-corner.test.ts

1333 lines
58 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.

import { beforeAll, describe, expect, it } from 'vitest'
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
import { BitReader } from '../../src/netproto/core/bit-reader.ts'
import { BitWriter } from '../../src/netproto/core/bit-writer.ts'
import { FakeClock } from '../../src/netproto/core/clock.ts'
import { ProtocolError } from '../../src/netproto/core/errors.ts'
import {
hexToBytes,
InMemoryPacketTap,
parseD2CapJsonl,
} from '../../src/netproto/core/packet-tap.ts'
import { createMemoryStreamPair } from '../../src/netproto/transport/memory-stream.ts'
import {
createMixedResolver,
createTcpResolver,
createWsBridgeResolver,
resetActiveWssBnetConnectionsForTesting,
} from '../../src/netproto/transport/endpoint.ts'
import { xsha1 } from '../../src/netproto/crypto/xsha1.ts'
import { doubleHash, hashBnetPassword } from '../../src/netproto/crypto/bnet-hash.ts'
import { decodeD2CdKey, hashD2CdKey } from '../../src/netproto/crypto/cdkey.ts'
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
import { BncsOpcode, decodeBncsS2cPacket } from '../../src/netproto/bncs/packets.ts'
import { BncsSession } from '../../src/netproto/bncs/session.ts'
import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts'
import {
decodeMcpS2cPacket,
McpOpcode,
parseMcpCharStatString,
} from '../../src/netproto/mcp/packets.ts'
import { McpSession } from '../../src/netproto/mcp/session.ts'
import {
defaultD2gsHuffmanCodec,
encodeD2gsCompressedFrame,
parseD2gsBlockHeader,
unpackCustomHuffmanLengths,
wrapD2gsCompressedBlock,
} from '../../src/netproto/d2gs/compression.ts'
import { D2gsS2cFramer, resolveS2cPacketSize } from '../../src/netproto/d2gs/framing-s2c.ts'
import { resolveC2sPacketSize, validateC2sPacket } from '../../src/netproto/d2gs/framing-c2s.ts'
import { decodeD2gsS2cPacket, encodeD2gsClientCommand } from '../../src/netproto/d2gs/registry.ts'
import {
decodeD2gsItemPacket,
decodeItemBitstream,
} from '../../src/netproto/d2gs/items/item-bitstream.ts'
import {
ITEM_FLAG_IDENTIFIED,
resolveItemContainer,
} from '../../src/netproto/d2gs/items/item-types.ts'
import { D2gsSession } from '../../src/netproto/d2gs/session.ts'
import { D2gsAdapter } from '../../src/netproto/d2gs/adapter.ts'
import { createD2OnlineFlow } from '../../src/netproto/flow/online-flow.ts'
import type { ItemDataTables } from '../../src/netproto/domain/ids.ts'
import type { ByteStream, EndpointKind } from '../../src/netproto/transport/byte-stream.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import {
MapService,
TileAtlas,
getCollisionLocalSubTile,
isWalkableLocalSubTile,
} from '../../src/client/map/map-service.ts'
import { COLLIDE_MASK_INVALID } from '../../src/game/d2map.ts'
import { findPathLocal, hasLineOfSightLocal } from '../../src/client/motion/pathfind.ts'
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
import {
D2S_MAGIC,
D2S_QUEST_OFFSET,
D2S_VERSION_113C,
D2S_WAYPOINT_OFFSET,
unlockD2sBuffer,
validateBotCharacterName,
verifyD2sUnlocked,
writeD2sChecksum,
} from '../../scripts/d2s-unlock.ts'
import {
DEFAULT_SERVER_HOST,
parseClientSettingsJson,
sanitizeClientSettings,
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,
getViewportProfile,
VIEWPORT_1024x768,
VIEWPORT_800x600,
} 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 { computeHudLayout } from '../../src/ui/hud-manager.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 { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { formatHexDump, PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
import { asSubTile } from '../../src/netproto/index.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)
}
}
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
buf[0x25] = 0x00
buf[0x2b] = 1
buf[D2S_QUEST_OFFSET] = 0x57
buf[D2S_QUEST_OFFSET + 1] = 0x6f
buf[D2S_QUEST_OFFSET + 2] = 0x6f
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
buf[D2S_WAYPOINT_OFFSET + 1] = 0x53
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
}
const emptyTables: ItemDataTables = {
itemStatCost: new Map(),
armor: new Map(),
weapons: new Map(),
misc: new Map(),
getStatMeta: () => undefined,
getStatCost: () => undefined,
getItemBaseMeta: () => undefined,
getItemMeta: () => undefined,
}
let sharedMapService: MapService
beforeAll(async () => {
sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles')
}, 60_000)
describe('Tier 2 — Negative, Boundary, Malformed & Corner Cases (F1–F20)', () => {
// ─── F1: Core Binary & Bit Primitives Boundaries ───────────────────────────
describe('F1: Core Binary & Bit Primitives Boundaries', () => {
it('F1.B1: ByteReader throws ProtocolError on buffer overrun across u8/u16/u32/bytes/skip', () => {
const r = new ByteReader(new Uint8Array([1, 2]), 0, 'bnet', 0x50)
expect(() => r.u32LE()).toThrow(ProtocolError)
expect(() => r.bytes(3)).toThrow(ProtocolError)
expect(() => r.skip(-1)).toThrow(ProtocolError)
expect(() => r.skip(5)).toThrow(ProtocolError)
})
it('F1.B2: ByteReader.cstring throws ProtocolError on unterminated null-delimited string', () => {
const r = new ByteReader(new Uint8Array([65, 66, 67]), 0, 'mcp', 0x01)
expect(() => r.cstring()).toThrow(ProtocolError)
})
it('F1.B3: ByteWriter.patchU16 throws RangeError on out-of-range offset and auto-grows capacity on large writes', () => {
const w = new ByteWriter(16)
w.u32LE(0x12345678)
w.u32LE(0x9abcdef0)
expect(w.length).toBe(8)
expect(() => w.patchU16(-1, 0)).toThrow(RangeError)
expect(() => w.patchU16(16, 0)).toThrow(RangeError)
})
it('F1.B4: BitReader and BitWriter reject invalid bit widths (<0 or >32) and throw ProtocolError on bit overrun', () => {
const br = new BitReader(new Uint8Array([0xff]))
expect(() => br.readBits(-1)).toThrow(ProtocolError)
expect(() => br.readBits(33)).toThrow(ProtocolError)
expect(() => br.readBits(9)).toThrow(ProtocolError)
expect(() => br.skipBits(-5)).toThrow(ProtocolError)
const bw = new BitWriter()
expect(() => bw.writeBits(1, -1)).toThrow(/invalid bit count/)
expect(() => bw.writeBits(1, 33)).toThrow(/invalid bit count/)
})
it('F1.B5: hexToBytes rejects odd-length or non-hex strings and InMemoryPacketTap evicts oldest entry at maxEntries', () => {
expect(() => hexToBytes('abc')).toThrow()
expect(() => hexToBytes('zz')).toThrow()
const tap = new InMemoryPacketTap(2)
tap.record({ t: 1, conn: 'game', dir: 's2c', raw: new Uint8Array([1]) })
tap.record({ t: 2, conn: 'game', dir: 's2c', raw: new Uint8Array([2]) })
tap.record({ t: 3, conn: 'game', dir: 's2c', raw: new Uint8Array([3]) })
expect(tap.entries).toHaveLength(2)
expect(tap.entries[0]?.t).toBe(2)
})
})
// ─── F2: Transport & Endpoint Resolution Boundaries ────────────────────────
describe('F2: Transport & Endpoint Resolution Boundaries', () => {
it('F2.B1: MemoryStream.write throws Error after stream is closed', () => {
const [a] = createMemoryStreamPair()
a.close()
expect(() => a.write(new Uint8Array([1]))).toThrow(/closed/)
})
it('F2.B2: createWsBridgeResolver enforces maxConcurrentBnetWss = 2 and rejects a 3rd concurrent bnet stream', async () => {
resetActiveWssBnetConnectionsForTesting()
const resolver = createWsBridgeResolver({
maxConcurrentBnetWss: 2,
streamFactory: async () => createMemoryStreamPair()[0],
})
const s1 = await resolver.open('bnet', 'test.local', 6112)
const s2 = await resolver.open('bnet', 'test.local', 6112)
await expect(resolver.open('bnet', 'test.local', 6112)).rejects.toThrow(/Rate limit/)
s1.close()
s2.close()
})
it('F2.B3: createWsBridgeResolver decrements active bnet connection count if streamFactory rejects', async () => {
resetActiveWssBnetConnectionsForTesting()
const resolver = createWsBridgeResolver({
maxConcurrentBnetWss: 1,
streamFactory: async () => {
throw new Error('handshake failed')
},
})
await expect(resolver.open('bnet', 'test.local', 6112)).rejects.toThrow('handshake failed')
const okResolver = createWsBridgeResolver({
maxConcurrentBnetWss: 1,
streamFactory: async () => createMemoryStreamPair()[0],
})
const s = await okResolver.open('bnet', 'test.local', 6112)
s.close()
})
it('F2.B4: createMixedResolver throws descriptive Error when endpoint kind has no mapped or fallback resolver', async () => {
const mixed = createMixedResolver({})
await expect(mixed.open('ts', '127.0.0.1', 4002)).rejects.toThrow(/No endpoint resolver/)
})
it('F2.B5: MemoryStream propagates error CloseReason when closed with an Error', () => {
const [a, b] = createMemoryStreamPair()
let reasonKind = ''
b.onClose((r) => {
reasonKind = r.kind
})
a.close({ kind: 'error', error: new Error('socket reset') })
expect(reasonKind).toBe('error')
})
})
// ─── F3: Cryptographic & Auth Primitives Boundaries ────────────────────────
describe('F3: Cryptographic & Auth Primitives Boundaries', () => {
it('F3.B1: xsha1 handles empty 0-byte input and multi-block (>64 byte) inputs without overrun', () => {
const emptyHash = xsha1(new Uint8Array(0))
const longHash = xsha1(new Uint8Array(200).fill(0x61))
expect(emptyHash.byteLength).toBe(20)
expect(longHash.byteLength).toBe(20)
expect(emptyHash).not.toEqual(longHash)
})
it('F3.B2: doubleHash throws Error when passwordHash20 is not exactly 20 bytes', () => {
expect(() => doubleHash(1, 2, new Uint8Array(19))).toThrow(/20-byte/)
expect(() => doubleHash(1, 2, new Uint8Array(21))).toThrow(/20-byte/)
})
it('F3.B3: decodeD2CdKey throws Error when cleaned key is not 16 characters', () => {
expect(() => decodeD2CdKey('ABCD-1234')).toThrow(/16-character/)
expect(() => decodeD2CdKey('')).toThrow(/16-character/)
})
it('F3.B4: decodeD2CdKey throws Error on invalid alphabet characters (e.g., 0, 1, 3, I, O, U)', () => {
expect(() => decodeD2CdKey('0000-1111-3333-OOOO')).toThrow(/Invalid D2 CD-key character/)
})
it('F3.B5: hashD2CdKey accepts raw 16-char string or pre-decoded DecodedCdKey identically', () => {
const raw = '4678-2468-79BC-DEFG'
const decoded = decodeD2CdKey(raw)
expect(hashD2CdKey(10, 20, raw)).toEqual(hashD2CdKey(10, 20, decoded))
})
})
// ─── F4: BNCS Framing, Packets & Session Boundaries ────────────────────────
describe('F4: BNCS Framing, Packets & Session Boundaries', () => {
it('F4.B1: BncsFramer throws ProtocolError when header byte is not 0xFF', () => {
const framer = new BncsFramer()
expect(() => framer.push(new Uint8Array([0xfe, 0x50, 0x04, 0x00]))).toThrow(ProtocolError)
})
it('F4.B2: BncsFramer throws ProtocolError when declared frame length is < 4 bytes', () => {
const framer = new BncsFramer()
expect(() => framer.push(new Uint8Array([0xff, 0x50, 0x03, 0x00]))).toThrow(ProtocolError)
})
it('F4.B3: decodeBncsS2cPacket handles short failed SID_LOGONREALMEX (<16 bytes) and unknown opcodes gracefully', () => {
const w = new ByteWriter().u32(123).u32(0x80000001)
const shortRealm = decodeBncsS2cPacket({
id: BncsOpcode.SID_LOGONREALMEX,
payload: w.toUint8Array(),
raw: encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array()),
})
expect(shortRealm.type).toBe('SID_LOGONREALMEX')
if (shortRealm.type === 'SID_LOGONREALMEX') {
expect(shortRealm.data.mcpStatus).toBe(0x80000001)
}
const unk = decodeBncsS2cPacket({
id: 0xfe,
payload: new Uint8Array([1, 2]),
raw: new Uint8Array([0xff, 0xfe, 6, 0, 1, 2]),
})
expect(unk.type).toBe('UNKNOWN')
})
it('F4.B4: BncsSession rejects pending requests with ProtocolError on timeout', async () => {
const [clientStream] = createMemoryStreamPair()
const clock = new FakeClock(1000)
const session = new BncsSession(clientStream, { clock, requestTimeoutMs: 500 })
const p = session.listRealms()
clock.advance(600)
await expect(p).rejects.toThrow(ProtocolError)
})
it('F4.B5: BncsSession.handshake rejects with ProtocolError when SID_AUTH_CHECK returns non-zero result', async () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const framer = new BncsFramer()
let init = false
serverStream.onData((chunk) => {
let d = chunk
if (!init && d[0] === 0x01) {
init = true
d = d.subarray(1)
}
for (const pkt of framer.push(d)) {
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
const w = new ByteWriter().u32(0).u32(1).u32(0).u64BigInt(0n).cstring('ver-IX86-1.mpq').cstring('A=1')
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
const w = new ByteWriter().u32(0x100).cstring('Old game version')
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, w.toUint8Array()))
}
}
})
const session = new BncsSession(clientStream)
await expect(session.handshake()).rejects.toThrow(/SID_AUTH_CHECK failed/)
})
})
// ─── F5: MCP Framing, Packets & Session Boundaries ─────────────────────────
describe('F5: MCP Framing, Packets & Session Boundaries', () => {
it('F5.B1: McpFramer throws ProtocolError when packet length < 3', () => {
const framer = new McpFramer()
expect(() => framer.push(new Uint8Array([0x02, 0x00, 0x01]))).toThrow(ProtocolError)
})
it('F5.B2: parseMcpCharStatString handles empty/truncated statStrings with safe defaults', () => {
const parsed = parseMcpCharStatString('ShortChar', 0, new Uint8Array(0))
expect(parsed.name).toBe('ShortChar')
expect(parsed.level).toBe(1)
expect(parsed.charClass).toBe(0)
})
it('F5.B3: decodeMcpS2cPacket returns UNKNOWN for unrecognized MCP opcodes', () => {
const res = decodeMcpS2cPacket({ id: 0xfe, payload: new Uint8Array([1]), raw: new Uint8Array([4, 0, 0xfe, 1]) })
expect(res.type).toBe('UNKNOWN')
})
it('F5.B4: McpSession.startup rejects when server returns non-zero status', async () => {
const [clientStream, serverStream] = createMemoryStreamPair()
serverStream.onData((chunk) => {
if (chunk.length > 1) {
serverStream.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new ByteWriter().u32(0x02).toUint8Array()))
}
})
const mcp = new McpSession(clientStream)
await expect(
mcp.startup({
mcpCookie: 1,
mcpStatus: 0,
mcpIp: '127.0.0.1',
mcpPort: 6113,
mcpChunk1: new Uint8Array(8),
mcpChunk2: new Uint8Array(48),
uniqueName: 'Hero',
}),
).rejects.toThrow(/MCP_STARTUP failed/)
})
it('F5.B5: McpSession rejects pending requests when stream closes unexpectedly', async () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const mcp = new McpSession(clientStream)
const p = mcp.listChars(8)
serverStream.close()
await expect(p).rejects.toThrow(ProtocolError)
})
})
// ─── F6: D2GS Huffman Compression & Block Framing Boundaries ───────────────
describe('F6: D2GS Huffman Compression & Block Framing Boundaries', () => {
it('F6.B1: parseD2gsBlockHeader returns null on empty or partial 2-byte header buffers', () => {
expect(parseD2gsBlockHeader(new Uint8Array(0), 0)).toBeNull()
expect(parseD2gsBlockHeader(new Uint8Array([0xf0]), 0)).toBeNull()
})
it('F6.B2: wrapD2gsCompressedBlock throws ProtocolError when compressed block exceeds 4095 bytes', () => {
expect(() => wrapD2gsCompressedBlock(new Uint8Array(4096))).toThrow(ProtocolError)
})
it('F6.B3: unpackCustomHuffmanLengths throws ProtocolError when 0xAF packet is shorter than 130 bytes', () => {
expect(() => unpackCustomHuffmanLengths(new Uint8Array([0xaf, 0x81, 0x00]))).toThrow(ProtocolError)
})
it('F6.B4: defaultD2gsHuffmanCodec.decompress throws ProtocolError when bitstream ends before expectedLength', () => {
expect(() => defaultD2gsHuffmanCodec.decompress(new Uint8Array(0), 5)).toThrow(ProtocolError)
})
it('F6.B5: resolveC2sPacketSize and validateC2sPacket reject unknown or mismatched C->S opcodes', () => {
expect(() => resolveC2sPacketSize(new Uint8Array([0xfe]), 0)).toThrow(ProtocolError)
expect(() => validateC2sPacket(new Uint8Array(0))).toThrow(ProtocolError)
expect(() => validateC2sPacket(new Uint8Array([0x01, 0x00]))).toThrow(ProtocolError)
})
})
// ─── F7: D2GS S->C Registry & C->S Encoders Boundaries ─────────────────────
describe('F7: D2GS S->C Registry & C->S Encoders Boundaries', () => {
it('F7.B1: decodeD2gsS2cPacket throws ProtocolError on empty packet', () => {
expect(() => decodeD2gsS2cPacket(new Uint8Array(0), { tables: emptyTables })).toThrow(ProtocolError)
})
it('F7.B2: decodeD2gsS2cPacket throws ProtocolError on packet length mismatch', () => {
expect(() => decodeD2gsS2cPacket(new Uint8Array([0x03, 0x00, 0x01]), { tables: emptyTables })).toThrow(ProtocolError)
})
it('F7.B3: decodeD2gsS2cPacket throws ProtocolError on out-of-range opcode (>0xB4)', () => {
expect(() => decodeD2gsS2cPacket(new Uint8Array([0xfe]), { tables: emptyTables })).toThrow(ProtocolError)
})
it('F7.B4: decodeD2gsS2cPacket decodes rare 1.13c opcodes (e.g. 0x97 WeaponSwitch, 0x58 OpenUiBox) without throwing', () => {
const ev97 = decodeD2gsS2cPacket(new Uint8Array([0x97]), { tables: emptyTables })
expect(ev97.type).toBeDefined()
const ev58 = decodeD2gsS2cPacket(new Uint8Array(7).fill(0x58), { tables: emptyTables })
expect(ev58.type).toBeDefined()
})
it('F7.B5: D2gsS2cFramer throws ProtocolError when uncompressed or decompressed stream contains invalid opcode', () => {
const framer = new D2gsS2cFramer()
expect(() => framer.push(new Uint8Array([0xfe]))).toThrow(ProtocolError)
})
})
// ─── F8: D2GS 0x9C/0x9D Item Bitstream Boundaries ──────────────────────────
describe('F8: D2GS 0x9C/0x9D Item Bitstream Boundaries', () => {
it('F8.B1: decodeD2gsItemPacket rejects packets shorter than 8 bytes or with invalid opcode', () => {
expect(() => decodeD2gsItemPacket(new Uint8Array([0x9c, 0, 4, 0]), emptyTables)).toThrow(ProtocolError)
expect(() => decodeD2gsItemPacket(new Uint8Array(8).fill(0x01), emptyTables)).toThrow(ProtocolError)
})
it('F8.B2: decodeD2gsItemPacket rejects 0x9D packet shorter than 13-byte header or mismatched packetSize byte', () => {
const short9d = new Uint8Array(10)
short9d[0] = 0x9d
expect(() => decodeD2gsItemPacket(short9d, emptyTables)).toThrow(ProtocolError)
const badSize = new Uint8Array(10)
badSize[0] = 0x9c
badSize[2] = 99
expect(() => decodeD2gsItemPacket(badSize, emptyTables)).toThrow(ProtocolError)
})
it('F8.B3: decodeItemBitstream throws ProtocolError when expectJmHeader is true and JM (0x4D4A) is missing', () => {
const bw = new BitWriter()
bw.writeBits(0x1234, 16)
expect(() =>
decodeItemBitstream(
bw.toUint8Array(),
{ opcode: 0x9c, action: 0, category: 0, itemId: 1 },
emptyTables,
{ expectJmHeader: true },
),
).toThrow(ProtocolError)
})
it('F8.B4: decodeItemBitstream throws ProtocolError on invalid item quality (0 or >9)', () => {
const bw = new BitWriter()
bw.writeBits(ITEM_FLAG_IDENTIFIED, 32)
bw.writeBits(101, 10)
bw.writeBits(3, 3)
bw.writeBits(100, 16)
bw.writeBits(100, 16)
bw.writeAscii('rin ')
bw.writeBits(0, 3)
bw.writeBits(10, 7)
bw.writeBits(15, 4)
expect(() =>
decodeItemBitstream(
bw.toUint8Array(),
{ opcode: 0x9c, action: 0, category: 0, itemId: 1 },
emptyTables,
),
).toThrow(ProtocolError)
})
it('F8.B5: decodeItemBitstream throws ProtocolError when identified item references missing ItemStatCost entry', () => {
const bw = new BitWriter()
bw.writeBits(ITEM_FLAG_IDENTIFIED, 32)
bw.writeBits(101, 10)
bw.writeBits(3, 3)
bw.writeBits(100, 16)
bw.writeBits(100, 16)
bw.writeAscii('rin ')
bw.writeBits(0, 3)
bw.writeBits(10, 7)
bw.writeBits(2, 4)
bw.writeBit(0)
bw.writeBit(0)
bw.writeBits(123, 9)
expect(() =>
decodeItemBitstream(
bw.toUint8Array(),
{ opcode: 0x9c, action: 0, category: 0, itemId: 1 },
emptyTables,
),
).toThrow(ProtocolError)
})
})
// ─── F9: D2GS Session & Adapter Boundaries ─────────────────────────────────
describe('F9: D2GS Session & Adapter Boundaries', () => {
it('F9.B1: D2gsSession emits error when incoming stream contains corrupted frame bytes', () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const errors: Error[] = []
const session = new D2gsSession({
stream: clientStream,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
})
session.onError((e) => errors.push(e))
serverStream.write(new Uint8Array([0xfe]))
expect(errors).toHaveLength(1)
})
it('F9.B2: D2gsSession ignores sendRawPacket calls after session is closed', () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const received: Uint8Array[] = []
serverStream.onData((c) => received.push(c))
const session = new D2gsSession({
stream: clientStream,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
})
session.leaveAndClose()
const countAfterLeave = received.length
session.sendRawPacket(new Uint8Array([0x6b]))
expect(received.length).toBe(countAfterLeave)
})
it('F9.B3: D2gsSession stops heartbeat timer when stream closes with error', () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const clock = new FakeClock(1000)
const errors: Error[] = []
const session = new D2gsSession({
stream: clientStream,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
clock,
})
session.onError((e) => errors.push(e))
serverStream.write(new Uint8Array([0xaf, 0x01]))
serverStream.write(encodeD2gsCompressedFrame(new Uint8Array([0x02, 0x04])))
serverStream.close({ kind: 'error', error: new Error('reset') })
expect(session.state).toBe('closed')
expect(errors).toHaveLength(1)
const outBefore = session.stats.packetsOut
clock.advance(15000)
expect(session.stats.packetsOut).toBe(outBefore)
})
it('F9.B4: D2gsAdapter increments protocolErrors count when a packet fails to decode', () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const session = new D2gsSession({
stream: clientStream,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
})
const adapter = new D2gsAdapter({ session, tables: emptyTables })
serverStream.write(new Uint8Array([0xaf, 0x01]))
const bad9c = new Uint8Array(15)
bad9c[0] = 0x9c
bad9c[2] = 15
serverStream.write(encodeD2gsCompressedFrame(bad9c))
expect(adapter.stats.protocolErrors).toBe(1)
})
it('F9.B5: D2gsAdapter.send({ type: "Ping" }) routes through session.sendPing()', () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const sent: Uint8Array[] = []
serverStream.onData((c) => sent.push(c))
const session = new D2gsSession({
stream: clientStream,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
})
const adapter = new D2gsAdapter({ session, tables: emptyTables })
adapter.send({ type: 'Ping' })
expect(sent).toHaveLength(1)
expect(sent[0]?.[0]).toBe(0x6d)
expect(sent[0]?.byteLength).toBe(13)
})
})
// ─── F10: Full Online Flow (D2OnlineFlow) Boundaries ───────────────────────
describe('F10: Full Online Flow (D2OnlineFlow) Boundaries', () => {
it('F10.B1: D2OnlineFlow throws ProtocolError when calling listRealms or enterRealm before connectBnet', async () => {
const flow = createD2OnlineFlow({
bnetHost: '127.0.0.1',
resolver: { open: async () => createMemoryStreamPair()[0] },
})
await expect(flow.listRealms()).rejects.toThrow(ProtocolError)
await expect(flow.enterRealm()).rejects.toThrow(ProtocolError)
})
it('F10.B2: D2OnlineFlow throws ProtocolError when calling listCharacters or joinGame before enterRealm', async () => {
const flow = createD2OnlineFlow({
bnetHost: '127.0.0.1',
resolver: { open: async () => createMemoryStreamPair()[0] },
})
await expect(flow.listCharacters()).rejects.toThrow(ProtocolError)
await expect(flow.joinGame('test')).rejects.toThrow(ProtocolError)
})
it('F10.B3: D2OnlineFlow.login rejects with ProtocolError when SID_LOGONRESPONSE2 returns invalid password status', async () => {
const bncsFramer = new BncsFramer()
const flow = createD2OnlineFlow({
bnetHost: '127.0.0.1',
resolver: {
async open(): Promise<ByteStream> {
const [c, s] = createMemoryStreamPair()
let init = false
s.onData((chunk) => {
let d = chunk
if (!init && d[0] === 0x01) {
init = true
d = d.subarray(1)
}
for (const pkt of bncsFramer.push(d)) {
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
const w = new ByteWriter().u32(0).u32(1).u32(0).u64BigInt(0n).cstring('v').cstring('f')
s.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
s.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new ByteWriter().u32(0).cstring('').toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
s.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new ByteWriter().u32(2).toUint8Array()))
}
}
})
return c
},
},
})
await expect(flow.login('user', 'wrongpass')).rejects.toThrow(/SID_LOGONRESPONSE2 failed/)
})
it('F10.B4: D2OnlineFlow.joinGame rejects when called before selectCharacter()', async () => {
const bncsFramer = new BncsFramer()
const mcpFramer = new McpFramer()
const flow = createD2OnlineFlow({
bnetHost: '127.0.0.1',
resolver: {
async open(kind: EndpointKind): Promise<ByteStream> {
const [c, s] = createMemoryStreamPair()
let init = false
if (kind === 'bnet') {
s.onData((chunk) => {
let d = chunk
if (!init && d[0] === 0x01) {
init = true
d = d.subarray(1)
}
for (const pkt of bncsFramer.push(d)) {
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
s.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, new ByteWriter().u32(0).u32(1).u32(0).u64BigInt(0n).cstring('v').cstring('f').toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK || pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
s.write(encodeBncsFrame(pkt.id, new ByteWriter().u32(0).cstring('').toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_LOGONREALMEX) {
const w = new ByteWriter().u32(1).u32(0).bytes(new Uint8Array(8)).bytes(new Uint8Array([127, 0, 0, 1])).bytes(new Uint8Array([0x17, 0xe1, 0, 0])).bytes(new Uint8Array(48)).cstring('Hero')
s.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array()))
}
}
})
} else if (kind === 'realm') {
s.onData((chunk) => {
let d = chunk
if (!init && d[0] === 0x01) {
init = true
d = d.subarray(1)
}
for (const pkt of mcpFramer.push(d)) {
if (pkt.id === McpOpcode.MCP_STARTUP) {
s.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new ByteWriter().u32(0).toUint8Array()))
} else if (pkt.id === McpOpcode.MCP_CHARLIST2) {
s.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, new ByteWriter().u16(8).u32(0).u16(0).toUint8Array()))
}
}
})
}
return c
},
},
})
await flow.login('user', 'pass')
await flow.enterRealm('D2CS')
await expect(flow.joinGame('g1')).rejects.toThrow(/before selectCharacter/)
await flow.close()
})
it('F10.B5: D2OnlineFlow.joinGame rejects if D2GS stream closes during handshake', async () => {
const bncsFramer = new BncsFramer()
const mcpFramer = new McpFramer()
const flow = createD2OnlineFlow({
bnetHost: '127.0.0.1',
resolver: {
async open(kind: EndpointKind): Promise<ByteStream> {
const [c, s] = createMemoryStreamPair()
let init = false
if (kind === 'bnet') {
s.onData((chunk) => {
let d = chunk
if (!init && d[0] === 0x01) {
init = true
d = d.subarray(1)
}
for (const pkt of bncsFramer.push(d)) {
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
s.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, new ByteWriter().u32(0).u32(1).u32(0).u64BigInt(0n).cstring('v').cstring('f').toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK || pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
s.write(encodeBncsFrame(pkt.id, new ByteWriter().u32(0).cstring('').toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_LOGONREALMEX) {
const w = new ByteWriter().u32(1).u32(0).bytes(new Uint8Array(8)).bytes(new Uint8Array([127, 0, 0, 1])).bytes(new Uint8Array([0x17, 0xe1, 0, 0])).bytes(new Uint8Array(48)).cstring('Hero')
s.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array()))
}
}
})
} else if (kind === 'realm') {
s.onData((chunk) => {
let d = chunk
if (!init && d[0] === 0x01) {
init = true
d = d.subarray(1)
}
for (const pkt of mcpFramer.push(d)) {
if (pkt.id === McpOpcode.MCP_STARTUP || pkt.id === McpOpcode.MCP_CHARLOGON) {
s.write(encodeMcpFrame(pkt.id, new ByteWriter().u32(0).toUint8Array()))
} else if (pkt.id === McpOpcode.MCP_CHARLIST2) {
s.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, new ByteWriter().u16(8).u32(0).u16(0).toUint8Array()))
} else if (pkt.id === McpOpcode.MCP_JOINGAME) {
const reqId = new ByteReader(pkt.payload).u16()
s.write(encodeMcpFrame(McpOpcode.MCP_JOINGAME, new ByteWriter().u16(reqId).u16(1).u16(0).bytes(new Uint8Array([127, 0, 0, 1])).u32(1).u32(0).toUint8Array()))
}
}
})
} else if (kind === 'game') {
setTimeout(() => s.close(), 5)
}
return c
},
},
})
await flow.login('user', 'pass')
await flow.enterRealm('D2CS')
await flow.selectCharacter('BotSorc')
await expect(flow.joinGame('g1')).rejects.toThrow(/closed during handshake/)
await flow.close()
})
})
// ─── F11: ClientWorld State Store Boundaries ───────────────────────────────
describe('F11: ClientWorld State Store Boundaries', () => {
it('F11.B1: ClientWorld.apply(GameLogout) clears loading flag and preserves safe empty state on reset()', () => {
const world = new ClientWorld()
world.apply({ type: 'GameLoading' })
expect(world.loading).toBe(true)
world.apply({ type: 'GameLogout' })
expect(world.loading).toBe(false)
world.reset()
expect(world.units.size).toBe(0)
expect(world.selfId).toBe(0)
})
it('F11.B2: ClientWorld caps chatLog at 200 messages and soundQueue at 64 entries under flood', () => {
const world = new ClientWorld()
for (let i = 0; i < 250; i++) {
world.apply({ type: 'Chat', kind: 'say', text: `msg-${i}` })
}
expect(world.chatLog.length).toBe(200)
expect(world.chatLog[0]?.text).toBe('msg-50')
for (let i = 0; i < 100; i++) {
world.apply({ type: 'PlaySound', sound: i })
}
expect(world.soundQueue.length).toBe(64)
})
it('F11.B3: ClientWorld.tick ignores dead or stationary units and snaps units when within step distance', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 10, classId: 148, x: 100, y: 100, mode: 1 },
})
world.apply({
type: 'UnitMove',
unitType: 1,
id: 10,
mode: 'walk',
from: { x: 100, y: 100 },
to: { x: 101, y: 100 },
})
world.tick(1000)
expect(world.getUnit(1, 10)?.x).toBe(101)
})
it('F11.B4: ClientWorld removes party member when Party event has partyId === 0xFFFF (PlayerLeftGame)', () => {
const world = new ClientWorld()
world.apply({ type: 'Party', kind: 'roster', unitId: 5, charName: 'Peer', partyId: 1 })
expect(world.party.has(5)).toBe(true)
world.apply({ type: 'Party', kind: 'roster', unitId: 5, partyId: 0xffff })
expect(world.party.has(5)).toBe(false)
})
it('F11.B5: ClientWorld handles LifeMana with life=-1 / mana=-1 (from 0x96 WalkVerify) without clobbering existing HP/MP', () => {
const world = new ClientWorld()
world.apply({ type: 'LifeMana', life: 300, mana: 200, stamina: 100 })
world.apply({ type: 'LifeMana', life: -1, mana: -1, stamina: 90, at: { x: 5010, y: 5020 } })
expect(world.self.life).toBe(300)
expect(world.self.mana).toBe(200)
expect(world.self.stamina).toBe(90)
expect(world.self.x).toBe(5010)
})
})
// ─── F12: DRLG Map Generation & TileAtlas Boundaries ───────────────────────
describe('F12: DRLG Map Generation & TileAtlas Boundaries', () => {
it('F12.B1: MapService.getLevelView throws Error when called before loadAct()', async () => {
const fresh = await MapService.fromPackedDir('public/act-packs/tiles')
expect(() => fresh.getLevelView(1)).toThrow()
})
it('F12.B2: MapService.getLevelView throws Error when levelId is out of valid range', () => {
sharedMapService.loadAct({ act: 0, seed: 0x12345678, difficulty: 0 })
expect(() => sharedMapService.getLevelView(999)).toThrow()
})
it('F12.B3: getCollisionLocalSubTile and isWalkableLocalSubTile reject negative and beyond-grid coordinates', () => {
const view = sharedMapService.loadAct({ act: 0, seed: 0x12345678, difficulty: 0, areaId: 1 })!
expect(getCollisionLocalSubTile(view, -100, 0)).toBe(COLLIDE_MASK_INVALID)
expect(getCollisionLocalSubTile(view, 0, view.subHeight + 50)).toBe(COLLIDE_MASK_INVALID)
expect(isWalkableLocalSubTile(view, view.subWidth, view.subHeight)).toBe(false)
})
it('F12.B4: TileAtlas.getLibrary returns null for nonexistent DT1 path', () => {
const atlas = sharedMapService.getTileAtlas()!
expect(atlas.getLibrary('data\\global\\tiles\\nonexistent\\fake.dt1')).toBeNull()
})
it('F12.B5: TileAtlas.resolveCellTile returns null when dt1Path does not exist in catalog', () => {
const atlas = sharedMapService.getTileAtlas()!
expect(
atlas.resolveCellTile({
dt1Path: 'data\\global\\tiles\\nonexistent\\fake.dt1',
tileIndex: 0,
style: 0,
sequence: 0,
tileType: 0,
x: 0,
y: 0,
}),
).toBeNull()
})
})
// ─── F13: Pathfinding, LOS, Predictor & Interpolator Boundaries ────────────
describe('F13: Pathfinding, LOS, Predictor & Interpolator Boundaries', () => {
it('F13.B1: findPathLocal handles out-of-bounds start/goal or blocked grid gracefully', () => {
const blockedGrid = { subWidth: 5, subHeight: 5, walkable: new Uint8Array(25).fill(0) }
const res = findPathLocal(blockedGrid, 0, 0, 4, 4)
expect(res.reachedGoal).toBe(false)
})
it('F13.B2: findPathLocal prevents diagonal corner-cutting through pinch walls and stops at closest reachable sub-tile when boxed in', () => {
const subWidth = 5
const subHeight = 5
const walkable = new Uint8Array(subWidth * subHeight).fill(1)
// Surround walkable goal (3, 3) completely with walls (walkable = 0)
walkable[2 * subWidth + 2] = 0
walkable[2 * subWidth + 3] = 0
walkable[2 * subWidth + 4] = 0
walkable[3 * subWidth + 2] = 0
walkable[3 * subWidth + 4] = 0
walkable[4 * subWidth + 2] = 0
walkable[4 * subWidth + 3] = 0
walkable[4 * subWidth + 4] = 0
const res = findPathLocal({ subWidth, subHeight, walkable }, 0, 0, 3, 3)
expect(res.reachedGoal).toBe(false)
})
it('F13.B3: hasLineOfSightLocal returns false when wall blocks straight line or out of bounds', () => {
const subWidth = 5
const subHeight = 5
const walkable = new Uint8Array(subWidth * subHeight).fill(1)
walkable[2 * subWidth + 2] = 0
expect(hasLineOfSightLocal({ subWidth, subHeight, walkable }, 0, 0, 4, 4)).toBe(false)
expect(hasLineOfSightLocal({ subWidth, subHeight, walkable }, -1, 0, 2, 2)).toBe(false)
})
it('F13.B4: LocalMovementPredictor handles 0-tick steps and empty paths without NaN', () => {
const pred = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
pred.setPath([], false)
const s0 = pred.stepTicks(0)
expect(s0.moving).toBe(false)
expect(s0.x).toBe(100)
})
it('F13.B5: RemoteEntityInterpolator stopEntity snaps immediately to server stop coordinates', () => {
const interp = new RemoteEntityInterpolator()
interp.spawnOrSnap({ unitType: 1, unitId: 50, x: 100, y: 100 })
const stopped = interp.stopEntity({ unitType: 1, unitId: 50, x: 200, y: 200 })
expect(stopped.x).toBe(200)
expect(stopped.y).toBe(200)
expect(stopped.moving).toBe(false)
})
})
// ─── F14: Offline .d2s Unlock Tool Boundaries ──────────────────────────────
describe('F14: Offline .d2s Unlock Tool Boundaries', () => {
it('F14.B1: validateBotCharacterName rejects non-Bot names, path traversal, and invalid characters', () => {
expect(() => validateBotCharacterName('Taodao')).toThrow(/R6 Safety Violation/)
expect(() => validateBotCharacterName('../d2webbot')).toThrow(/R6 Safety Violation/)
expect(() => validateBotCharacterName('my_d2webbot')).toThrow(/R6 Safety Violation/)
})
it('F14.B2: unlockD2sBuffer rejects buffers shorter than minimum header size', () => {
expect(() => unlockD2sBuffer(new Uint8Array(100))).toThrow()
})
it('F14.B3: unlockD2sBuffer rejects buffers with invalid .d2s magic header', () => {
const bad = new Uint8Array(800)
expect(() => unlockD2sBuffer(bad)).toThrow()
})
it('F14.B4: unlockD2sBuffer rejects buffers with unsupported .d2s version != 96', () => {
const bad = createSyntheticD2sBuffer('d2webbot_badver')
const view = new DataView(bad.buffer)
view.setUint32(4, 92, true)
expect(() => unlockD2sBuffer(bad)).toThrow()
})
it('F14.B5: verifyD2sUnlocked detects corrupted checksum when a byte is tampered after unlock', () => {
const buf = createSyntheticD2sBuffer('d2webbot_tamper')
const unlocked = unlockD2sBuffer(buf)
unlocked[0x2b] = 99 // tamper character level without recomputing checksum
expect(verifyD2sUnlocked(unlocked).checksumValid).toBe(false)
})
})
// ─── F15: Client Settings Store Boundaries ─────────────────────────────────
describe('F15: Client Settings Store Boundaries', () => {
it('F15.B1: validateServerHost rejects empty strings and bare IPv4/IPv6 on HTTPS', () => {
expect(validateServerHost('', 'https:').valid).toBe(false)
expect(validateServerHost('34.187.177.211', 'https:').valid).toBe(false)
expect(validateServerHost('[::1]:6112', 'https:').valid).toBe(false)
})
it('F15.B2: validateServerHost rejects invalid ports and malformed host labels', () => {
expect(validateServerHost('www.laiseek.xyz:99999', 'https:').valid).toBe(false)
expect(validateServerHost('www.laiseek.xyz:0', 'https:').valid).toBe(false)
expect(validateServerHost('-badhost-.xyz', 'https:').valid).toBe(false)
})
it('F15.B3: parseClientSettingsJson falls back to DEFAULT_SERVER_HOST on malformed JSON or null', () => {
expect(parseClientSettingsJson('{corrupt json', { defaultLang: 'en' }).serverHost).toBe(DEFAULT_SERVER_HOST)
expect(parseClientSettingsJson(null, { defaultLang: 'en' }).serverHost).toBe(DEFAULT_SERVER_HOST)
})
it('F15.B4: sanitizeClientSettings clamps masterVolume to [0, 1] and replaces NaN with default 0.8', () => {
const s = sanitizeClientSettings({ masterVolume: 5.0 })
expect(s.masterVolume).toBe(1)
const sNeg = sanitizeClientSettings({ masterVolume: -2.0 })
expect(sNeg.masterVolume).toBe(0)
const sNan = sanitizeClientSettings({ masterVolume: Number.NaN })
expect(sNan.masterVolume).toBe(0.8)
})
it('F15.B5: SettingsStore survives StorageLike that throws QuotaExceededError on setItem', () => {
const brokenStorage: StorageLike = {
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError')
},
removeItem: () => {
throw new Error('SecurityError')
},
}
const store = new SettingsStore({ storage: brokenStorage, defaultLang: 'en', syncGlobalLang: false })
expect(() => store.set({ viewport: '1024x768' })).not.toThrow()
expect(store.get().viewport).toBe('1024x768')
expect(() => store.reset()).not.toThrow()
})
})
// ─── F16: Unified Top Toolbar Boundaries ───────────────────────────────────
describe('F16: Unified Top Toolbar Boundaries', () => {
it('F16.B1: Toolbar.submitServerHost rejects bare IP on HTTPS and sets diagnostic banner', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
pageProtocol: 'https:',
syncGlobalLang: false,
})
const toolbar = new Toolbar({ store, pageProtocol: 'https:' })
const res = toolbar.submitServerHost('34.187.177.211')
expect(res.valid).toBe(false)
expect(res.applied).toBe(false)
expect(toolbar.getSnapshot().diagnosticBannerText).toContain('www.laiseek.xyz')
toolbar.destroy()
})
it('F16.B2: Toolbar.submitServerHost does not apply host change when user cancels mid-session confirmation', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
pageProtocol: 'https:',
syncGlobalLang: false,
})
const toolbar = new Toolbar({
store,
pageProtocol: 'https:',
confirmReconnect: () => false,
})
toolbar.setConnectionState({ phase: 'd2gs', rttMs: 25 })
const res = toolbar.submitServerHost('eu.laiseek.xyz')
expect(res.valid).toBe(true)
expect(res.applied).toBe(false)
expect(store.get().serverHost).toBe('www.laiseek.xyz')
toolbar.destroy()
})
it('F16.B3: Toolbar.reportDiagnostic surfaces 5-per-IP quota_exceeded banner', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const toolbar = new Toolbar({ store })
toolbar.reportDiagnostic('quota_exceeded')
expect(toolbar.getSnapshot().diagnosticBannerText).toContain('5')
toolbar.destroy()
})
it('F16.B4: Toolbar.toggleInspector flips inspectorOpen boolean state cleanly', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const toolbar = new Toolbar({ store })
expect(toolbar.toggleInspector()).toBe(true)
expect(toolbar.toggleInspector()).toBe(false)
toolbar.destroy()
})
it('F16.B5: Toolbar.destroy unsubscribes from SettingsStore updates cleanly', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
let calls = 0
const toolbar = new Toolbar({
store,
callbacks: {
onViewportChange: () => {
calls++
},
},
})
toolbar.destroy()
store.set({ viewport: '1024x768' })
expect(calls).toBe(0)
})
})
// ─── F17: Dual Viewport & Scene Drawables Boundaries ───────────────────────
describe('F17: Dual Viewport & Scene Drawables Boundaries', () => {
it('F17.B1: getViewportProfile falls back to VIEWPORT_800x600 for unrecognized resolution strings', () => {
expect(getViewportProfile('invalid' as '800x600')).toEqual(VIEWPORT_800x600)
})
it('F17.B2: computeHudLayout handles 0, negative, and NaN CSS dimensions or DPR without dividing by zero', () => {
const layout = computeHudLayout(0, -100, Number.NaN)
expect(Number.isFinite(layout.uiScale)).toBe(true)
expect(layout.uiScale).toBeGreaterThan(0)
})
it('F17.B3: applyViewportProfileToDom updates canvas dimensions for both 800x600 and 1024x768', () => {
const stageEl = { style: { width: '', height: '' } }
const glCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
const hudCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
applyViewportProfileToDom('800x600', { stageEl, glCanvas, hudCanvas })
expect(glCanvas.width).toBe(800)
applyViewportProfileToDom('1024x768', { stageEl, glCanvas, hudCanvas })
expect(glCanvas.width).toBe(1024)
})
it('F17.B4: ClientWorldSceneSource handles empty unit list at (0,0)', () => {
const automap = new AutomapView({ initialMode: 'off' })
const levelView = automap.setContext(0x12345678, 0, 1)
const source = new ClientWorldSceneSource(
(): ClientSceneWorldAdapter => ({
tick: 0,
actId: 1,
levelId: 1,
playerSubX: 0,
playerSubY: 0,
units: [],
levelView,
}),
'800x600',
)
expect(source.getViewportProfile()).toEqual(VIEWPORT_800x600)
})
it('F17.B5: buildSceneDrawables sorts sortedDrawables deterministically and applies freeze tint on state 1', () => {
const units: RenderableUnitInput[] = [
{ kind: 'monster', id: 20, classId: 10, x: 100, y: 110, states: [1] },
{ kind: 'monster', id: 10, classId: 10, x: 100, y: 102 },
]
const drawables = buildSceneDrawables(units, { tick: 1 })
expect(drawables.sortedDrawables.filter((d) => d.drawableKind === 'unit').map((d) => d.unitId)).toEqual([10, 20])
expect(drawables.unitDrawables[0]?.tintColor).toEqual([90, 155, 255])
})
})
// ─── F18: Automap Overlay Boundaries ───────────────────────────────────────
describe('F18: Automap Overlay Boundaries', () => {
it('F18.B1: AutomapView.onServerRemoveRoom on unrevealed room is a safe no-op', () => {
const automap = new AutomapView({ initialMode: 'off' })
automap.setContext(0xdeadbeef, 0, 1)
expect(() => automap.onServerRemoveRoom(99999, 99999, 1)).not.toThrow()
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
})
it('F18.B2: AutomapView.onServerAddRoom deduplicates repeated reveals of the same room', () => {
const automap = new AutomapView({ initialMode: 'off' })
const view = automap.setContext(0xdeadbeef, 0, 1)
const r0 = view.rooms[0]!
automap.onServerAddRoom(r0.tileX, r0.tileY, 1)
automap.onServerAddRoom(r0.tileX, r0.tileY, 1)
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
})
it('F18.B3: AutomapView switches from act -> off mode and hides non-server rooms', () => {
const automap = new AutomapView({
initialMode: 'act',
actRevealLevelIds: { 1: [1, 2], 2: [40], 3: [75], 4: [103], 5: [109] },
})
automap.setContext(0xdeadbeef, 0, 1)
expect(automap.buildSnapshot().visibleAreaIds).toEqual([1, 2])
automap.setMode('off')
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
})
it('F18.B4: AutomapView.setContext resets server-revealed rooms when seed or area changes', () => {
const automap = new AutomapView({ initialMode: 'off' })
const v1 = automap.setContext(0x11111111, 0, 1)
automap.onServerAddRoom(v1.rooms[0]!.tileX, v1.rooms[0]!.tileY, 1)
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
automap.setContext(0x22222222, 0, 1)
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(0)
})
it('F18.B5: AutomapView handles Act 4 (103) and Act 5 (109) town contexts cleanly', () => {
const automap = new AutomapView({ initialMode: 'level' })
const v4 = automap.setContext(0xdeadbeef, 0, 103)
expect(v4.id).toBe(103)
const v5 = automap.setContext(0xdeadbeef, 0, 109)
expect(v5.id).toBe(109)
})
})
// ─── F19: Spatial Audio & OPFS/HTTP-Range MPQ Boundaries ───────────────────
describe('F19: Spatial Audio & OPFS/HTTP-Range MPQ Boundaries', () => {
it('F19.B1: computeSpatialAudioMetrics culls sounds beyond maxAudibleSubTiles with gain=0', () => {
const far = computeSpatialAudioMetrics(200, 200, 100, 100)
expect(far.culled).toBe(true)
expect(far.gain).toBe(0)
})
it('F19.B2: resolveSoundEnvironmentForLevel falls back safely when given out-of-range levelId', () => {
expect(resolveSoundEnvironmentForLevel(999).actId).toBeGreaterThanOrEqual(1)
expect(resolveSoundEnvironmentForLevel(-5).actId).toBeGreaterThanOrEqual(1)
})
it('F19.B3: SoundService returns null on playSoundById when muted or spatially culled', async () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const backend: AudioBackendLike = {
async init() {},
setMuted() {},
async playBGM() {},
playSFX() {},
playButtonClick() {},
playFootstep() {},
playPortalEnter() {},
playWaypointActivate() {},
}
const svc = new SoundService({ store, backend })
await svc.init()
svc.setMuted(true)
expect(svc.playSoundById(47)).toBeNull()
svc.setMuted(false)
expect(
svc.playSoundById(47, {
sourceSubX: 300,
sourceSubY: 300,
listenerSubX: 100,
listenerSubY: 100,
}),
).toBeNull()
svc.destroy()
})
it('F19.B4: HttpRangeMpqSource rejects negative offset/length and throws on HTTP 404 failure', async () => {
const source = new HttpRangeMpqSource({
url: '/diablo2/data/missing.mpq',
label: 'missing.mpq',
fetchFn: async () => ({
ok: false,
status: 404,
headers: { get: () => null },
arrayBuffer: async () => new ArrayBuffer(0),
}),
})
await expect(source.read(-1, 10)).rejects.toThrow()
await expect(source.read(0, 10)).rejects.toThrow(/404/)
expect(await source.read(0, 0)).toEqual(new Uint8Array(0))
})
it('F19.B5: InMemoryChunkCache evicts oldest entry when maxEntries (16) is exceeded', async () => {
const lru = new InMemoryChunkCache(16)
for (let i = 0; i < 16; i++) {
await lru.putChunk(`k${i}`, new Uint8Array([i]))
}
await lru.putChunk('k16', new Uint8Array([16]))
expect(await lru.getChunk('k0')).toBeNull()
expect(await lru.getChunk('k16')).toEqual(new Uint8Array([16]))
})
})
// ─── F20: Outbound Rate Limiter & Packet Inspector Boundaries ──────────────
describe('F20: Outbound Rate Limiter & Packet Inspector Boundaries', () => {
it('F20.B1: OutboundRateLimiter throttles rapid skill casts within skillMinIntervalMs', () => {
const limiter = new OutboundRateLimiter({ skillMinIntervalMs: 200 })
const cast = { type: 'CastRightSkillOnLocation', x: asSubTile(100), y: asSubTile(100) } as const
expect(limiter.tryAcquire(cast, 1000).allowed).toBe(true)
const second = limiter.tryAcquire(cast, 1050)
expect(second.allowed).toBe(false)
expect(second.reason).toBe('category_cooldown')
})
it('F20.B2: OutboundRateLimiter exhausts token bucket under burst across distinct categories', () => {
const limiter = new OutboundRateLimiter({
moveMinIntervalMs: 0,
skillMinIntervalMs: 0,
burstCapacity: 2,
refillPerSec: 1,
})
expect(limiter.tryAcquire({ type: 'RunToLocation', x: asSubTile(10), y: asSubTile(20) }, 1000).allowed).toBe(true)
expect(limiter.tryAcquire({ type: 'CastRightSkillOnLocation', x: asSubTile(10), y: asSubTile(20) }, 1000).allowed).toBe(true)
const third = limiter.tryAcquire({ type: 'Interact', target: { unitType: 1, id: 1 } }, 1000)
expect(third.allowed).toBe(false)
expect(third.reason).toBe('token_bucket_empty')
})
it('F20.B3: PacketInspector respects maxRows ring buffer limit and stepReplay returns null when empty', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap()
const inspector = new PacketInspector({ store, tap, maxRows: 2 })
tap.record({ t: 1, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) })
tap.record({ t: 2, conn: 'game', dir: 's2c', packetId: 0x02, raw: new Uint8Array([0x02]) })
tap.record({ t: 3, conn: 'game', dir: 's2c', packetId: 0x04, raw: new Uint8Array([0x04]) })
expect(inspector.getAllRows()).toHaveLength(2)
expect(inspector.getAllRows()[0]?.opcode).toBe(0x02)
expect(inspector.stepReplay()).toBeNull()
inspector.destroy()
})
it('F20.B4: formatHexDump handles empty 0-byte buffer and multi-line (>16 byte) buffers', () => {
expect(formatHexDump(new Uint8Array(0))).toBeDefined()
const dump = formatHexDump(new Uint8Array(32).fill(0x41))
expect(dump.split('\n').length).toBeGreaterThanOrEqual(2)
})
it('F20.B5: parseD2CapJsonl ignores blank and comment lines and throws on malformed JSON line', () => {
const validWithComments = '# comment\n\n{"t":1,"conn":"game","dir":"s2c","hex":"04"}\n'
const parsed = parseD2CapJsonl(validWithComments)
expect(parsed.records).toHaveLength(1)
expect(() => parseD2CapJsonl('{bad json')).toThrow()
})
})
})