2592 lines
96 KiB
TypeScript
2592 lines
96 KiB
TypeScript
/**
|
||
* 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) -> 0x79 GoldInTrade (action 0x7900 | bySelf) -> cancel (action 0x02)
|
||
world.apply({ type: 'Trade', action: 0x01, partnerId: 2 })
|
||
expect(world.trade.active).toBe(true)
|
||
expect(world.trade.partnerId).toBe(2)
|
||
// Gold carried by a non-0x79 trade action is not attributed to either side
|
||
world.apply({ type: 'Trade', action: 0x01, partnerId: 2, gold: 12345 })
|
||
expect(world.trade.partnerGold).toBe(0)
|
||
expect(world.trade.selfGold).toBe(0)
|
||
// bySelf = 0 -> partner's offered gold only
|
||
world.apply({ type: 'Trade', action: 0x7900, gold: 50000 })
|
||
expect(world.trade.partnerGold).toBe(50000)
|
||
expect(world.trade.selfGold).toBe(0)
|
||
// bySelf != 0 -> own offered gold only
|
||
world.apply({ type: 'Trade', action: 0x7901, gold: 1200 })
|
||
expect(world.trade.selfGold).toBe(1200)
|
||
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)
|
||
})
|
||
})
|