diablo2-web/tests/e2e-netproto/tier5-adversarial-coverage....

2580 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Tier 5 — White-Box Adversarial & Edge-Case Coverage Suite (`tests/e2e-netproto/tier5-adversarial-coverage.test.ts`).
*
* Authored by the Tier 5 Challenger (`challenger_m6_1`) following a systematic white-box audit
* across Features F1–F20 (`src/netproto/**`, `src/client/**`, `src/client/ui/hud-manager.ts`,
* `src/client/frontend/flow-state.ts`, and `scripts/{extract-d2net-tables,d2s-unlock,netproto-replay}.ts`).
*
* Every test is prefixed with `adv_` per the `test_coverage_audit` playbook and executes
* hermetically in-memory with zero live network calls.
*/
import { describe, expect, it } from 'vitest'
// ─── Core & Wire Primitives (F1) ────────────────────────────────────────────
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 { ClosedError, ProtocolError } from '../../src/netproto/core/errors.ts'
import {
InMemoryPacketTap,
formatD2CapEntry,
formatD2CapHeader,
parseD2CapJsonl,
} from '../../src/netproto/core/packet-tap.ts'
import { createBrowserTextCodec } from '../../src/netproto/core/text-codec.ts'
// ─── Crypto & BNCS / MCP (F2, F3, F4) ───────────────────────────────────────
import { BncsFramer } from '../../src/netproto/bncs/framing.ts'
import {
decodeBncsS2cPacket,
encodeBncsAuthCheck,
encodeBncsAuthInfo,
encodeBncsChatCommand,
encodeBncsCreateAccount2,
encodeBncsEnterChat,
encodeBncsGetChannelList,
encodeBncsJoinChannel,
encodeBncsLogonRealmEx,
encodeBncsLogonResponse2,
encodeBncsNull,
encodeBncsPing,
encodeBncsQueryRealms2,
} from '../../src/netproto/bncs/packets.ts'
import { BncsSession } from '../../src/netproto/bncs/session.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 { McpFramer } from '../../src/netproto/mcp/framing.ts'
import {
decodeMcpS2cPacket,
encodeMcpCharCreate,
encodeMcpCharDelete,
encodeMcpCharList2,
encodeMcpCharLogon,
encodeMcpCharUpgrade,
encodeMcpCreateGame,
encodeMcpGameInfo,
encodeMcpGameList,
encodeMcpJoinGame,
encodeMcpMotd,
encodeMcpStartup,
} from '../../src/netproto/mcp/packets.ts'
import { McpSession } from '../../src/netproto/mcp/session.ts'
// ─── D2GS Framing, Huffman, Tables, Packets, Items (F5, F6, F7, F8) ─────────
import {
D2gsHuffmanCodec,
encodeD2gsCompressedFrame,
parseD2gsBlockHeader,
unpackCustomHuffmanLengths,
wrapD2gsCompressedBlock,
} from '../../src/netproto/d2gs/compression.ts'
import { D2gsS2cFramer, resolveS2cPacketSize } from '../../src/netproto/d2gs/framing-s2c.ts'
import {
decodeD2gsItemPacket,
decodeItemBitstream,
encodeD2gsItemPacket,
} from '../../src/netproto/d2gs/items/item-bitstream.ts'
import {
ITEM_FLAG_COMPACT,
ITEM_FLAG_ETHEREAL,
ITEM_FLAG_IDENTIFIED,
ITEM_FLAG_ISEAR,
ITEM_FLAG_PERSONALIZED,
ITEM_FLAG_RUNEWORD,
ITEM_FLAG_SOCKETED,
} from '../../src/netproto/d2gs/items/item-types.ts'
import { decodeD2gsS2cPacket, encodeD2gsClientCommand } from '../../src/netproto/d2gs/registry.ts'
import { FOG_DLL_SHA1, HUFFMAN_BIT_LENGTHS, HUFFMAN_BIT_MASKS, HUFFMAN_CODES } from '../../src/netproto/d2gs/tables/huffman.ts'
import {
C2S_PACKET_SIZES,
D2CLIENT_DLL_SHA1,
D2CLIENT_S2C_HANDLERS,
D2NET_DLL_SHA1,
S2C_PACKET_SIZES,
S2C_VARLEN_SWITCH_VAS,
} from '../../src/netproto/d2gs/tables/s2c-sizes.ts'
import {
asSubTile,
asUnitId,
createMemoryStreamPair,
type ClientCommand,
type GameServerAdapter,
type ItemBaseMeta,
type ItemData,
type ItemDataTables,
type ItemStatCostMeta,
type ServerEvent,
} from '../../src/netproto/index.ts'
// ─── Client World, Map, Automap, Motion, HUD, Input, Session (F9–F19) ───────
import {
SoundService,
computeSpatialAudioMetrics,
resolveSoundEnvironmentForLevel,
type AudioBackendLike,
} from '../../src/client/audio/sound-service.ts'
import { AutomapView, getActForArea } from '../../src/client/automap/automap-view.ts'
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
import {
findPathLocal,
findPathWorld,
hasLineOfSightLocal,
isCellWalkableLocal,
type SubTileWalkabilityGrid,
} from '../../src/client/motion/pathfind.ts'
import {
D2_BASE_RUN_SUBTILES_PER_SEC,
D2_BASE_WALK_SUBTILES_PER_SEC,
LocalMovementPredictor,
deltaToDir16,
} from '../../src/client/motion/predictor.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import {
createDefaultClientSettings,
deriveRealmWsEndpoints,
validateServerHost,
} from '../../src/client/settings/client-settings.ts'
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
import { HudModel, itemDataToUiInventoryItem } from '../../src/client/ui-model/hud-model.ts'
import { ClientWorldSceneSource } from '../../src/client/view/scene-source.ts'
import { getViewportProfile } from '../../src/client/view/viewport-profile.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { getCanonicalItemDataTables } from '../../src/client/world/item-tables-provider.ts'
import {
clickBattleNetButton,
confirmCharacterSelect,
createInitialFlowState,
hoverCampfireClass,
isValidCharacterName,
selectCampfireClass,
stepFlowTick,
submitBattleNetLogin,
submitCharacterCreate,
submitCreateGame,
toggleChatGem,
} from '../../src/client/frontend/flow-state.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
// ─── Scripts (F6, F20) ──────────────────────────────────────────────────────
import {
D2S_MAGIC,
D2S_QUEST_OFFSET,
D2S_VERSION_113C,
D2S_WAYPOINT_OFFSET,
unlockD2sBuffer,
validateBotCharacterName,
verifyD2sUnlocked,
writeD2sChecksum,
} from '../../tools/d2s-unlock.ts'
import { replayD2CapText } from '../../tools/netproto-replay.ts'
// ─── Helpers ────────────────────────────────────────────────────────────────
function createDeterministicRng(seed = 0x1337c0de): () => number {
let s = seed >>> 0
return () => {
s = (s + 0x6d2b79f5) >>> 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 0x100000000
}
}
function createMemoryStorageLike(): StorageLike {
const map = new Map<string, string>()
return {
getItem(key: string): string | null {
return map.get(key) ?? null
},
setItem(key: string, value: string): void {
map.set(key, value)
},
removeItem(key: string): void {
map.delete(key)
},
}
}
function buildAdversarialItemTables(): ItemDataTables {
const base = getCanonicalItemDataTables()
const itemStatCost = new Map<number, ItemStatCostMeta>(base.itemStatCost)
// Override/ensure deterministic adversarial test entries
const customStats: ItemStatCostMeta[] = [
{
id: 0,
name: 'strength',
saveBits: 8,
saveAdd: 32,
saveParamBits: 0,
sendBits: 8,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 17,
name: 'item_maxdamage_percent',
saveBits: 9,
saveAdd: 0,
saveParamBits: 0,
sendBits: 9,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 18,
name: 'item_mindamage_percent',
saveBits: 9,
saveAdd: 0,
saveParamBits: 0,
sendBits: 9,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 31,
name: 'armorclass',
saveBits: 11,
saveAdd: 10,
saveParamBits: 0,
sendBits: 11,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 36,
name: 'damageresist',
saveBits: 8,
saveAdd: 100,
saveParamBits: 0,
sendBits: 8,
sendParamBits: 0,
signed: true,
valShift: 0,
encode: 0,
},
{
id: 48,
name: 'firemindam',
saveBits: 8,
saveAdd: 0,
saveParamBits: 0,
sendBits: 8,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 49,
name: 'firemaxdam',
saveBits: 8,
saveAdd: 0,
saveParamBits: 0,
sendBits: 8,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 57,
name: 'poisonmindam',
saveBits: 10,
saveAdd: 0,
saveParamBits: 0,
sendBits: 10,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 58,
name: 'poisonmaxdam',
saveBits: 10,
saveAdd: 0,
saveParamBits: 0,
sendBits: 10,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 59,
name: 'poisonlength',
saveBits: 9,
saveAdd: 0,
saveParamBits: 0,
sendBits: 9,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 72,
name: 'durability',
saveBits: 9,
saveAdd: 0,
saveParamBits: 0,
sendBits: 9,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 73,
name: 'maxdurability',
saveBits: 8,
saveAdd: 0,
saveParamBits: 0,
sendBits: 8,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 107,
name: 'item_singleskill',
saveBits: 5,
saveAdd: 0,
saveParamBits: 16,
sendBits: 5,
sendParamBits: 16,
signed: false,
valShift: 0,
encode: 0,
},
{
id: 194,
name: 'item_numsockets',
saveBits: 4,
saveAdd: 0,
saveParamBits: 0,
sendBits: 4,
sendParamBits: 0,
signed: false,
valShift: 0,
encode: 0,
},
]
for (const meta of customStats) {
itemStatCost.set(meta.id, meta)
}
const lookupStat = (statId: number): ItemStatCostMeta | undefined => itemStatCost.get(statId)
const lookupBase = (code: string): ItemBaseMeta | undefined => base.getItemBaseMeta?.(code)
return {
itemStatCost,
armor: base.armor,
weapons: base.weapons,
misc: base.misc,
getStatMeta: lookupStat,
getStatCost: lookupStat,
getItemBaseMeta: lookupBase,
getItemMeta: lookupBase,
}
}
function makeSyntheticItemData(overrides: Partial<ItemData> & { id: number; code: string }): ItemData {
return {
itemId: overrides.itemId ?? overrides.id,
opcode: overrides.opcode ?? 0x9c,
action: overrides.action ?? 0x00,
category: overrides.category ?? 0,
flags: overrides.flags ?? ITEM_FLAG_IDENTIFIED,
rawFlags: overrides.rawFlags ?? ITEM_FLAG_IDENTIFIED,
version: overrides.version ?? 101,
location: overrides.location ?? 0,
container: overrides.container ?? 'inventory',
bodyLoc: overrides.bodyLoc ?? 0,
equippedSlot: overrides.equippedSlot ?? (overrides.bodyLoc ?? 0),
gridX: overrides.gridX ?? (overrides.x ?? 0),
gridY: overrides.gridY ?? (overrides.y ?? 0),
storePage: overrides.storePage ?? 0,
storagePage: overrides.storagePage ?? 0,
worldX: overrides.worldX ?? (overrides.x ?? 0),
worldY: overrides.worldY ?? (overrides.y ?? 0),
x: overrides.x ?? 0,
y: overrides.y ?? 0,
rawCode: overrides.rawCode ?? overrides.code.padEnd(4, ' '),
compact: overrides.compact ?? false,
simple: overrides.simple ?? false,
identified: overrides.identified ?? true,
socketed: overrides.socketed ?? false,
ethereal: overrides.ethereal ?? false,
runeword: overrides.runeword ?? false,
personalized: overrides.personalized ?? false,
isEar: overrides.isEar ?? false,
newItem: overrides.newItem ?? false,
starterItem: overrides.starterItem ?? false,
broken: overrides.broken ?? false,
inSocket: overrides.inSocket ?? false,
equipped: overrides.equipped ?? (overrides.container === 'equipment'),
socketedChildCount: overrides.socketedChildCount ?? 0,
usedSockets: overrides.usedSockets ?? 0,
totalSockets: overrides.totalSockets ?? 0,
guid: overrides.guid ?? 0x11223344,
ilvl: overrides.ilvl ?? 85,
quality: overrides.quality ?? 7,
invWidth: overrides.invWidth ?? 1,
invHeight: overrides.invHeight ?? 1,
stats: overrides.stats ?? [],
setBonusStats: overrides.setBonusStats ?? [],
runewordStats: overrides.runewordStats ?? [],
...overrides,
}
}
// ============================================================================
// SECTION 1: F1–F4 Wire Primitives, Crypto, BNCS & MCP Adversarial Edge Cases
// ============================================================================
describe('Tier 5 Adversarial Coverage — F1–F4 Wire Primitives, Crypto, BNCS & MCP', () => {
it('adv_01_bitreader_bitwriter_cross_byte_32bit_and_exhaustion_guards', () => {
const rng = createDeterministicRng(0xbeef0001)
const writer = new BitWriter()
const specs: Array<{ width: number; signed: boolean; value: number }> = []
for (let i = 0; i < 64; i++) {
const rawWidth = (Math.floor(rng() * 32) % 32) + 1 // 1..32 bits
const signed = i % 3 === 0 && rawWidth >= 2 && rawWidth !== 31
const width = rawWidth
let value: number
if (width === 32) {
value = signed ? (-0x7fffffff + i) | 0 : (0xf0000000 + i) >>> 0
} else if (signed) {
const min = -(1 << (width - 1))
const max = (1 << (width - 1)) - 1
value = min + (Math.floor(rng() * (max - min + 1)) % (max - min + 1))
} else {
const max = width === 31 ? 0x7fffffff : (1 << width) - 1
value = Math.floor(rng() * (max + 1)) & max
}
specs.push({ width, signed, value })
if (signed) {
writer.writeSignedBits(value, width)
} else {
writer.writeBits(value, width)
}
}
const bytes = writer.toUint8Array()
const reader = new BitReader(bytes)
for (const s of specs) {
if (s.width === 32 && !s.signed) {
expect(reader.readBits(32) >>> 0).toBe(s.value >>> 0)
} else if (s.signed) {
expect(reader.readSignedBits(s.width)).toBe(s.value)
} else {
expect(reader.readBits(s.width)).toBe(s.value)
}
}
// Invalid bit widths (<0 or >32) and read past EOF must throw ProtocolError
expect(() => reader.readBits(-1)).toThrow(ProtocolError)
expect(() => reader.readBits(33)).toThrow(ProtocolError)
expect(() => reader.readBits(64)).toThrow(ProtocolError)
})
it('adv_02_bytereader_gbk_multibyte_truncation_and_missing_null_terminator', () => {
const utf8Codec = createBrowserTextCodec('utf-8')
const gbkCodec = createBrowserTextCodec('gbk')
// 1. UTF-8 round-trip with cstring + fixedCString truncation
const writer = new ByteWriter()
writer.cstring('暗黑破坏神II: 毁灭之王 (LoD 1.13c)', 'utf-8')
writer.fixedCString('亚马逊Amazon_OverflowTest', 12, 'utf-8')
const buf = writer.toUint8Array()
const reader = new ByteReader(buf)
expect(reader.cstring('utf-8')).toBe('暗黑破坏神II: 毁灭之王 (LoD 1.13c)')
const fixed = reader.fixedString(12, 'utf-8')
expect(fixed.startsWith('亚马逊')).toBe(true)
expect(utf8Codec.encode(fixed).length).toBeLessThanOrEqual(11)
// 2. Authentic GBK wire bytes ("暗黑II" = b0 b5 ba da 49 49) decoded via ByteReader('gbk')
const gbkWire = new Uint8Array([0xb0, 0xb5, 0xba, 0xda, 0x49, 0x49, 0x00])
const gbkReader = new ByteReader(gbkWire)
expect(gbkReader.cstring(gbkCodec)).toBe('暗黑II')
// 3. Untested adversarial edge: C-string without NUL byte before buffer end -> ProtocolError
const unterminated = new Uint8Array([0x41, 0x42, 0x43, 0xb0, 0xa1])
const badReader = new ByteReader(unterminated)
expect(() => badReader.cstring('gbk')).toThrow(ProtocolError)
expect(() => badReader.u32()).toThrow()
})
it('adv_03_xsha1_cdkey_bncs_all_11_and_mcp_all_9_packets_and_session_close', async () => {
// 1. XSHA-1 multi-block (>64 bytes), doubleHash, and CD-Key decode + hash determinism
const longInput = new Uint8Array(195)
for (let i = 0; i < longInput.length; i++) longInput[i] = (i * 37 + 11) & 0xff
const h1 = xsha1(longInput)
const h2 = xsha1(longInput)
expect(h1).toEqual(h2)
expect(xsha1Ascii('AdvPass!2026').length).toBe(20)
const pwHash = hashBnetPassword('AdvPass!2026')
expect(pwHash.length).toBe(20)
const dblHash = doubleHash(0x12345678, 0x9abcdef0, pwHash)
expect(dblHash.length).toBe(20)
expect(hashRealmPassword(0x12345678, 0x9abcdef0)).toEqual(
doubleHash(0x12345678, 0x9abcdef0, hashBnetPassword('password')),
)
const synthPair = createSyntheticCdKeyPair(1)
const key1 = hashD2CdKey(0x11112222, 0x33334444, synthPair.classic)
const key2 = hashD2CdKey(0x11112222, 0x33334444, synthPair.expansion)
expect(key1.hashedKeyData.length).toBe(20)
expect(key2.hashedKeyData.length).toBe(20)
const decodedKey = decodeD2CdKey('F898947F27262B67')
const hashedKey = hashD2CdKey(0x11112222, 0x33334444, decodedKey)
expect(hashedKey.hashedKeyData.length).toBe(20)
const rev = getCheckRevision113c()
expect(rev.exeVersion).toBe(D2XP_113C_VERSION_CHECK.exeVersion)
// 2. All 12 BNCS C->S encoders produce valid 0xFF-framed packets
const bncsBuilt = [
encodeBncsNull(),
encodeBncsPing(0xdeadbeef),
encodeBncsAuthInfo(),
encodeBncsAuthCheck({
clientToken: 1,
exeVersion: rev.exeVersion,
exeHash: rev.exeHash,
keys: [key1, key2],
exeInfo: rev.exeInfo,
ownerName: 'Tester',
}),
encodeBncsCreateAccount2('adv_user', pwHash),
encodeBncsLogonResponse2(1, 2, dblHash, 'adv_user'),
encodeBncsQueryRealms2(),
encodeBncsLogonRealmEx(1, dblHash, 'D2CS'),
encodeBncsEnterChat('HeroSorc', 'D2CS,HeroSorc'),
encodeBncsGetChannelList(),
encodeBncsJoinChannel('Diablo II', 2),
encodeBncsChatCommand('/whois HeroSorc'),
]
expect(bncsBuilt).toHaveLength(12)
for (const pkt of bncsBuilt) {
expect(pkt[0]).toBe(0xff)
const len = pkt[2]! | (pkt[3]! << 8)
expect(len).toBe(pkt.length)
}
// 3. Malformed BNCS header magic (!== 0xFF) & length (<4) must throw ProtocolError in BncsFramer / decodeBncsS2cPacket
const bncsFramer = new BncsFramer()
expect(() => bncsFramer.push(new Uint8Array([0xfe, 0x25, 0x08, 0x00, 0, 0, 0, 0]))).toThrow(ProtocolError)
expect(() => bncsFramer.push(new Uint8Array([0xff, 0x00, 0x02, 0x00]))).toThrow(ProtocolError)
expect(() =>
decodeBncsS2cPacket({
id: 0x25,
payload: new Uint8Array([1, 2]),
raw: new Uint8Array([0xff, 0x25, 0x06, 0x00, 1, 2]),
}),
).toThrow(ProtocolError)
// 4. All 9 MCP C->S encoders & odd-count (9 characters) MCP_CHARLIST2 roster decoding
const mcpToken = {
mcpCookie: 1,
mcpStatus: 0,
mcpIp: '101.37.117.183',
mcpPort: 6113,
mcpChunk1: new Uint8Array(8),
mcpChunk2: new Uint8Array(48),
uniqueName: 'adv_user',
}
const mcpBuilt = [
encodeMcpStartup(mcpToken),
encodeMcpCharCreate('AdvSorc', 1, 0x0028),
encodeMcpCharLogon('AdvSorc'),
encodeMcpCharDelete('AdvSorc'),
encodeMcpCharUpgrade('AdvSorc'),
encodeMcpCharList2(8),
encodeMcpCreateGame({
requestId: 1,
difficulty: 0,
maxPlayers: 8,
gameName: 'g1',
gamePassword: '',
gameDescription: '',
}),
encodeMcpJoinGame(2, 'g1', ''),
encodeMcpGameList(3, ''),
encodeMcpGameInfo(4, 'g1'),
encodeMcpMotd(),
]
expect(mcpBuilt.length).toBeGreaterThanOrEqual(9)
// Craft MCP_CHARLIST2 (0x19) payload with 9 characters -> verify decoder extracts all 9 cleanly
const w = new ByteWriter()
w.u16(8) // requested
w.u32(9) // total on account
w.u16(9) // returned count = 9
for (let i = 0; i < 9; i++) {
w.u32(1735689600 + i) // expiresAt
w.cstring(`Hero_${i}`, 'gbk')
const statBytes = new Uint8Array(33).fill(0xff) // non-zero D2 statstring bytes before trailing NUL
statBytes[0] = 0x84
statBytes[1] = 0x80
statBytes[13] = (i % 7) + 1 // classId + 1
statBytes[25] = 10 + i // level
statBytes[26] = 0x28 // expansion + ladder
statBytes[27] = 0x80 // act/diff
w.bytes(statBytes)
w.u8(0) // NUL terminator for stat string
}
const mcpPayload = w.toUint8Array()
const parsedMcp = decodeMcpS2cPacket({ id: 0x19, payload: mcpPayload, raw: mcpPayload })
expect(parsedMcp.type).toBe('MCP_CHARLIST2')
if (parsedMcp.type === 'MCP_CHARLIST2') {
expect(parsedMcp.chars).toHaveLength(9)
expect(parsedMcp.chars[8]!.name).toBe('Hero_8')
expect(parsedMcp.chars[8]!.level).toBe(18)
}
// Malformed McpFramer length < 3 must throw ProtocolError
const mcpFramer = new McpFramer()
expect(() => mcpFramer.push(new Uint8Array([0x02, 0x00, 0x01]))).toThrow(ProtocolError)
// 5. BncsSession & McpSession mid-handshake close propagation
const [bncsClient, bncsServer] = createMemoryStreamPair()
const bncsSession = new BncsSession(bncsClient)
const loginPromise = bncsSession.handshake()
bncsServer.close({ kind: 'remote' })
await expect(loginPromise).rejects.toThrow()
const [mcpClient, mcpServer] = createMemoryStreamPair()
const mcpSession = new McpSession(mcpClient)
const mcpPromise = mcpSession.startup(mcpToken)
mcpServer.close({ kind: 'remote' })
await expect(mcpPromise).rejects.toThrow()
})
})
// ============================================================================
// SECTION 2: F5–F8 D2GS Compression, S2C Framing, Tables, Opcodes & Items
// ============================================================================
describe('Tier 5 Adversarial Coverage — F5–F8 D2GS Framing, Huffman, Tables & Item Bitstream', () => {
it('adv_04_d2gs_block_header_boundary_240_vs_241_and_max_frame_size', () => {
// Small payload where totalFrameSize < 0xF0 (1-byte header)
const smallActual = new Uint8Array([0x26, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0x41, 0x00, 0x48, 0x69, 0x00])
const smallFrame = encodeD2gsCompressedFrame(smallActual)
const smallHdr = parseD2gsBlockHeader(smallFrame, 0)
expect(smallHdr).not.toBeNull()
expect(smallHdr!.headerSize).toBe(1)
expect(smallHdr!.totalFrameSize).toBeLessThan(0xf0)
// Large payload where compressed size >= 239 -> forces 2-byte header (b0 >= 0xF0)
const twelveAssigns = new Uint8Array(16 * 26) // 416 bytes
for (let i = 0; i < 16; i++) {
const off = i * 26
twelveAssigns[off] = 0x59 // AssignPlayer (26 bytes)
twelveAssigns[off + 1] = i + 1
twelveAssigns[off + 5] = i % 7
twelveAssigns[off + 6] = 0x50 + (i % 26)
for (let k = 0; k < 14; k++) {
twelveAssigns[off + 7 + k] = 0x61 + ((i * 7 + k * 13) % 26)
}
twelveAssigns[off + 21] = 0
twelveAssigns[off + 22] = (100 + i * 17) & 0xff
twelveAssigns[off + 24] = (200 + i * 31) & 0xff
}
const largeFrame = encodeD2gsCompressedFrame(twelveAssigns)
const largeHdr = parseD2gsBlockHeader(largeFrame, 0)
expect(largeHdr).not.toBeNull()
expect(largeHdr!.headerSize).toBe(2)
expect(largeHdr!.totalFrameSize).toBeGreaterThanOrEqual(0xf0)
// Verify D2gsS2cFramer decodes both 1-byte and 2-byte header frames back to exact packets
const framer = new D2gsS2cFramer(true)
const outSmall = framer.push(smallFrame)
expect(outSmall).toHaveLength(1)
expect(outSmall[0]!.packetId).toBe(0x26)
expect(outSmall[0]!.bytes).toEqual(smallActual)
const outLarge = framer.push(largeFrame)
expect(outLarge).toHaveLength(16)
for (let i = 0; i < 16; i++) {
expect(outLarge[i]!.packetId).toBe(0x59)
expect(outLarge[i]!.bytes).toEqual(twelveAssigns.subarray(i * 26, (i + 1) * 26))
}
})
it('adv_05_d2gs_framer_multi_chunk_arbitrary_tcp_splits_1_to_7_bytes', () => {
// Construct compressed frames containing a mix of fixed and variable S->C packets
const p1 = new Uint8Array([0x03, 0x00, 0x78, 0x56, 0x34, 0x12, 0x01, 0x00, 0xef, 0xbe, 0xad, 0xde]) // LoadAct (12B)
const p2 = new Uint8Array([0x07, 0x10, 0x00, 0x20, 0x00, 0x01]) // MapAdd (6B)
const p3 = new Uint8Array([0x15, 0x00, 0x01, 0x00, 0x00, 0x00, 0x88, 0x13, 0x90, 0x13, 0x01]) // ReassignPlayer (11B)
const p4 = new Uint8Array([0x8f, ...new Array(32).fill(0xaa)]) // Pong (33B)
const block1 = new Uint8Array(p1.length + p2.length)
block1.set(p1, 0)
block1.set(p2, p1.length)
const block2 = new Uint8Array(p3.length + p4.length)
block2.set(p3, 0)
block2.set(p4, p3.length)
const f1 = encodeD2gsCompressedFrame(block1)
const f2 = encodeD2gsCompressedFrame(block2)
const combinedStream = new Uint8Array(2 + f1.length + f2.length)
combinedStream.set([0xaf, 0x01], 0) // Uncompressed 0xAF greeting switching stream to compressed mode
combinedStream.set(f1, 2)
combinedStream.set(f2, 2 + f1.length)
// Feed 1 byte at a time
const framer1B = new D2gsS2cFramer(false)
const collected1B: number[] = []
for (let i = 0; i < combinedStream.length; i++) {
const pkts = framer1B.push(combinedStream.subarray(i, i + 1))
for (const p of pkts) {
collected1B.push(p.packetId)
}
}
expect(collected1B).toEqual([0xaf, 0x03, 0x07, 0x15, 0x8f])
expect(framer1B.bufferedTcpBytes).toBe(0)
expect(framer1B.bufferedPacketBytes).toBe(0)
// Feed pseudo-random 1..7 byte chunks
const rng = createDeterministicRng(0x20260417)
const framerRand = new D2gsS2cFramer(false)
const collectedRand: number[] = []
let cursor = 0
while (cursor < combinedStream.length) {
const step = Math.min(combinedStream.length - cursor, (Math.floor(rng() * 7) % 7) + 1)
const pkts = framerRand.push(combinedStream.subarray(cursor, cursor + step))
for (const p of pkts) {
collectedRand.push(p.packetId)
}
cursor += step
}
expect(collectedRand).toEqual([0xaf, 0x03, 0x07, 0x15, 0x8f])
})
it('adv_06_d2gs_framer_cross_block_packet_reassembly_across_two_compressed_frames', () => {
// A single 26-byte 0x59 AssignPlayer packet + 9-byte 0x96 WalkVerify packet (35 bytes total)
// split at byte 17 across two separate compressed blocks!
const assign = new Uint8Array(26)
assign[0] = 0x59
assign[1] = 1 // unitId = 1
assign[5] = 1 // Sorceress
assign[6] = 0x53 // 'S'
assign[7] = 0x6f // 'o'
assign[8] = 0x72 // 'r'
assign[9] = 0x63 // 'c'
assign[10] = 0x00
assign[22] = 0x10
assign[23] = 0x14 // x = 0x1410
assign[24] = 0x20
assign[25] = 0x15 // y = 0x1520
const walkVerify = new Uint8Array(9) // 0x96 WalkVerify is 9 bytes in D2Net.dll (0x6fbfa900)
walkVerify[0] = 0x96
const fullDecompressed = new Uint8Array(35)
fullDecompressed.set(assign, 0)
fullDecompressed.set(walkVerify, 26)
const partA = fullDecompressed.subarray(0, 17) // Incomplete 0x59 (17 of 26 bytes)
const partB = fullDecompressed.subarray(17) // Remaining 9 bytes of 0x59 + 9 bytes of 0x96
const frameA = encodeD2gsCompressedFrame(partA)
const frameB = encodeD2gsCompressedFrame(partB)
const framer = new D2gsS2cFramer(true)
const outA = framer.push(frameA)
expect(outA).toHaveLength(0) // Must hold 17 partial bytes in packetBuf
expect(framer.bufferedPacketBytes).toBe(17)
const outB = framer.push(frameB)
expect(outB).toHaveLength(2)
expect(outB[0]!.packetId).toBe(0x59)
expect(outB[0]!.bytes).toEqual(assign)
expect(outB[1]!.packetId).toBe(0x96)
expect(outB[1]!.bytes).toEqual(walkVerify)
expect(framer.bufferedPacketBytes).toBe(0)
})
it('adv_07_d2gs_0xAF_custom_130_byte_huffman_table_negotiation', () => {
// Build a custom 130-byte 0xAF packet with flag=0x81 and custom 4-bit code lengths:
// Each nibble encodes (length - 1), so 0x77 encodes two symbols of length 7 + 1 = 8 bits!
const customAf = new Uint8Array(130)
customAf[0] = 0xaf
customAf[1] = 0x81
for (let i = 2; i < 130; i++) {
customAf[i] = 0x77
}
const lengths = unpackCustomHuffmanLengths(customAf)
expect(lengths).toHaveLength(256)
expect(lengths.every((l) => l === 8)).toBe(true)
const customCodec = new D2gsHuffmanCodec(lengths)
const samplePkt = new Uint8Array([0x0d, 0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x10, 0x00, 0x20, 0x00, 0x00, 0x64]) // PlayerStop (13B)
const compressedWithCustom = wrapD2gsCompressedBlock(customCodec.compress(samplePkt))
const framer = new D2gsS2cFramer(false)
const stream = new Uint8Array(customAf.length + compressedWithCustom.length)
stream.set(customAf, 0)
stream.set(compressedWithCustom, customAf.length)
const pkts = framer.push(stream)
expect(pkts).toHaveLength(2)
expect(pkts[0]!.packetId).toBe(0xaf)
expect(pkts[0]!.bytes.length).toBe(130)
expect(pkts[1]!.packetId).toBe(0x0d)
expect(pkts[1]!.bytes).toEqual(samplePkt)
})
it('adv_08_d2gs_framer_fail_fast_on_unknown_and_corrupt_s2c_opcodes', () => {
// 0x2B is unused (0 in S2C_PACKET_SIZES); 0xB5 is >= 181 table bound
expect(S2C_PACKET_SIZES[0x2b]).toBe(0)
const framerUncomp = new D2gsS2cFramer(false)
expect(() => framerUncomp.push(new Uint8Array([0x2b, 0x00, 0x00]))).toThrow(ProtocolError)
const framerComp = new D2gsS2cFramer(true)
const badBlock = encodeD2gsCompressedFrame(new Uint8Array([0xb5, 0x01, 0x02]))
expect(() => framerComp.push(badBlock)).toThrow(ProtocolError)
// Variable-length packet with invalid size > 0x204 (516) must throw ProtocolError in framer
const oversize94 = new Uint8Array(606)
oversize94[0] = 0x94
oversize94[1] = 200 // 6 + 200*3 = 606 > 516 (0x204)
const framerOversize = new D2gsS2cFramer(false)
expect(() => framerOversize.push(oversize94)).toThrow(ProtocolError)
})
it('adv_09_d2gs_variable_length_s2c_packets_0x26_0x94_0xA8_0xA9_0xAA_0xAC', () => {
const tables = buildAdversarialItemTables()
const world = new ClientWorld()
world.selfId = 1
// 1. 0x94 BaseSkillLevels: count=3 -> 6 + 3*3 = 15 bytes
const pkt94 = new ByteWriter()
.u8(0x94)
.u8(3) // count
.u32(1) // playerId = 1
.u16(36)
.u8(20) // Fire Bolt lvl 20
.u16(47)
.u8(25) // Fire Ball lvl 25
.u16(54)
.u8(1) // Teleport lvl 1
.toUint8Array()
expect(resolveS2cPacketSize(pkt94, 0)).toBe(15)
const ev94 = decodeD2gsS2cPacket(pkt94, { tables, encoding: 'gbk' })
expect(ev94.type).toBe('SkillList')
world.apply(ev94)
expect(world.self.skills.get(47)).toBe(25)
// 2. 0xA8 StateSet with bitstream stats: [0xA8, unitType=0, unitId(4), packetLen, stateId, ...statBitStream]
// D2Client.dll 0x6fb5dd70 reads raw `sendBits` without `saveAdd`
const statBits = new BitWriter()
statBits.writeBits(0, 9) // statId = 0 (Strength)
statBits.writeBits(35, 8) // value = +35 (8 sendBits)
statBits.writeBits(0x1ff, 9) // terminator
const statPayload = statBits.toUint8Array()
const pktA8 = new ByteWriter()
.u8(0xa8)
.u8(0) // player
.u32(1) // unitId = 1
.u8(8 + statPayload.length)
.u8(32) // state = BattleOrders (32)
.bytes(statPayload)
.toUint8Array()
expect(resolveS2cPacketSize(pktA8, 0)).toBe(8 + statPayload.length)
const evA8 = decodeD2gsS2cPacket(pktA8, { tables, encoding: 'gbk' })
expect(evA8.type).toBe('StateSet')
world.apply({ type: 'StatSet', stat: 0, value: 100 })
world.apply(evA8)
expect(world.self.states.has(32)).toBe(true)
expect(world.self.stats.get(0)).toBe(135) // 100 base + 35 from state 32
// 3. 0xA9 StateEnd (7 bytes): removes state 32 and restores base stat 100!
const pktA9 = new ByteWriter().u8(0xa9).u8(0).u32(1).u8(32).toUint8Array()
const evA9 = decodeD2gsS2cPacket(pktA9, { tables, encoding: 'gbk' })
expect(evA9.type).toBe('StateEnd')
world.apply(evA9)
expect(world.self.states.has(32)).toBe(false)
expect(world.self.stats.get(0)).toBe(100)
// 4. 0xAA StateAdd / UnitInfo (variable length at byte[6])
const pktAA = new ByteWriter()
.u8(0xaa)
.u8(1) // monster
.u32(55) // monster id = 55
.u8(9) // total length = 9
.u8(8) // state 8 (Freeze)
.u8(0)
.toUint8Array()
expect(resolveS2cPacketSize(pktAA, 0)).toBe(9)
const evAA = decodeD2gsS2cPacket(pktAA, { tables, encoding: 'gbk' })
expect(evAA.type).toBe('StateSet')
// 5. 0xAC AssignNPC / Monster (variable length at byte[12])
const pktAC = new ByteWriter()
.u8(0xac)
.u32(777) // unitId
.u16(156) // classId = Andariel (156)
.u16(5120) // x
.u16(5140) // y
.u8(0x80) // lifePct = 128 (100%)
.u8(16) // total length = 16
.u8(0x01)
.u8(0x02)
.u8(0x03)
.toUint8Array()
expect(resolveS2cPacketSize(pktAC, 0)).toBe(16)
const evAC = decodeD2gsS2cPacket(pktAC, { tables, encoding: 'gbk' })
expect(evAC.type).toBe('UnitAssign')
world.apply(evAC)
const andariel = world.getUnit('monster', 777)
expect(andariel).toBeDefined()
expect(andariel!.classId).toBe(156)
expect(andariel!.lifePct).toBe(128)
})
it('adv_10_item_bitstream_chained_stat_pairs_17_18_48_49_57_58_59_and_negative_signed_stats', () => {
const tables = buildAdversarialItemTables()
const bw = new BitWriter()
// Flags: IDENTIFIED | SOCKETED | ETHEREAL
const flags = ITEM_FLAG_IDENTIFIED | ITEM_FLAG_SOCKETED | ITEM_FLAG_ETHEREAL
bw.writeBits(flags, 32)
bw.writeBits(101, 10) // version = 101 (10 bits)
bw.writeBits(0, 3) // location = 0 (inventory, 3 bits)
bw.writeBits(0, 4) // bodyLoc = 0
bw.writeBits(2, 4) // gridX = 2
bw.writeBits(3, 4) // gridY = 3
bw.writeBits(0, 3) // storePage = 0 (inventory)
// 4-char item code 'uap ' (Harlequin Crest Shako)
const code = 'uap '
for (let i = 0; i < 4; i++) {
bw.writeBits(code.charCodeAt(i), 8)
}
bw.writeBits(1, 3) // socketedChildCount = 1
// Extended section (non-compact, saveFormat=false -> no 32-bit guid):
bw.writeBits(87, 7) // ilvl = 87
bw.writeBits(7, 4) // quality = 7 (Unique)
bw.writeBits(0, 1) // hasGraphic = 0
bw.writeBits(0, 1) // hasAutoAffix = 0
bw.writeBits(125, 12) // uniqueItemId = 125 (Harlequin Crest)
// Armor defense (stat 31: saveBits=11, saveAdd=10): write 141 + 10 = 151
bw.writeBits(141 + 10, 11)
// Durability (stat 73 maxDurability 8 bits, stat 72 curDurability 9 bits):
bw.writeBits(12, 8)
bw.writeBits(12, 9)
// Socketed flag set -> totalSockets (stat 194, 4 bits) = 2
bw.writeBits(2, 4)
// Primary stat list:
// 1) Stat 17 (Enhanced Damage % chained with 18): write id=17 (9b), maxEd=200 (9b), minEd=200 (9b)
bw.writeBits(17, 9)
bw.writeBits(200, 9)
bw.writeBits(200, 9)
// 2) Stat 48 (Fire Damage chained with 49): write id=48 (9b), minFire=15 (8b), maxFire=45 (8b)
bw.writeBits(48, 9)
bw.writeBits(15, 8)
bw.writeBits(45, 8)
// 3) Stat 57 (Poison Damage chained with 58 and 59): write id=57 (9b), minPois=50 (10b), maxPois=100 (10b), len=125 (9b)
bw.writeBits(57, 9)
bw.writeBits(50, 10)
bw.writeBits(100, 10)
bw.writeBits(125, 9)
// 4) Signed negative stat 36 (damageresist, saveAdd=100, saveBits=8, signed=true):
// Raw = 70 -> decoded = 70 - 100 = -30%
bw.writeBits(36, 9)
bw.writeBits(70, 8)
// 5) Parametrized stat 107 (+3 to Teleport skillId=54): id=107 (9b), param=54 (16b), val=3 (5b)
bw.writeBits(107, 9)
bw.writeBits(54, 16)
bw.writeBits(3, 5)
// Terminator 0x1ff
bw.writeBits(0x1ff, 9)
const decoded = decodeItemBitstream(
bw.toUint8Array(),
{ opcode: 0x9c, action: 0x00, category: 0, itemId: 9001 },
tables,
{ saveFormat: false },
)
expect(decoded.code).toBe('uap')
expect(decoded.ethereal).toBe(true)
expect(decoded.socketed).toBe(true)
expect(decoded.totalSockets).toBe(2)
expect(decoded.uniqueItemId).toBe(125)
expect(decoded.defense).toBe(141)
expect(decoded.maxDurability).toBe(12)
expect(decoded.curDurability).toBe(12)
// All 9 expanded stats (including chained 18, 49, 58, 59) must be present in order!
const statMap = new Map(decoded.stats.map((s) => [`${s.statId}:${s.param}`, s.value]))
expect(statMap.get('17:0')).toBe(200)
expect(statMap.get('18:0')).toBe(200)
expect(statMap.get('48:0')).toBe(15)
expect(statMap.get('49:0')).toBe(45)
expect(statMap.get('57:0')).toBe(50)
expect(statMap.get('58:0')).toBe(100)
expect(statMap.get('59:0')).toBe(125)
expect(statMap.get('36:0')).toBe(-30)
expect(statMap.get('107:54')).toBe(3)
})
it('adv_11_item_bitstream_set_bonus_lists_runeword_personalized_and_ear_packets', () => {
const tables = buildAdversarialItemTables()
// 1. Set + Runeword + Personalized item in .d2s saveFormat=true
const bw = new BitWriter()
bw.writeBits(0x4d4a, 16) // "JM"
const flags = ITEM_FLAG_IDENTIFIED | ITEM_FLAG_RUNEWORD | ITEM_FLAG_PERSONALIZED
bw.writeBits(flags, 32)
bw.writeBits(101, 10) // version (10 bits)
bw.writeBits(1, 3) // location = 1 (equipped, 3 bits)
bw.writeBits(4, 4) // bodyLoc = 4 (right hand weapon1)
bw.writeBits(0, 4)
bw.writeBits(0, 4)
bw.writeBits(0, 3)
const code = 'crs '
for (let i = 0; i < 4; i++) bw.writeBits(code.charCodeAt(i), 8)
bw.writeBits(2, 3) // socketedChildCount
bw.writeBits(0xdeadbeef, 32) // 32-bit guid (saveFormat=true)
bw.writeBits(85, 7) // ilvl
bw.writeBits(5, 4) // quality = 5 (Set)
bw.writeBits(0, 1) // hasGraphic
bw.writeBits(0, 1) // hasAutoAffix
bw.writeBits(42, 12) // setItemId = 42
bw.writeBits(27, 16) // runewordData = 27
// Personalized name "Anya" (7-bit ASCII chars + 0 terminator)
for (const ch of 'Anya') {
bw.writeBits(ch.charCodeAt(0), 7)
}
bw.writeBits(0, 7)
bw.writeBits(0, 1) // hasRealmData = 0 (saveFormat=true)
// Weapon durability for 'crs': maxDur=40 (8b), curDur=38 (9b)
bw.writeBits(40, 8)
bw.writeBits(38, 9)
// Set bonus mask (5 bits): bits 0 and 2 active (0b00101 = 5 -> 2 extra set stat lists)
bw.writeBits(0b00101, 5)
// Base stat list: Strength +10 (id=0, saveAdd=32 -> 42)
bw.writeBits(0, 9)
bw.writeBits(10 + 32, 8)
bw.writeBits(0x1ff, 9)
// Set bonus list #1: Strength +15
bw.writeBits(0, 9)
bw.writeBits(15 + 32, 8)
bw.writeBits(0x1ff, 9)
// Set bonus list #2: Strength +25
bw.writeBits(0, 9)
bw.writeBits(25 + 32, 8)
bw.writeBits(0x1ff, 9)
// Runeword stat list: Fire min/max +10..20
bw.writeBits(48, 9)
bw.writeBits(10, 8)
bw.writeBits(20, 8)
bw.writeBits(0x1ff, 9)
const setRwItem = decodeItemBitstream(
bw.toUint8Array(),
{ opcode: 0x9c, action: 0x04, category: 0, itemId: 9002 },
tables,
{ saveFormat: true, expectJmHeader: true },
)
expect(setRwItem.personalizedName).toBe('Anya')
expect(setRwItem.setItemId).toBe(42)
expect(setRwItem.setBonusStats).toHaveLength(2)
expect(setRwItem.setBonusStats[0]![0]!.value).toBe(15)
expect(setRwItem.setBonusStats[1]![0]!.value).toBe(25)
expect(setRwItem.runewordStats).toHaveLength(2)
// Round-trip encodeD2gsItemPacket -> decodeD2gsItemPacket
const reencoded = encodeD2gsItemPacket({ ...setRwItem, opcode: 0x9c }, tables, { saveFormat: false })
const roundtripped = decodeD2gsItemPacket(reencoded, tables, { saveFormat: false })
expect(roundtripped.personalizedName).toBe('Anya')
expect(roundtripped.setItemId).toBe(42)
// 2. Player Ear item (ITEM_FLAG_ISEAR | ITEM_FLAG_COMPACT) via full 0x9C packet
const bwEar = new BitWriter()
bwEar.writeBits(ITEM_FLAG_ISEAR | ITEM_FLAG_COMPACT, 32)
bwEar.writeBits(101, 10)
bwEar.writeBits(0, 3)
bwEar.writeBits(0, 4)
bwEar.writeBits(1, 4)
bwEar.writeBits(1, 4)
bwEar.writeBits(0, 3)
bwEar.writeBits(2, 3) // earClass = 2 (Necromancer)
bwEar.writeBits(92, 7) // earLevel = 92
for (const ch of 'PK_Victim') {
bwEar.writeBits(ch.charCodeAt(0), 7)
}
bwEar.writeBits(0, 7)
const earBits = bwEar.toUint8Array()
const pkt9C = new ByteWriter()
.u8(0x9c)
.u8(0x00) // action
.u8(8 + earBits.length) // packetSize
.u8(0) // category
.u32(9003) // itemId
.bytes(earBits)
.toUint8Array()
const earItem = decodeD2gsItemPacket(pkt9C, tables)
expect(earItem.isEar).toBe(true)
expect(earItem.earClass).toBe(2)
expect(earItem.earLevel).toBe(92)
expect(earItem.earName).toBe('PK_Victim')
})
it('adv_12_c2s_client_command_exhaustive_encoding_parity_all_variants', () => {
const commands: Array<{ cmd: ClientCommand; expectedOpcodes: number[] }> = [
{ cmd: { type: 'Move', to: { x: asSubTile(5100), y: asSubTile(4800) }, run: false }, expectedOpcodes: [0x01] },
{ cmd: { type: 'Move', to: { x: asSubTile(5105), y: asSubTile(4805) }, run: true }, expectedOpcodes: [0x03] },
{
cmd: { type: 'MoveToUnit', target: { unitType: 'monster', id: asUnitId(42) }, run: false },
expectedOpcodes: [0x02],
},
{
cmd: { type: 'MoveToUnit', target: { unitType: 'monster', id: asUnitId(42) }, run: true },
expectedOpcodes: [0x04],
},
{
cmd: { type: 'SkillAt', at: { x: asSubTile(5110), y: asSubTile(4810) }, hand: 'left', shift: false },
expectedOpcodes: [0x05],
},
{
cmd: { type: 'SkillAt', at: { x: asSubTile(5110), y: asSubTile(4810) }, hand: 'left', shift: true },
expectedOpcodes: [0x08],
},
{
cmd: { type: 'SkillAt', at: { x: asSubTile(5110), y: asSubTile(4810) }, hand: 'right', shift: false },
expectedOpcodes: [0x0c],
},
{
cmd: { type: 'SkillAt', at: { x: asSubTile(5110), y: asSubTile(4810) }, hand: 'right', shift: true },
expectedOpcodes: [0x0f],
},
{
cmd: { type: 'SkillOn', target: { unitType: 'monster', id: asUnitId(42) }, hand: 'left', shift: false },
expectedOpcodes: [0x06],
},
{
cmd: { type: 'SkillOn', target: { unitType: 'monster', id: asUnitId(42) }, hand: 'left', shift: true },
expectedOpcodes: [0x07],
},
{
cmd: { type: 'SkillOn', target: { unitType: 'monster', id: asUnitId(42) }, hand: 'right', shift: false },
expectedOpcodes: [0x0d],
},
{
cmd: { type: 'SkillOn', target: { unitType: 'monster', id: asUnitId(42) }, hand: 'right', shift: true },
expectedOpcodes: [0x0e],
},
{ cmd: { type: 'SelectSkill', skill: 54, hand: 'right' }, expectedOpcodes: [0x3c] },
{ cmd: { type: 'Interact', target: { unitType: 'object', id: asUnitId(100) } }, expectedOpcodes: [0x13] },
{ cmd: { type: 'NpcInit', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x2f] },
{ cmd: { type: 'NpcCancel', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x30] },
{ cmd: { type: 'NpcBuy', npcId: asUnitId(200), itemId: asUnitId(300), tab: 0, cost: 500 }, expectedOpcodes: [0x32] },
{ cmd: { type: 'NpcSell', npcId: asUnitId(200), itemId: asUnitId(301), tab: 0, cost: 250 }, expectedOpcodes: [0x33] },
{ cmd: { type: 'NpcRepair', npcId: asUnitId(200), itemId: asUnitId(302), tab: 0, cost: 100 }, expectedOpcodes: [0x35] },
{ cmd: { type: 'WaypointGo', waypointId: asUnitId(400), destAreaId: 3 }, expectedOpcodes: [0x49] },
{ cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: false }, expectedOpcodes: [0x16] },
{ cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: true }, expectedOpcodes: [0x16] },
{ cmd: { type: 'ItemDrop', itemId: asUnitId(500) }, expectedOpcodes: [0x17] },
{ cmd: { type: 'ItemPlace', itemId: asUnitId(500), x: 2, y: 3, buffer: 0 }, expectedOpcodes: [0x18] },
{ cmd: { type: 'ItemEquip', itemId: asUnitId(500), slot: 4 }, expectedOpcodes: [0x1a] },
{ cmd: { type: 'BeltUse', itemId: asUnitId(600), shift: false }, expectedOpcodes: [0x26] },
{ cmd: { type: 'SwapWeapons' }, expectedOpcodes: [0x60] },
{ cmd: { type: 'AddStatPoint', statId: 0, count: 1 }, expectedOpcodes: [0x3a] },
{ cmd: { type: 'AddSkillPoint', skillId: 54 }, expectedOpcodes: [0x3b] },
{ cmd: { type: 'Chat', text: '你好世界' }, expectedOpcodes: [0x15] },
{ cmd: { type: 'Chat', text: '密语测试', whisperTo: 'Friend' }, expectedOpcodes: [0x15] },
{ cmd: { type: 'OverheadChat', text: '救命!Help!' }, expectedOpcodes: [0x14] },
{ cmd: { type: 'Party', action: 0x06, targetPlayerId: asUnitId(2) }, expectedOpcodes: [0x5e] },
{ cmd: { type: 'Hostile', targetPlayerId: asUnitId(2), hostile: true }, expectedOpcodes: [0x5d] },
{ cmd: { type: 'Resurrect' }, expectedOpcodes: [0x41] },
{ cmd: { type: 'LeaveGame' }, expectedOpcodes: [0x69] },
]
for (const { cmd, expectedOpcodes } of commands) {
const pkts = encodeD2gsClientCommand(cmd, 'gbk')
expect(pkts.map((p) => p[0])).toEqual(expectedOpcodes)
}
expect(C2S_PACKET_SIZES.length).toBe(113)
})
})
// ============================================================================
// SECTION 3: F9–F13 ClientWorld, MapService, Automap, Pathfind & Predictor
// ============================================================================
describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Pathfind & Predictor', () => {
it('adv_13_client_world_rapid_unit_assign_reassign_remove_and_ring_buffer_caps', () => {
const world = new ClientWorld()
// 1. Assign self player + 150 monsters, then remove 100 monsters and reassign 50
world.apply({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: asUnitId(1),
classId: 1,
name: 'AdvSorc',
x: asSubTile(5100),
y: asSubTile(5000),
},
})
expect(world.selfId).toBe(1)
expect(world.self.name).toBe('AdvSorc')
for (let id = 100; id < 250; id++) {
world.apply({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: asUnitId(id),
classId: 1, // Skeleton
x: asSubTile(5100 + (id - 100)),
y: asSubTile(5000),
lifePct: 128,
},
})
}
expect(world.units.size).toBe(151) // 1 player + 150 monsters
// Remove 100 monsters (100..199)
for (let id = 100; id < 200; id++) {
world.apply({
type: 'UnitRemove',
unitType: 'monster',
id: asUnitId(id),
})
}
expect(world.units.size).toBe(51)
// Reassign remaining 50 monsters (200..249) with snap=false vs snap=true
for (let id = 200; id < 250; id++) {
world.apply({
type: 'UnitReassign',
unitType: 'monster',
id: asUnitId(id),
at: { x: asSubTile(6000), y: asSubTile(6000) },
snap: id % 2 === 0,
})
}
expect(world.getUnit('monster', 200)!.x).toBe(6000)
// 2. Chat log ring buffer cap (200 entries) & sound queue cap (64 entries)
for (let i = 0; i < 250; i++) {
world.apply({
type: 'Chat',
kind: 'say',
from: 'Speaker',
text: `Msg_${i}`,
})
}
expect(world.chatLog.length).toBe(200)
expect(world.chatLog[0]!.text).toBe('Msg_50')
expect(world.chatLog[199]!.text).toBe('Msg_249')
for (let i = 0; i < 100; i++) {
world.apply({
type: 'PlaySound',
sound: 1000 + i,
})
}
expect(world.soundQueue.length).toBe(64)
const drained = world.drainSoundQueue()
expect(drained).toHaveLength(64)
expect(drained[0]!.sound).toBe(1036)
expect(world.soundQueue.length).toBe(0)
})
it('adv_14_client_world_8bit_shifted_pool_stats_and_negative_stat_deltas', () => {
const world = new ClientWorld()
world.selfId = 1
// 1. In 1.13c, StatSet for HitPoints(6), MaxHp(7), Mana(8), MaxMana(9), Stamina(10), MaxStamina(11)
// carries 8.8 fixed-point values when >= 256 (shifted right by 8 bits in `MutableSelfPlayerState.applyStat`)
world.apply({ type: 'StatSet', stat: 7, value: 450 << 8 }) // MaxHp = 450
world.apply({ type: 'StatSet', stat: 6, value: 320 << 8 }) // HitPoints = 320
world.apply({ type: 'StatSet', stat: 9, value: 600 << 8 }) // MaxMana = 600
world.apply({ type: 'StatSet', stat: 8, value: 550 << 8 }) // Mana = 550
world.apply({ type: 'StatSet', stat: 11, value: 300 << 8 }) // MaxStamina = 300
world.apply({ type: 'StatSet', stat: 10, value: 280 << 8 }) // Stamina = 280
expect(world.self.maxLife).toBe(450)
expect(world.self.life).toBe(320)
expect(world.self.maxMana).toBe(600)
expect(world.self.mana).toBe(550)
expect(world.self.maxStamina).toBe(300)
expect(world.self.stamina).toBe(280)
// 2. Gold delta (stat=14, param=1) clamping at 0 on negative overflow
world.apply({ type: 'StatSet', stat: 14, value: 1000, param: 0 })
expect(world.self.gold).toBe(1000)
world.apply({ type: 'StatSet', stat: 14, value: -350, param: 1 })
expect(world.self.gold).toBe(650)
world.apply({ type: 'StatSet', stat: 14, value: -5000, param: 1 })
expect(world.self.gold).toBe(0) // Never negative!
// 3. Experience unsigned 32-bit wrap safety (> 2^31 - 1, e.g. level 99 XP = 3,520,485,254)
world.apply({ type: 'Experience', value: 3_000_000_000, isDelta: false })
expect(world.self.experience).toBe(3_000_000_000)
world.apply({ type: 'Experience', value: 520_485_254, isDelta: true })
expect(world.self.experience).toBe(3_520_485_254)
})
it('adv_15_client_world_inventory_container_cycle_and_weapon_swap_isolation', () => {
const world = new ClientWorld()
world.selfId = 1
const itemId = asUnitId(8888)
// Step 1: Drop on ground (action 0x00, container 'ground')
const groundItem = makeSyntheticItemData({
id: itemId,
code: 'uap',
action: 0x00,
container: 'ground',
worldX: 5112,
worldY: 5004,
x: 5112,
y: 5004,
})
world.apply({ type: 'ItemEvent', action: 0x00, container: 'ground', item: groundItem })
expect(world.containers.ground.has(itemId)).toBe(true)
expect(world.getUnit('item', itemId)).toBeDefined()
// Step 2: Pick up to inventory (action 0x04, container 'inventory') -> must auto-evict from ground & world units!
const invItem = makeSyntheticItemData({
id: itemId,
code: 'uap',
action: 0x04,
container: 'inventory',
gridX: 3,
gridY: 1,
x: 3,
y: 1,
})
world.apply({ type: 'ItemEvent', action: 0x04, container: 'inventory', item: invItem })
expect(world.containers.ground.has(itemId)).toBe(false)
expect(world.getUnit('item', itemId)).toBeUndefined()
expect(world.containers.inventory.get(itemId)?.gridX).toBe(3)
// Step 3: Pick up to cursor (action 0x05, container 'cursor') -> must evict from inventory!
const cursorItem = makeSyntheticItemData({
id: itemId,
code: 'uap',
action: 0x05,
container: 'cursor',
})
world.apply({ type: 'ItemEvent', action: 0x05, container: 'cursor', item: cursorItem })
expect(world.containers.inventory.has(itemId)).toBe(false)
expect(world.containers.cursor?.id).toBe(itemId)
// Step 4: Equip to helm slot (action 0x06, container 'equipment', bodyLoc = 1) -> must clear cursor!
const eqItem = makeSyntheticItemData({
id: itemId,
code: 'uap',
action: 0x06,
container: 'equipment',
bodyLoc: 1,
equippedSlot: 1,
equipped: true,
})
world.apply({ type: 'ItemEvent', action: 0x06, container: 'equipment', item: eqItem })
expect(world.containers.cursor).toBeNull()
expect(world.containers.equipment.get(itemId)?.id).toBe(itemId)
expect(world.containers.equipmentBySlot.get(1)?.id).toBe(itemId)
// Step 5: Move to Horadric Cube (action 0x04, container 'cube') -> must evict from equipment!
const cubeItem = makeSyntheticItemData({
id: itemId,
code: 'uap',
action: 0x04,
container: 'cube',
gridX: 0,
gridY: 0,
})
world.apply({ type: 'ItemEvent', action: 0x04, container: 'cube', item: cubeItem })
expect(world.containers.equipment.has(itemId)).toBe(false)
expect(world.containers.equipmentBySlot.has(1)).toBe(false)
expect(world.containers.cube.has(itemId)).toBe(true)
// Step 6: Move to Stash (action 0x04, container 'stash') -> must evict from cube!
const stashItem = makeSyntheticItemData({
id: itemId,
code: 'uap',
action: 0x04,
container: 'stash',
gridX: 4,
gridY: 5,
})
world.apply({ type: 'ItemEvent', action: 0x04, container: 'stash', item: stashItem })
expect(world.containers.cube.has(itemId)).toBe(false)
expect(world.containers.stash.has(itemId)).toBe(true)
// Step 7: Weapon switch (action 0x17) toggles weaponSet 0 <-> 1
expect(world.self.weaponSet).toBe(0)
world.apply({ type: 'ItemEvent', action: 0x17, container: 'equipment', item: eqItem })
expect(world.self.weaponSet).toBe(1)
world.apply({ type: 'ItemEvent', action: 0x17, container: 'equipment', item: eqItem })
expect(world.self.weaponSet).toBe(0)
})
it('adv_16_client_world_mercenary_portal_party_and_trade_lifecycle', () => {
const world = new ClientWorld()
world.selfId = 1
// 1. Mercenary assign -> stat -> exp -> unassign (mercId = 0)
world.apply({
type: 'Merc',
kind: 'assign',
mercId: 77,
ownerId: 1,
classId: 271, // Act 2 Desert Mercenary
nameId: 5,
})
expect(world.merc).not.toBeNull()
expect(world.merc!.mercId).toBe(77)
expect(world.self.mercId).toBe(77)
world.apply({ type: 'Merc', kind: 'exp', value: 999999 })
expect(world.merc!.exp).toBe(999999)
world.apply({ type: 'Merc', kind: 'stat', mercId: 77, stat: 12, value: 85 })
expect(world.getUnit('monster', 77)?.stats.get(12)).toBe(85)
// Merc death/dismissal (mercId = 0)
world.apply({ type: 'Merc', kind: 'assign', mercId: 0, ownerId: 1 })
expect(world.merc).toBeNull()
expect(world.self.mercId).toBeNull()
// 2. Portal creation and automatic removal on UnitRemove
world.apply({
type: 'Portal',
localId: 300,
remoteId: 301,
ownerName: 'PartySorc',
ownerId: 2,
areaId: 1,
flags: 1,
})
expect(world.portals.get(300)?.ownerName).toBe('PartySorc')
world.apply({ type: 'UnitRemove', unitType: 'object', id: 300 })
expect(world.portals.has(300)).toBe(false)
// 3. Party member join -> update -> leave (partyId = 0xFFFF)
world.apply({
type: 'Party',
kind: 'roster',
unitId: 2,
charName: 'PaladinBro',
charClass: 3,
charLevel: 90,
partyId: 1,
lifePct: 100,
areaId: 1,
})
expect(world.party.get(2)?.charName).toBe('PaladinBro')
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 0xffff })
expect(world.party.has(2)).toBe(false)
// 4. Trade open (action 0x01) -> gold update -> cancel (action 0x02)
world.apply({ type: 'Trade', action: 0x01, partnerId: 2, gold: 50000 })
expect(world.trade.active).toBe(true)
expect(world.trade.partnerGold).toBe(50000)
world.apply({ type: 'Trade', action: 0x02 })
expect(world.trade.active).toBe(false)
})
it('adv_17_pathfind_diagonal_corner_cutting_prevention_and_void_bounds', () => {
// Build a 20x20 sub-tile walkability grid where a diagonal gap exists between (5,5)=blocked and (6,4)=blocked.
// Moving diagonally from (5,4) to (6,5) would cut between two diagonal wall corners and MUST be forbidden!
const walkable = new Uint8Array(20 * 20).fill(1)
walkable[5 * 20 + 5] = 0 // (5,5) blocked
walkable[4 * 20 + 6] = 0 // (6,4) blocked
const grid: SubTileWalkabilityGrid = {
subOriginX: 1000,
subOriginY: 2000,
subWidth: 20,
subHeight: 20,
walkable,
}
// 1. Out-of-bounds (void) sub-tiles must never be walkable
expect(isCellWalkableLocal(grid, -1, 0)).toBe(false)
expect(isCellWalkableLocal(grid, 20, 20)).toBe(false)
// 2. A* from local (5,4) to (6,5) without string pulling must route around the diagonal pinch (at least 3 points)
const rawRes = findPathLocal(grid, 5, 4, 6, 5, { smooth: false })
expect(rawRes.reachedGoal).toBe(true)
expect(rawRes.path.length).toBeGreaterThanOrEqual(3)
for (let i = 1; i < rawRes.path.length; i++) {
const prev = rawRes.path[i - 1]!
const cur = rawRes.path[i]!
// Cannot jump directly from (5,4) to (6,5)
expect(prev.x === 5 && prev.y === 4 && cur.x === 6 && cur.y === 5).toBe(false)
}
// 3. String-pulled world path (`findPathWorld`) must also respect LOS and not collapse through the diagonal wall
expect(hasLineOfSightLocal(grid, 5, 4, 6, 5)).toBe(false)
const worldRes = findPathWorld(grid, 1005, 2004, 1006, 2005, { smooth: true })
expect(worldRes.reachedGoal).toBe(true)
expect(worldRes.path.length).toBeGreaterThanOrEqual(3)
})
it('adv_18_predictor_rubber_band_vs_hard_snap_boundary_and_frw_diminishing_returns', () => {
const predictor = new LocalMovementPredictor({
initialX: 5000,
initialY: 5000,
rubberBandThreshold: 5.0,
rubberBandCorrectionRate: 12.0,
})
// 1. Start a multi-waypoint path
predictor.setPath(
[
{ x: 5000, y: 5000 },
{ x: 5020, y: 5000 },
{ x: 5020, y: 5020 },
],
true,
)
expect(predictor.getSnapshot().moving).toBe(true)
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
// 2. Server WalkVerify at drift = 4.99 sub-tiles (<= 5.0 threshold) -> rubber-band mode, path preserved!
const rBand = predictor.reconcileServerPosition({ x: 5004.99, y: 5000, teleport: false })
expect(rBand.mode).toBe('rubber-band')
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
const snapBeforeDecay = predictor.getSnapshot()
expect(Math.abs(snapBeforeDecay.errorOffsetX)).toBeGreaterThan(4.0)
// Step 500ms -> rubber-band offset decays exponentially toward 0
predictor.update(500)
const snapAfterDecay = predictor.getSnapshot()
expect(Math.abs(snapAfterDecay.errorOffsetX)).toBeLessThan(0.1)
// 3. Server ReassignPlayer at drift = 5.01 sub-tiles (> 5.0 threshold) -> hard-snap mode, clears path!
const curLogical = predictor.getSnapshot()
const rSnap = predictor.reconcileServerPosition({
x: curLogical.logicalX + 5.01,
y: curLogical.logicalY,
teleport: false,
})
expect(rSnap.mode).toBe('hard-snap')
expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0)
expect(predictor.getSnapshot().errorOffsetX).toBe(0)
// 4. Teleport flag (`teleport: true`) forces hard-snap even when drift is only 1.0 sub-tile
predictor.setPath([{ x: 5050, y: 5000 }], true)
const rTele = predictor.reconcileServerPosition({
x: predictor.getSnapshot().logicalX + 1.0,
y: predictor.getSnapshot().logicalY,
teleport: true,
})
expect(rTele.mode).toBe('hard-snap')
expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0)
// 5. D2 1.13c Faster Run/Walk (FRW) diminishing returns: effectiveFRW = floor(150 * frw / (150 + frw))
// At frw = 150%: effFrw = floor(22500 / 300) = 75% -> runSpeed = 13.5 + 9.0 * 0.75 = 20.25 sub-tiles/sec
predictor.setRunning(true)
predictor.setFasterRunWalkPercent(150)
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBeCloseTo(
D2_BASE_RUN_SUBTILES_PER_SEC + D2_BASE_WALK_SUBTILES_PER_SEC * 0.75,
5,
)
// Extreme chill (-200% FRW) clamps at minimum 1.5 sub-tiles/sec
predictor.setFasterRunWalkPercent(-200)
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(1.5)
// 6. 16-direction facing helper handles zero vector fallback cleanly
expect(deltaToDir16(0, 0, 7)).toBe(7)
})
it('adv_19_remote_entity_interpolator_knockback_stop_and_remove', () => {
const interp = new RemoteEntityInterpolator()
// Spawn remote monster #50 at (5100, 5100)
interp.spawnOrSnap({
unitType: 1,
unitId: 50,
x: 5100,
y: 5100,
})
// Fast movement to (5112, 5100) -> run velocity (13.5 sub-tiles/sec)
interp.startMove({
unitType: 1,
unitId: 50,
currentX: 5100,
currentY: 5100,
targetX: 5112,
targetY: 5100,
running: true,
moveType: 2,
})
interp.update(300) // 0.3s * 18 = 5.4 sub-tiles
const midSnap = interp.getEntityState(1, 50)
expect(midSnap).toBeDefined()
expect(midSnap!.x).toBeGreaterThan(5104)
expect(midSnap!.x).toBeLessThan(5112)
expect(midSnap!.moving).toBe(true)
// Monster stops/dies at (5108, 5100) -> immediately halts interpolation
interp.stopEntity({
unitType: 1,
unitId: 50,
x: 5108,
y: 5100,
})
interp.update(500)
const stoppedSnap = interp.getEntityState(1, 50)
expect(stoppedSnap!.moving).toBe(false)
expect(stoppedSnap!.x).toBeCloseTo(5108, 1)
// Remove entity
expect(interp.removeEntity(1, 50)).toBe(true)
expect(interp.getEntityState(1, 50)).toBeNull()
})
})
// ============================================================================
// SECTION 4: F14–F15 HUD Model, Modal Click Shielding (800×600 & 1024×768) & Input
// ============================================================================
describe('Tier 5 Adversarial Coverage — F14–F15 HUD Modal Click Shielding & CommandMapper', () => {
it('adv_20_hud_modal_click_shielding_800x600_all_left_and_right_panels', () => {
const world = new ClientWorld()
world.selfId = 1
world.apply({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: asUnitId(1), classId: 1, name: 'Sorc', x: asSubTile(5000), y: asSubTile(5000) },
})
const hud = new HudManager()
hud.resize(800, 600)
const emitted: ClientCommand[] = []
const hudModel = new HudModel({
world,
hudManager: hud,
onCommand: (cmd: ClientCommand) => {
emitted.push(cmd)
},
})
const mapper = new CommandMapper({
world,
hudManager: hud,
hudModel,
rateLimiter: new OutboundRateLimiter({ moveMinIntervalMs: 0, skillMinIntervalMs: 0 }),
sendCommand: (cmd: ClientCommand) => {
emitted.push(cmd)
},
})
// 1. Bottom status bar (y >= 552) and HP/Mana globes in 800x600 must intercept pointer clicks
expect(mapper.handlePointerDown(400, 570, 0, 100)).toBeNull()
expect(mapper.handlePointerDown(40, 520, 0, 200)).toBeNull()
expect(mapper.handlePointerDown(760, 520, 0, 300)).toBeNull()
expect(emitted).toHaveLength(0)
// 2. Every Left Panel ('char', 'quest', 'waypoint', 'stash', 'cube', 'vendor') covers x in [0, 400), y in [0, 552)
const leftPanels = ['char', 'quest', 'waypoint', 'stash', 'cube', 'vendor'] as const
for (const lp of leftPanels) {
hud.leftPanel = lp
hud.rightPanel = 'none'
expect(mapper.isPointIntercepted(200, 250)).toBe(true)
if (lp === 'vendor') {
// Vendor panel without an active vendor descriptor enforces fail-fast guard on click
expect(() => mapper.handlePointerDown(200, 250, 0, 400)).toThrow(/active vendor descriptor/)
} else {
expect(mapper.handlePointerDown(200, 250, 0, 400)).toBeNull()
}
// Right half (x=600, y=250) is unobstructed when only left panel is open
expect(mapper.isPointIntercepted(600, 250)).toBe(false)
}
// 3. Every Right Panel ('inv', 'skill') covers x in [400, 800), y in [0, 552)
const rightPanels = ['inv', 'skill'] as const
for (const rp of rightPanels) {
hud.leftPanel = 'none'
hud.rightPanel = rp
expect(mapper.isPointIntercepted(600, 250)).toBe(true)
expect(mapper.handlePointerDown(600, 250, 0, 500)).toBeNull()
// Left half (x=200, y=250) is unobstructed when only right panel is open
expect(mapper.isPointIntercepted(200, 250)).toBe(false)
}
expect(emitted).toHaveLength(0)
// 4. External UI overlay hook (`isExternalUiIntercepted`) blocks world click
hud.leftPanel = 'none'
hud.rightPanel = 'none'
const mapperWithOverlay = new CommandMapper({
world,
hudManager: hud,
hudModel,
isExternalUiIntercepted: (lx: number, ly: number) => lx >= 700 && ly <= 400,
sendCommand: (cmd: ClientCommand) => {
emitted.push(cmd)
},
})
expect(mapperWithOverlay.handlePointerDown(750, 200, 0, 600)).toBeNull()
expect(emitted).toHaveLength(0)
})
it('adv_21_hud_modal_click_shielding_1024x768_middle_corridor_and_centered_bottom_bar', () => {
const world = new ClientWorld()
world.selfId = 1
world.apply({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: asUnitId(1), classId: 1, name: 'Sorc', x: asSubTile(5000), y: asSubTile(5000) },
})
const hud = new HudManager()
hud.resize(1024, 768)
const emitted: ClientCommand[] = []
const hudModel = new HudModel({
world,
hudManager: hud,
onCommand: (cmd: ClientCommand) => {
emitted.push(cmd)
},
})
const mapper = new CommandMapper({
world,
hudManager: hud,
hudModel,
rateLimiter: new OutboundRateLimiter({ moveMinIntervalMs: 0, skillMinIntervalMs: 0 }),
sendCommand: (cmd: ClientCommand) => {
emitted.push(cmd)
},
})
// In 1024x768:
// - Bottom 800px bar is centered at xOffset = (1024 - 800) / 2 = 112..912, y = 720..768
// - Left panel occupies x in [0, 400), y in [0, 552)
// - Right panel occupies x in [624, 1024), y in [0, 552)
// - Middle corridor x in [400, 624), y in [0, 552) is WORLD SPACE even when BOTH left and right panels are open!
expect(mapper.isPointIntercepted(512, 740)).toBe(true)
expect(mapper.isPointIntercepted(50, 740)).toBe(false)
expect(mapper.isPointIntercepted(960, 740)).toBe(false)
hud.leftPanel = 'char'
hud.rightPanel = 'inv'
expect(mapper.handlePointerDown(200, 300, 0, 1000)).toBeNull()
expect(mapper.handlePointerDown(800, 300, 0, 1100)).toBeNull()
expect(emitted).toHaveLength(0)
// Clicking inside the middle corridor (x=512, y=300) while both panels are open MUST emit a Move command!
const corridorRec = mapper.handlePointerDown(512, 300, 0, 1200)
expect(corridorRec).not.toBeNull()
expect(corridorRec!.command.type).toBe('Move')
expect(emitted).toHaveLength(1)
})
it('adv_22_command_mapper_dead_monster_exclusion_shift_standstill_and_escape_cascade', () => {
const world = new ClientWorld()
world.selfId = 1
world.apply({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: asUnitId(1), classId: 1, name: 'Sorc', x: asSubTile(5000), y: asSubTile(5000) },
})
world.apply({
type: 'ActiveSkill',
unitId: 1,
hand: 'left',
skill: 36, // Fire Bolt
})
world.apply({
type: 'ActiveSkill',
unitId: 1,
hand: 'right',
skill: 54, // Teleport
})
// Place a dead monster (mode = 12, lifePct = 0) at (5002, 5000)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 'monster',
id: asUnitId(99),
classId: 5,
x: asSubTile(5002),
y: asSubTile(5000),
mode: 12,
lifePct: 0,
},
})
const hud = new HudManager()
hud.resize(800, 600)
const camera = new ViewportCamera(800, 600)
camera.update(5000, 5000)
const emitted: ClientCommand[] = []
let escapeMenuCalls = 0
const hudModel = new HudModel({
world,
hudManager: hud,
onCommand: (cmd: ClientCommand) => {
emitted.push(cmd)
},
})
const mapper = new CommandMapper({
world,
camera,
hudManager: hud,
hudModel,
rateLimiter: new OutboundRateLimiter({ moveMinIntervalMs: 0, skillMinIntervalMs: 0 }),
sendCommand: (cmd: ClientCommand) => {
emitted.push(cmd)
},
onEscapeMenu: () => {
escapeMenuCalls += 1
},
})
// 1. Clicking directly on the dead monster's screen coordinates must NOT target the corpse — it emits `Move`!
const corpseScreen = camera.subtileToScreen(5002, 5000)
const clickCorpse = mapper.handlePointerDown(corpseScreen.x, corpseScreen.y, 0, 100)
expect(clickCorpse?.command.type).toBe('Move')
// 2. Holding Shift + Left-Click emits `SkillAt` with `hand: 'left', shift: true`
mapper.handleKeyDown({ key: 'Shift', shiftKey: true })
expect(mapper.isShiftHeld).toBe(true)
const shiftLeft = mapper.handlePointerDown(corpseScreen.x, corpseScreen.y, 0, 200)
expect(shiftLeft?.command).toEqual({
type: 'SkillAt',
hand: 'left',
at: expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }),
shift: true,
})
mapper.handleKeyUp({ key: 'Shift', shiftKey: false })
expect(mapper.isShiftHeld).toBe(false)
// 3. Escape key cascade: if panels are open, first Escape closes all panels without firing `onEscapeMenu`;
// second Escape with no panels open fires `onEscapeMenu`!
hud.leftPanel = 'char'
hud.rightPanel = 'inv'
expect(mapper.handleKeyDown({ key: 'Escape' })).toBe(true)
expect(hud.leftPanel).toBe('none')
expect(hud.rightPanel).toBe('none')
expect(escapeMenuCalls).toBe(0)
expect(mapper.handleKeyDown({ key: 'Escape' })).toBe(true)
expect(escapeMenuCalls).toBe(1)
})
})
// ============================================================================
// SECTION 5: F11, F16–F20 Rate Limiter, OnlineSession, Settings, Flow & Scripts
// ============================================================================
describe('Tier 5 Adversarial Coverage — F11, F16–F20 Rate Limiter, OnlineSession, Settings & Scripts', () => {
it('adv_23_outbound_rate_limiter_duplicate_suppression_coalescing_and_token_bucket', () => {
const limiter = new OutboundRateLimiter({
moveMinIntervalMs: 120,
skillMinIntervalMs: 100,
chatMinIntervalMs: 500,
interactionMinIntervalMs: 80,
burstCapacity: 4,
refillPerSec: 10,
})
// 1. First Move at t=1000ms is allowed
const m1: ClientCommand = { type: 'Move', to: { x: asSubTile(5010), y: asSubTile(5010) }, run: true }
expect(limiter.tryAcquire(m1, 1000)).toEqual({
allowed: true,
category: 'movement',
retryAfterMs: 0,
})
// 2. Identical Move at t=1200ms (past 120ms moveMinIntervalMs, but within 350ms duplicate window) -> suppressed!
const dup = limiter.tryAcquire(m1, 1200)
expect(dup.allowed).toBe(false)
expect(dup.reason).toBe('duplicate_move')
// 3. Rapid distinct Moves within 120ms cooldown (at t=1050ms and t=1090ms) -> coalesced to latest destination!
const m2: ClientCommand = { type: 'Move', to: { x: asSubTile(5020), y: asSubTile(5020) }, run: true }
const m3: ClientCommand = { type: 'Move', to: { x: asSubTile(5030), y: asSubTile(5030) }, run: true }
const c1 = limiter.tryAcquire(m2, 1050)
expect(c1.allowed).toBe(false)
expect(c1.reason).toBe('category_cooldown')
expect(c1.coalesced).toBe(true)
const c2 = limiter.tryAcquire(m3, 1090)
expect(c2.allowed).toBe(false)
expect(c2.coalesced).toBe(true)
// Flushing before t=1120ms returns null; flushing at t=1120ms emits m3 (latest cursor target)!
expect(limiter.flushPendingMove(1110)).toBeNull()
expect(limiter.flushPendingMove(1120)).toEqual(m3)
// 4. Category isolation: SkillAt and Interact at t=1120ms do not collide with movement cooldown
expect(
limiter.tryAcquire(
{ type: 'SkillAt', at: { x: asSubTile(5030), y: asSubTile(5030) }, hand: 'right', shift: false },
1120,
).allowed,
).toBe(true)
expect(
limiter.tryAcquire({ type: 'Interact', target: { unitType: 'object', id: asUnitId(10) } }, 1120).allowed,
).toBe(true)
// 5. Global token bucket exhaustion: 4 tokens consumed at t=1000..1120ms (~3.2 left -> after 2 more, empty)
expect(limiter.tryAcquire({ type: 'Chat', text: 'hi' }, 1120).allowed).toBe(true)
const bucketEmpty = limiter.tryAcquire({ type: 'SelectSkill', skill: 54, hand: 'right' }, 1220)
expect(bucketEmpty.allowed).toBe(false)
expect(bucketEmpty.reason).toBe('token_bucket_empty')
// 6. Heartbeat (`Ping`) ALWAYS bypasses token bucket exhaustion!
expect(limiter.tryAcquire({ type: 'Ping' }, 1220)).toEqual({
allowed: true,
category: 'heartbeat',
retryAfterMs: 0,
})
})
it('adv_24_online_session_d2cap_replay_injection_and_mid_game_disconnect_recovery', () => {
const store = new SettingsStore({ storage: createMemoryStorageLike() })
const session = new OnlineSession({ settingsStore: store })
// Build a synthetic .d2cap JSONL capture containing LoadAct (0x03), AssignPlayer (0x59), WalkVerify (0x96), and LoadDone (0x04)
const pktLoadAct = new ByteWriter()
.u8(0x03)
.u8(0) // Act 1
.u32(0x12345678) // mapSeed
.u16(1) // areaId = 1 (Rogue Encampment)
.u32(0)
.toUint8Array()
const pktAssign = new ByteWriter()
.u8(0x59)
.u32(1) // unitId = 1
.u8(1) // Sorceress
.fixedCString('ReplaySorc', 16, 'gbk')
.u16(5108)
.u16(5012)
.toUint8Array()
const bwWalkVerify = new BitWriter()
bwWalkVerify.writeBits(0x96, 8)
bwWalkVerify.writeBits(100, 15) // stamina (15 bits)
bwWalkVerify.writeBits(5114, 16) // x (16 bits)
bwWalkVerify.writeBits(5018, 16) // y (16 bits)
bwWalkVerify.writeSignedBits(0, 8) // vx (8 bits)
bwWalkVerify.writeSignedBits(0, 8) // vy (8 bits)
bwWalkVerify.writeBits(0, 1) // 1-bit pad -> 72 bits = 9 bytes
const pktWalkVerify = bwWalkVerify.toUint8Array()
const pktLoadDone = new Uint8Array([0x04])
const jsonl = [
formatD2CapHeader({
format: 'd2cap-v1',
createdAt: '2026-09-29T00:00:00Z',
server: '101.37.117.183:4000',
scenario: 'adv_replay',
}),
formatD2CapEntry({ t: 10, conn: 'game', dir: 's2c', raw: pktLoadAct, packetId: 0x03 }),
formatD2CapEntry({ t: 20, conn: 'game', dir: 's2c', raw: pktAssign, packetId: 0x59 }),
formatD2CapEntry({ t: 30, conn: 'game', dir: 's2c', raw: pktWalkVerify, packetId: 0x96 }),
formatD2CapEntry({ t: 40, conn: 'game', dir: 's2c', raw: pktLoadDone, packetId: 0x04 }),
].join('\n')
// Replay through `session.loadD2CapReplay(jsonl)` + `session.replayAllD2Cap()`
expect(session.loadD2CapReplay(jsonl)).toBe(4)
const replayedFrames = session.replayAllD2Cap()
expect(replayedFrames).toBe(4)
expect(session.world.areaId).toBe(1)
expect(session.world.selfId).toBe(1)
expect(session.world.self.name).toBe('ReplaySorc')
expect(session.world.self.x).toBe(5114)
expect(session.world.self.y).toBe(5018)
expect(session.world.loading).toBe(false)
// Verify mid-game unexpected disconnect recovery via attached GameServerAdapter
const disconnectReasons: string[] = []
session.onDisconnect((r: string) => disconnectReasons.push(r))
let closeHandler: ((reason: { kind: 'remote' | 'error'; error?: Error }) => void) | null = null
const mockAdapter: GameServerAdapter = {
state: 'ingame',
stats: { rttMs: 42, bytesIn: 100, bytesOut: 50, unknownPackets: 0, opaquePackets: 0 },
send() {},
onEvent() {
return () => {}
},
onClose(cb) {
closeHandler = cb as (reason: { kind: 'remote' | 'error'; error?: Error }) => void
return () => {}
},
leave() {},
}
session.attachGameServer(mockAdapter)
expect(session.phase).toBe('ingame')
// Trigger unexpected remote socket drop -> session transitions cleanly to 'disconnected'
closeHandler!({ kind: 'remote' })
expect(session.phase).toBe('disconnected')
expect(disconnectReasons.length).toBe(1)
expect(session.disconnectReason).toBeTruthy()
session.destroy()
})
it('adv_25_settings_store_corrupt_json_fallback_and_https_bare_ip_rejection', () => {
const rawStorage = createMemoryStorageLike()
// 1. Corrupt JSON in localStorage must fall back gracefully to defaults without throwing
rawStorage.setItem('d2.client.settings.v1', '{corrupt_json: [1, 2,')
const store1 = new SettingsStore({ storage: rawStorage, storageKey: 'd2.client.settings.v1' })
expect(store1.get()).toEqual(createDefaultClientSettings())
// 2. Out-of-range fields (volume=999, viewport='invalid', automapReveal='hack') must be sanitized
rawStorage.setItem(
'd2.client.settings.v1',
JSON.stringify({
viewport: '4k_ultra',
automapReveal: 'maphack',
audioMuted: 'yes',
masterVolume: 9.5,
inspectorOpen: 123,
}),
)
const store2 = new SettingsStore({ storage: rawStorage, storageKey: 'd2.client.settings.v1' })
expect(store2.get().viewport).toBe('800x600')
expect(store2.get().automapReveal).toBe('off')
expect(store2.get().audioMuted).toBe(false)
expect(store2.get().masterVolume).toBe(1.0)
expect(store2.get().inspectorOpen).toBe(false)
// 3. HTTPS bare-IP WSS cert validation (`validateServerHost` & `deriveRealmWsEndpoints`)
const bareIpHttps = validateServerHost('101.37.117.183', 'https:')
expect(bareIpHttps.valid).toBe(false)
expect(bareIpHttps.reason).toBe('bare_ip_on_https')
const domainHttps = validateServerHost('www.laiseek.xyz', 'https:')
expect(domainHttps.valid).toBe(true)
expect(domainHttps.normalizedHost).toBe('www.laiseek.xyz')
const bareIpHttp = validateServerHost('101.37.117.183', 'http:')
expect(bareIpHttp.valid).toBe(true)
const endpoints = deriveRealmWsEndpoints('www.laiseek.xyz', 'https:')
expect(endpoints.bncsUrl).toBe('wss://www.laiseek.xyz/ws/bncs')
expect(endpoints.mcpUrl).toBe('wss://www.laiseek.xyz/ws/mcp')
expect(endpoints.d2gsUrl).toBe('wss://www.laiseek.xyz/ws/d2gs')
})
it('adv_26_sound_service_spatial_culling_mute_gate_and_level_environment', async () => {
const store = new SettingsStore({ storage: createMemoryStorageLike() })
const playedSfx: Array<{ id: string; vol: number }> = []
const mockBackend: AudioBackendLike = {
async init() {},
setMuted() {},
async playBGM() {},
playSFX(soundId: string, volume = 1) {
playedSfx.push({ id: soundId, vol: volume })
},
playButtonClick() {
playedSfx.push({ id: 'button', vol: 1 })
},
playFootstep() {},
playPortalEnter() {},
playWaypointActivate() {},
}
const sound = new SoundService({ store, backend: mockBackend })
// 1. Spatial attenuation & culling beyond 45 sub-tiles (`computeSpatialAudioMetrics`)
const nearMetrics = computeSpatialAudioMetrics(5005, 5000, 5000, 5000, 45)
expect(nearMetrics.culled).toBe(false)
expect(nearMetrics.gain).toBeGreaterThan(0.95)
expect(nearMetrics.pan).toBeGreaterThan(0)
const farMetrics = computeSpatialAudioMetrics(5100, 5100, 5000, 5000, 45)
expect(farMetrics.culled).toBe(true)
expect(farMetrics.gain).toBe(0)
// 2. When muted (`audioMuted: true`), `playSoundById` returns null and does not invoke backend
sound.setMuted(true)
expect(sound.playSoundById(42)).toBeNull()
expect(playedSfx).toHaveLength(0)
// 3. Unmuted spatial sound within range plays with attenuated volume; out-of-range is culled
sound.setMuted(false)
sound.setMasterVolume(0.8)
const recNear = sound.playSoundById(42, {
sourceSubX: 5005,
sourceSubY: 5000,
listenerSubX: 5000,
listenerSubY: 5000,
})
expect(recNear).not.toBeNull()
expect(playedSfx).toHaveLength(1)
const recFar = sound.playSoundById(42, {
sourceSubX: 5100,
sourceSubY: 5100,
listenerSubX: 5000,
listenerSubY: 5000,
})
expect(recFar).toBeNull()
// 4. `resolveSoundEnvironmentForLevel` across all 5 Act towns (1, 40, 75, 103, 109)
expect(resolveSoundEnvironmentForLevel(1).actId).toBe(1)
expect(resolveSoundEnvironmentForLevel(40).actId).toBe(2)
expect(resolveSoundEnvironmentForLevel(75).actId).toBe(3)
expect(resolveSoundEnvironmentForLevel(103).actId).toBe(4)
expect(resolveSoundEnvironmentForLevel(109).actId).toBe(5)
await sound.onEnterArea(109)
sound.destroy()
})
it('adv_27_d2s_unlock_checksum_zeroing_invariant_and_non_bot_guard', () => {
// Build a synthetic 800-byte v96 .d2s buffer with magic 0xAA55AA55, version 96, name 'd2webbot_sorc',
// "Woo!" quest section at D2S_QUEST_OFFSET (335), and "WS" waypoint section at D2S_WAYPOINT_OFFSET (633)
const d2s = new Uint8Array(800)
const dv = new DataView(d2s.buffer)
dv.setUint32(0, D2S_MAGIC, true) // 0xaa55aa55
dv.setUint32(4, D2S_VERSION_113C, true) // 96 (1.10–1.13c)
dv.setUint32(8, 800, true) // file size
const nameBytes = new TextEncoder().encode('d2webbot_sorc')
d2s.set(nameBytes, 20)
d2s[36] = 0x20 // Expansion flag
d2s[40] = 1 // Sorceress
d2s[43] = 1 // level 1
// Quest header "Woo!" at D2S_QUEST_OFFSET (335)
d2s.set([0x57, 0x6f, 0x6f, 0x21, 0x06, 0x00, 0x00, 0x00, 0x2a, 0x01], D2S_QUEST_OFFSET)
// Waypoint header "WS" at D2S_WAYPOINT_OFFSET (633)
d2s.set([0x57, 0x53, 0x01, 0x00, 0x00, 0x00, 0x50, 0x00], D2S_WAYPOINT_OFFSET)
writeD2sChecksum(d2s)
const unlockedBytes = unlockD2sBuffer(d2s, {
progression: 15,
minLevel: 85,
})
// Verify checksum at offset 12 matches recomputed checksum when bytes 12..15 are zeroed
const verification = verifyD2sUnlocked(unlockedBytes)
expect(verification.checksumValid).toBe(true)
expect(verification.allUnlocked).toBe(true)
expect(unlockedBytes[0x2b]).toBe(85)
// Non-bot character name ('RealPlayerHero') MUST be rejected by `validateBotCharacterName`
expect(() => validateBotCharacterName('RealPlayerHero')).toThrow(/refusing to unlock character/i)
})
it('adv_28_scripts_extract_d2net_tables_and_netproto_replay_corrupt_jsonl_resilience', () => {
// 1. Static table parity check: verify 1.13c DLL SHA-1 constants & 181 S2C / 113 C2S / 256 Huffman entries
expect(D2NET_DLL_SHA1).toBe('4b2579f301bedc03d2510c911949584d372f59ce')
expect(FOG_DLL_SHA1).toBe('fc9e40e6b81e8c65703afdaaae010aace85d0969')
expect(D2CLIENT_DLL_SHA1).toBe('26c2b536744ec8bba05dc43150b179313404d5ee')
expect(S2C_PACKET_SIZES).toHaveLength(181)
expect(C2S_PACKET_SIZES).toHaveLength(113)
expect(D2CLIENT_S2C_HANDLERS).toHaveLength(175)
expect(Object.keys(S2C_VARLEN_SWITCH_VAS)).toHaveLength(14)
expect(HUFFMAN_BIT_LENGTHS).toHaveLength(256)
expect(HUFFMAN_BIT_MASKS).toHaveLength(16)
expect(HUFFMAN_CODES).toHaveLength(256)
// 2. `parseD2CapJsonl` & `replayD2CapText` with blank lines and game packets
const pktLoadDone = new Uint8Array([0x04])
const jsonl = [
'',
formatD2CapHeader({
format: 'd2cap-v1',
createdAt: '2026-09-29T00:00:00Z',
server: '101.37.117.183:4000',
scenario: 'adv_cli',
}),
' ',
formatD2CapEntry({ t: 5, conn: 'game', dir: 's2c', raw: pktLoadDone, packetId: 0x04 }),
].join('\n')
const parsed = parseD2CapJsonl(jsonl)
expect(parsed.header?.scenario).toBe('adv_cli')
expect(parsed.records).toHaveLength(1)
const summary = replayD2CapText(jsonl, { strict: true })
expect(summary.totalRecords).toBe(1)
expect(summary.gameS2cPackets).toBe(1)
expect(summary.protocolErrors).toBe(0)
})
it('adv_29_frontend_flow_state_7_stage_adversarial_transitions_and_name_validation', () => {
const state = createInitialFlowState()
expect(state.stage).toBe('main_menu')
// 1. Character name validation edge cases (`isValidCharacterName`)
expect(isValidCharacterName('A')).toBe(false) // Too short (<2)
expect(isValidCharacterName('Abcdefghijklmnop')).toBe(false) // Too long (>15)
expect(isValidCharacterName('_BadStart')).toBe(false) // Leading underscore
expect(isValidCharacterName('BadEnd-')).toBe(false) // Trailing hyphen
expect(isValidCharacterName('Two--Hyphens')).toBe(false) // Consecutive hyphens
expect(isValidCharacterName('Two__Unders')).toBe(false) // Consecutive underscores
expect(isValidCharacterName('Has Space')).toBe(false) // Space
expect(isValidCharacterName('Valid_Hero-1')).toBe(true)
// 2. Stage 1 -> Stage 2 (`bnet_connecting` -> 18 ticks -> `bnet_login`) -> Stage 3 (`char_create` when roster empty)
clickBattleNetButton(state)
expect(state.stage).toBe('bnet_connecting')
for (let i = 0; i < 18; i++) {
stepFlowTick(state)
}
expect(state.stage).toBe('bnet_login')
state.accountName = 'AdvAccount'
state.accountPassword = 'secret'
submitBattleNetLogin(state)
expect(state.stage).toBe('char_create')
// 3. Campfire class hover & selection: selecting Sorceress ('so') then switching to Necromancer ('ne')
// puts Sorceress into backward walk ('bw') and Necromancer into forward walk ('fw')!
hoverCampfireClass(state, 'so')
expect(state.campfireClasses.so.phase).toBe('nu2')
selectCampfireClass(state, 'so')
expect(state.selectedClass).toBe('so')
expect(state.campfireClasses.so.phase).toBe('fw')
selectCampfireClass(state, 'ne')
expect(state.selectedClass).toBe('ne')
expect(state.campfireClasses.so.phase).toBe('bw')
expect(state.campfireClasses.ne.phase).toBe('fw')
// Step campfire animation ticks until 'fw' completes into 'nu3' and 'bw' returns to 'nu2' (while hovered) -> 'nu1' (when unhovered)
for (let i = 0; i < 65; i++) {
stepFlowTick(state)
}
expect(state.campfireClasses.ne.phase).toBe('nu3')
expect(state.campfireClasses.so.phase).toBe('nu2')
hoverCampfireClass(state, null)
expect(state.campfireClasses.so.phase).toBe('nu1')
// 4. Submit character create -> Stage 4 (`char_select`) -> Stage 5 (`bnet_lobby`) -> Stage 6 (`door_loading`) -> Stage 7 (`in_game`)
state.charName = 'AdvNecro'
const created = submitCharacterCreate(state)
expect(created).not.toBeNull()
expect(state.stage).toBe('char_select')
expect(state.roster).toHaveLength(1)
expect(confirmCharacterSelect(state)).toBe(true)
expect(state.stage).toBe('bnet_lobby')
toggleChatGem(state)
expect(state.gemActivated).toBe(true)
state.gameName = 'AdvRoom1'
expect(submitCreateGame(state)).toBe(true)
expect(state.stage).toBe('door_loading')
// Door loading holds at frame 9 until `state.sceneReady = true` AND `stageTicks >= 30`
for (let i = 0; i < 35; i++) {
stepFlowTick(state)
}
expect(state.stage).toBe('door_loading')
expect(state.doorFrame).toBe(9)
state.sceneReady = true
stepFlowTick(state)
expect(state.stage).toBe('in_game')
})
it('adv_30_automap_view_reveal_modes_and_scene_source_viewport_synchronization', () => {
const automap = new AutomapView({ initialMode: 'off' })
// 1. Act resolution across all 5 acts (1, 40, 75, 103, 109)
expect(getActForArea(1)).toBe(1)
expect(getActForArea(40)).toBe(2)
expect(getActForArea(75)).toBe(3)
expect(getActForArea(103)).toBe(4)
expect(getActForArea(109)).toBe(5)
// 2. Set Act 1 Rogue Encampment context + reveal room + verify automap cycle ('off' -> 'level' -> 'act' -> 'off')
automap.setContext(0x11223344, 0, 1)
automap.onServerAddRoom(100, 100, 1)
expect(automap.getMode()).toBe('off')
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
automap.setMode('level')
const levelSnap = automap.buildSnapshot()
expect(levelSnap.visibleRoomCount).toBeGreaterThanOrEqual(1)
expect(levelSnap.mode).toBe('level')
automap.setMode('act')
const actSnap = automap.buildSnapshot()
expect(actSnap.visibleRoomCount).toBeGreaterThanOrEqual(levelSnap.visibleRoomCount)
expect(actSnap.mode).toBe('act')
automap.onServerRemoveRoom(100, 100, 1)
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(0)
// 3. ClientWorldSceneSource viewport profile switching (`800x600` <-> `1024x768`) & PacketInspector filter
const source = new ClientWorldSceneSource(
() => ({
tick: 1,
actId: 1,
levelId: 1,
playerSubX: 500,
playerSubY: 500,
units: [],
levelView: null,
tileAtlas: null,
}),
getViewportProfile('800x600'),
)
expect(source.getViewportProfile().width).toBe(800)
source.setViewportProfile(getViewportProfile('1024x768'))
expect(source.getViewportProfile().width).toBe(1024)
expect(source.getViewportProfile().height).toBe(768)
const tap = new InMemoryPacketTap(100)
const inspector = new PacketInspector({ tap })
tap.record({ t: 1, conn: 'game', dir: 's2c', raw: new Uint8Array([0x04]), packetId: 0x04 })
expect(inspector.getAllRows()).toHaveLength(1)
inspector.destroy()
})
it('adv_31_hud_model_reactive_sync_and_item_to_ui_conversion_edge_cases', () => {
const world = new ClientWorld()
world.selfId = 1
const hud = new HudManager()
hud.resize(800, 600)
const commands: ClientCommand[] = []
const hudModel = new HudModel({
world,
hudManager: hud,
onCommand: (c: ClientCommand) => commands.push(c),
})
// 1. Apply self player stats, skills, waypoints, and inventory items -> verify HudModel snapshot & HudManager sync
world.apply({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 'player', id: asUnitId(1), classId: 6, name: 'AdvSin', x: asSubTile(5000), y: asSubTile(5000) },
})
world.apply({ type: 'StatSet', stat: 12, value: 88 }) // Level 88
world.apply({ type: 'StatSet', stat: 0, value: 156 }) // Strength 156
world.apply({ type: 'StatSet', stat: 4, value: 15 }) // StatPts 15
world.apply({ type: 'StatSet', stat: 5, value: 4 }) // NewSkills 4
world.apply({ type: 'WaypointMenu', unitId: 10, open: [0, 1, 5] })
const snap = hudModel.getSnapshot()
expect(snap.charName).toBe('AdvSin')
expect(snap.classCode).toBe('ass')
expect(snap.level).toBe(88)
expect(snap.str).toBe(156)
expect(snap.statPoints).toBe(15)
expect(snap.skillPoints).toBe(4)
expect(snap.unlockedWaypoints).toEqual([0, 1, 5])
// 2. Rune code ('r30' = Ber Rune) and personalized ethereal item conversion (`itemDataToUiInventoryItem`)
const runeItem = makeSyntheticItemData({
id: 701,
code: 'r30',
quality: 3,
invWidth: 1,
invHeight: 1,
})
const uiRune = itemDataToUiInventoryItem(runeItem)
expect(uiRune.quality).toBe('rune')
const ethPersItem = makeSyntheticItemData({
id: 702,
code: 'uap',
quality: 7,
ethereal: true,
personalized: true,
personalizedName: 'Taodao',
defense: 211,
maxDurability: 7,
curDurability: 7,
totalSockets: 1,
stats: [{ statId: 0, param: 0, value: 2 }],
})
const uiEth = itemDataToUiInventoryItem(ethPersItem)
expect(uiEth.quality).toBe('unique')
expect(uiEth.ethereal).toBe(true)
expect(uiEth.name).toContain("Taodao's")
expect(uiEth.durability).toEqual({ current: 7, max: 7 })
expect(uiEth.sockets).toBe(1)
hudModel.dispose()
})
it('adv_32_property_stress_100_mixed_d2gs_packets_random_chunking_into_client_world', () => {
const rng = createDeterministicRng(0x99887766)
const tables = buildAdversarialItemTables()
const world = new ClientWorld()
// Generate 100 valid S->C packets mixing LoadAct, AssignPlayer, UnitMove, StatSet, WalkVerify, GameChat, Pong
const rawPackets: Uint8Array[] = []
// Packet 0: LoadAct (Act 1, areaId=1)
rawPackets.push(new ByteWriter().u8(0x03).u8(0).u32(0xabcdef01).u16(1).u32(0).toUint8Array())
// Packet 1: AssignPlayer self (unitId=1)
rawPackets.push(
new ByteWriter()
.u8(0x59)
.u32(1)
.u8(1)
.fixedCString('StressHero', 16, 'gbk')
.u16(5000)
.u16(5000)
.toUint8Array(),
)
for (let i = 2; i < 99; i++) {
const choice = i % 5
if (choice === 0) {
// 0x1D ByteToAttr (StatSet 8-bit): [0x1D, statId, value]
rawPackets.push(new Uint8Array([0x1d, 0, 50 + (i % 100)]))
} else if (choice === 1) {
// 0x96 WalkVerify (9 bytes LSB-first bit-packed)
const x = 5000 + i
const y = 5000 + (i % 20)
const bw96 = new BitWriter()
bw96.writeBits(0x96, 8)
bw96.writeBits(100, 15)
bw96.writeBits(x, 16)
bw96.writeBits(y, 16)
bw96.writeSignedBits(0, 8)
bw96.writeSignedBits(0, 8)
bw96.writeBits(0, 1)
rawPackets.push(bw96.toUint8Array())
} else if (choice === 2) {
// 0x0F PlayerMove (16 bytes)
rawPackets.push(
new ByteWriter()
.u8(0x0f)
.u8(0) // player
.u32(2) // remote player #2
.u8(0x17) // run
.u16(5100 + i)
.u16(5100 + i)
.u8(0)
.u16(5090 + i)
.u16(5090 + i)
.toUint8Array(),
)
} else if (choice === 3) {
// 0x26 GameChat
rawPackets.push(
new ByteWriter()
.u8(0x26)
.u8(0x01)
.u8(0x00)
.u8(0x00)
.u32(1)
.u8(0x00)
.u8(0x00)
.cstring('StressHero', 'gbk')
.cstring(`Tick_${i}`, 'gbk')
.toUint8Array(),
)
} else {
// 0x8F Pong (33 bytes)
const pong = new Uint8Array(33)
pong[0] = 0x8f
rawPackets.push(pong)
}
}
// Packet 99: LoadDone (0x04)
rawPackets.push(new Uint8Array([0x04]))
expect(rawPackets).toHaveLength(100)
// Group the 100 packets into random batches of 1..6 packets per Huffman-compressed block
const compressedFrames: Uint8Array[] = []
let idx = 0
while (idx < rawPackets.length) {
const batchCount = Math.min(rawPackets.length - idx, (Math.floor(rng() * 6) % 6) + 1)
let totalLen = 0
for (let k = 0; k < batchCount; k++) totalLen += rawPackets[idx + k]!.length
const block = new Uint8Array(totalLen)
let off = 0
for (let k = 0; k < batchCount; k++) {
const p = rawPackets[idx + k]!
block.set(p, off)
off += p.length
}
compressedFrames.push(encodeD2gsCompressedFrame(block))
idx += batchCount
}
let streamLen = 0
for (const f of compressedFrames) streamLen += f.length
const wireStream = new Uint8Array(streamLen)
let wOff = 0
for (const f of compressedFrames) {
wireStream.set(f, wOff)
wOff += f.length
}
// Feed wireStream in arbitrary 1..19 byte TCP fragments into D2gsS2cFramer -> decode -> ClientWorld.apply
const framer = new D2gsS2cFramer(true)
const decodedEvents: ServerEvent[] = []
let pos = 0
while (pos < wireStream.length) {
const chunkLen = Math.min(wireStream.length - pos, (Math.floor(rng() * 19) % 19) + 1)
const framed = framer.push(wireStream.subarray(pos, pos + chunkLen))
for (const pkt of framed) {
const ev = decodeD2gsS2cPacket(pkt.bytes, { tables, encoding: 'gbk', rttMs: 25 })
decodedEvents.push(ev)
world.apply(ev)
}
pos += chunkLen
}
expect(decodedEvents).toHaveLength(100)
expect(framer.bufferedTcpBytes).toBe(0)
expect(framer.bufferedPacketBytes).toBe(0)
expect(world.selfId).toBe(1)
expect(world.self.name).toBe('StressHero')
expect(world.loading).toBe(false)
expect(world.chatLog.length).toBeGreaterThan(15)
expect(world.lastRttMs).toBe(25)
})
})