1036 lines
39 KiB
TypeScript
1036 lines
39 KiB
TypeScript
/**
|
||
* Tier 3 — Cross-Feature Pairwise Integration Test Suite (`>= 20` tests, 24 total).
|
||
*
|
||
* Verifies deterministic pairwise interactions across F1–F20:
|
||
* X1 (F1 × F2) : ByteWriter/ByteReader + GBK/UTF-8 text + InMemoryPacketTap
|
||
* X2 (F1 × F3) : BitReader/BitWriter + CD-Key hash + CheckRevision + XSHA-1
|
||
* X3 (F2 × F4) : MemoryStream + BncsSession + InMemoryPacketTap handshake & login
|
||
* X4 (F4 × F5) : BNCS SID_LOGONREALMEX 86B cookie -> MCP_STARTUP 77B & MCP_CHARLIST2
|
||
* X5 (F5 × F9) : MCP_JOINGAME gameHash/gameToken -> D2GS 0x68 GameLogon & validation
|
||
* X6 (F6 × F7) : Huffman compress + D2gsS2cFramer + decodeD2gsS2cPacket coalesced frame
|
||
* X7 (F6 × F8) : Huffman + D2gsS2cFramer + 0x9C ItemAction + 0x26 Chat + 0x28 QuestInfo
|
||
* X8 (F7 × F11) : S2C map/entity/stat decoders -> ClientWorld state mirror
|
||
* X9 (F8 × F11) : S2C item/skill/chat/quest/waypoint decoders -> ClientWorld state mirror
|
||
* X10 (F9 × F10) : D2gsAdapter.send -> encodeD2gsClientCommand -> D2gsSession C2S stream
|
||
* X11 (F10 × F11): D2gsSession + D2gsAdapter live S2C stream -> ClientWorld synchronization
|
||
* X12 (F11 × F12): ClientWorld LoadAct/RoomReveal -> MapService DRLG room & collision reveal
|
||
* X13 (F12 × F13): MapService collision grid -> findPathLocal A* -> LocalMovementPredictor
|
||
* X14 (F7 × F13) : S2C 0x15 ReassignPlayer & 0x0F PlayerMove -> Predictor & Interpolator
|
||
* X15 (F5 × F14) : d2s-unlock progression & waypoint unlock + MCP bot character validation
|
||
* X16 (F15 × F16): SettingsStore persistence <-> Toolbar state & i18n synchronization
|
||
* X17 (F2 × F15) : SettingsStore serverHost -> deriveRealmWsEndpoints -> WssBnet guard
|
||
* X18 (F11 × F17): ClientWorld units -> ClientWorldSceneSource -> screen click to ClientCommand
|
||
* X19 (F16 × F17): Toolbar viewport & lighting toggles -> SceneSource & LightGrid rendering
|
||
* X20 (F12 × F18): AutomapView off/level/act modes + 0x07/0x08 room reveal + 1024x768 HUD dock
|
||
* X21 (F15 × F19): SettingsStore mute <-> SoundService BGM/spatial SFX + OPFS HTTP Range cache
|
||
* X22 (F9 × F20) : OutboundRateLimiter coalescing & anti-flood -> encodeD2gsClientCommand
|
||
* X23 (F2 × F20) : InMemoryPacketTap -> PacketInspector filter, JSONL export & replay
|
||
* X24 (F16 × F20): Toolbar inspector toggle + diagnostic toast <-> PacketInspector state
|
||
*/
|
||
|
||
import path from 'node:path'
|
||
import { beforeAll, describe, expect, it } from 'vitest'
|
||
|
||
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 { decodeText, encodeText } from '../../src/netproto/core/text-codec.ts'
|
||
import { InMemoryPacketTap } from '../../src/netproto/core/packet-tap.ts'
|
||
import { asSubTile, asUnitId } from '../../src/netproto/domain/ids.ts'
|
||
import { createMemoryStreamPair } from '../../src/netproto/transport/memory-stream.ts'
|
||
import {
|
||
createWsBridgeResolver,
|
||
resetActiveWssBnetConnectionsForTesting,
|
||
resolveEndpointUrl,
|
||
} from '../../src/netproto/transport/endpoint.ts'
|
||
import { xsha1Words } from '../../src/netproto/crypto/xsha1.ts'
|
||
import { doubleHash, hashBnetPassword } from '../../src/netproto/crypto/bnet-hash.ts'
|
||
import { createSyntheticCdKeyPair, hashD2CdKey } from '../../src/netproto/crypto/cdkey.ts'
|
||
import { D2XP_113C_VERSION_CHECK, getCheckRevision113c } from '../../src/netproto/crypto/check-revision.ts'
|
||
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
|
||
import {
|
||
BncsOpcode,
|
||
decodeBncsS2cPacket,
|
||
encodeBncsPing,
|
||
} from '../../src/netproto/bncs/packets.ts'
|
||
import { BncsSession } from '../../src/netproto/bncs/session.ts'
|
||
import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts'
|
||
import {
|
||
encodeMcpStartup,
|
||
McpOpcode,
|
||
} from '../../src/netproto/mcp/packets.ts'
|
||
import { McpSession } from '../../src/netproto/mcp/session.ts'
|
||
import {
|
||
defaultD2gsHuffmanCodec,
|
||
wrapD2gsCompressedBlock,
|
||
} from '../../src/netproto/d2gs/compression.ts'
|
||
import { D2gsS2cFramer } from '../../src/netproto/d2gs/framing-s2c.ts'
|
||
import { validateC2sPacket } from '../../src/netproto/d2gs/framing-c2s.ts'
|
||
import {
|
||
decodeD2gsS2cPacket,
|
||
encodeD2gsClientCommand,
|
||
} from '../../src/netproto/d2gs/registry.ts'
|
||
import { ITEM_FLAG_COMPACT } from '../../src/netproto/d2gs/items/item-types.ts'
|
||
import { encodeC2sGameLogon } from '../../src/netproto/d2gs/c2s/session.ts'
|
||
import { D2gsSession } from '../../src/netproto/d2gs/session.ts'
|
||
import { D2gsAdapter } from '../../src/netproto/d2gs/adapter.ts'
|
||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||
import { getCanonicalItemDataTables } from '../../src/client/world/item-tables-provider.ts'
|
||
import { MapService } from '../../src/client/map/map-service.ts'
|
||
import type { TileAtlas } from '../../src/client/map/tile-atlas.ts'
|
||
import { findPathLocal } from '../../src/client/motion/pathfind.ts'
|
||
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
|
||
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
|
||
import {
|
||
D2S_MAGIC,
|
||
D2S_QUEST_OFFSET,
|
||
D2S_VERSION_113C,
|
||
D2S_WAYPOINT_OFFSET,
|
||
unlockD2sBuffer,
|
||
validateBotCharacterName,
|
||
verifyD2sUnlocked,
|
||
writeD2sChecksum,
|
||
} from '../../tools/d2s-unlock.ts'
|
||
import {
|
||
deriveRealmWsEndpoints,
|
||
validateServerHost,
|
||
} from '../../src/client/settings/client-settings.ts'
|
||
import {
|
||
SettingsStore,
|
||
type StorageLike,
|
||
} from '../../src/client/settings/settings-store.ts'
|
||
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
|
||
import { tToolbar } from '../../src/client/i18n/lang.ts'
|
||
import {
|
||
getViewportProfile,
|
||
subTileToScreen,
|
||
VIEWPORT_1024x768,
|
||
} from '../../src/client/view/viewport-profile.ts'
|
||
import {
|
||
ClientWorldSceneSource,
|
||
type ClientSceneWorldAdapter,
|
||
} from '../../src/client/view/scene-source.ts'
|
||
import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts'
|
||
import { applyLightingInputs } from '../../src/client/scene/lighting-presets.ts'
|
||
import { translateScreenClickToClientCommand } from '../../src/client/scene/mouse-controller.ts'
|
||
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
|
||
import { computeDockingLayout } from '../../src/client/ui/hud-manager.ts'
|
||
import { AutomapView } from '../../src/client/automap/automap-view.ts'
|
||
import {
|
||
computeSpatialAudioMetrics,
|
||
SoundService,
|
||
type AudioBackendLike,
|
||
} from '../../src/client/audio/sound-service.ts'
|
||
import {
|
||
HttpRangeMpqSource,
|
||
InMemoryChunkCache,
|
||
OpfsMpqManager,
|
||
} from '../../src/client/assets/opfs-mpq.ts'
|
||
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
||
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
|
||
import { LightGrid } from '../../src/common/drlg/light-grid.ts'
|
||
|
||
class MemoryStorage implements StorageLike {
|
||
readonly data = new Map<string, string>()
|
||
getItem(key: string): string | null {
|
||
return this.data.get(key) ?? null
|
||
}
|
||
setItem(key: string, value: string): void {
|
||
this.data.set(key, value)
|
||
}
|
||
removeItem(key: string): void {
|
||
this.data.delete(key)
|
||
}
|
||
}
|
||
|
||
const itemTables = getCanonicalItemDataTables()
|
||
|
||
let sharedMapService: MapService
|
||
let sharedTileAtlas: TileAtlas
|
||
|
||
beforeAll(async () => {
|
||
sharedMapService = await MapService.fromPackedDir(path.resolve('public/act-packs/tiles'))
|
||
sharedTileAtlas = sharedMapService.getTileAtlas()!
|
||
}, 60_000)
|
||
|
||
function buildSynthetic0x9CWorldItemPacket(itemId = 42, worldX = 5100, worldY = 5200): Uint8Array {
|
||
const bw = new BitWriter()
|
||
bw.writeBits(ITEM_FLAG_COMPACT, 32)
|
||
bw.writeBits(101, 10) // version
|
||
bw.writeBits(3, 3) // location = 3 (ground)
|
||
bw.writeBits(worldX, 16)
|
||
bw.writeBits(worldY, 16)
|
||
bw.writeAscii('hp1 ')
|
||
const bitData = bw.toUint8Array()
|
||
const w = new ByteWriter(8 + bitData.length)
|
||
w.u8(0x9c)
|
||
w.u8(0x00)
|
||
w.u8(8 + bitData.length)
|
||
w.u8(0x00)
|
||
w.u32LE(itemId)
|
||
w.bytes(bitData)
|
||
return w.toUint8Array()
|
||
}
|
||
|
||
function createSyntheticD2sBuffer(charName = 'd2webbot_sorc'): Uint8Array {
|
||
const buf = new Uint8Array(765)
|
||
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
||
view.setUint32(0x00, D2S_MAGIC, true)
|
||
view.setUint32(0x04, D2S_VERSION_113C, true)
|
||
view.setUint32(0x08, buf.byteLength, true)
|
||
for (let i = 0; i < Math.min(15, charName.length); i++) {
|
||
buf[0x14 + i] = charName.charCodeAt(i) & 0x7f
|
||
}
|
||
buf[0x24] = 0x20
|
||
buf[0x25] = 0x00
|
||
buf[0x2b] = 1
|
||
|
||
buf[D2S_QUEST_OFFSET] = 0x57
|
||
buf[D2S_QUEST_OFFSET + 1] = 0x6f
|
||
buf[D2S_QUEST_OFFSET + 2] = 0x6f
|
||
buf[D2S_QUEST_OFFSET + 3] = 0x21
|
||
view.setUint32(D2S_QUEST_OFFSET + 4, 6, true)
|
||
view.setUint16(D2S_QUEST_OFFSET + 8, 298, true)
|
||
|
||
buf[D2S_WAYPOINT_OFFSET] = 0x57
|
||
buf[D2S_WAYPOINT_OFFSET + 1] = 0x53
|
||
view.setUint32(D2S_WAYPOINT_OFFSET + 2, 1, true)
|
||
view.setUint16(D2S_WAYPOINT_OFFSET + 6, 80, true)
|
||
for (let diff = 0; diff < 3; diff++) {
|
||
const wpBase = 0x281 + diff * 24
|
||
buf[wpBase] = 0x02
|
||
buf[wpBase + 1] = 0x01
|
||
buf[wpBase + 2] = 0x01
|
||
}
|
||
writeD2sChecksum(buf)
|
||
return buf
|
||
}
|
||
|
||
describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
|
||
it('X1 (F1 × F2): ByteWriter/ByteReader + GBK/UTF-8 text + InMemoryPacketTap round-trip bilingual packet frames', () => {
|
||
const tap = new InMemoryPacketTap()
|
||
const writer = new ByteWriter()
|
||
writer.u8(0x26)
|
||
writer.u32LE(1001)
|
||
const gbkSample = new Uint8Array([0xb0, 0xb5, 0xba, 0xda]) // "暗黑" in GBK
|
||
writer.bytes(gbkSample)
|
||
writer.u8(0)
|
||
writer.cstring('Stay awhile and listen!')
|
||
const raw = writer.toUint8Array()
|
||
|
||
tap.record({ t: 12, conn: 'game', dir: 's2c', packetId: 0x26, raw })
|
||
expect(tap.entries).toHaveLength(1)
|
||
|
||
const reader = new ByteReader(tap.entries[0]!.raw)
|
||
expect(reader.u8()).toBe(0x26)
|
||
expect(reader.u32LE()).toBe(1001)
|
||
const gbkBytes = reader.bytes(4)
|
||
expect(reader.u8()).toBe(0)
|
||
expect(decodeText(gbkBytes, 'gbk')).toBe('暗黑')
|
||
expect(reader.cstring()).toBe('Stay awhile and listen!')
|
||
expect(decodeText(encodeText('暗黑破坏神II', 'utf-8'), 'utf-8')).toBe('暗黑破坏神II')
|
||
})
|
||
|
||
it('X2 (F1 × F3): BitWriter/BitReader + CD-Key hash + CheckRevision + XSHA-1 serialize and verify auth primitives', () => {
|
||
const pair = createSyntheticCdKeyPair(7)
|
||
const keyHash = hashD2CdKey(0x11223344, 0x55667788, pair.classic)
|
||
const rev = getCheckRevision113c()
|
||
const pwBytes = doubleHash(0x11223344, 0x55667788, hashBnetPassword('secret'))
|
||
const pwFirstWord = new DataView(pwBytes.buffer, pwBytes.byteOffset, pwBytes.byteLength).getUint32(0, true)
|
||
|
||
const bw = new BitWriter()
|
||
bw.writeBits(rev.exeVersion, 32)
|
||
bw.writeBits(rev.exeHash, 32)
|
||
bw.writeBits(keyHash.productValue, 32)
|
||
bw.writeBits(keyHash.publicValue, 32)
|
||
bw.writeBits(pwFirstWord, 32)
|
||
const packed = bw.toUint8Array()
|
||
|
||
const br = new BitReader(packed)
|
||
expect(br.readBits(32) >>> 0).toBe(D2XP_113C_VERSION_CHECK.exeVersion >>> 0)
|
||
expect(br.readBits(32) >>> 0).toBe(rev.exeHash >>> 0)
|
||
expect(br.readBits(32) >>> 0).toBe(keyHash.productValue >>> 0)
|
||
expect(br.readBits(32) >>> 0).toBe(keyHash.publicValue >>> 0)
|
||
expect(br.readBits(32) >>> 0).toBe(pwFirstWord >>> 0)
|
||
expect(xsha1Words(packed)).toHaveLength(5)
|
||
})
|
||
|
||
it('X3 (F2 × F4): MemoryStream + BncsSession + InMemoryPacketTap complete BNCS handshake, SID_PING, and login', async () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
const tap = new InMemoryPacketTap()
|
||
const bncs = new BncsSession(clientStream, { clientToken: 0x12345678, packetTap: tap })
|
||
const serverFramer = new BncsFramer()
|
||
let initByteSeen = false
|
||
|
||
serverStream.onData((chunk) => {
|
||
let data = chunk
|
||
if (!initByteSeen && data[0] === 0x01) {
|
||
initByteSeen = true
|
||
data = data.subarray(1)
|
||
}
|
||
for (const pkt of serverFramer.push(data)) {
|
||
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
|
||
serverStream.write(encodeBncsPing(0xaabbccdd))
|
||
const w50 = new ByteWriter()
|
||
w50.u32LE(0)
|
||
w50.u32LE(0x87654321)
|
||
w50.u32LE(0)
|
||
w50.u32LE(0)
|
||
w50.u32LE(0)
|
||
w50.cstring('ver-IX86-1.mpq')
|
||
w50.cstring('A=1 B=2 C=3 4 A=A^S B=B^C C=C^A A=A^B')
|
||
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w50.toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
|
||
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0])))
|
||
} else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
|
||
serverStream.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0])))
|
||
}
|
||
}
|
||
})
|
||
|
||
await bncs.handshake()
|
||
const login = await bncs.login('d2webbot1', 'pass')
|
||
expect(login.status).toBe(0)
|
||
expect(tap.entries.some((e) => e.conn === 'bnet' && e.packetId === BncsOpcode.SID_PING)).toBe(true)
|
||
bncs.close()
|
||
})
|
||
|
||
it('X4 (F4 × F5): BNCS SID_LOGONREALMEX 86-byte realm cookie pipes directly into McpSession.startup and listChars', async () => {
|
||
const w3e = new ByteWriter()
|
||
w3e.u32LE(0x11223344) // mcpCookie
|
||
w3e.u32LE(0) // mcpStatus
|
||
w3e.bytes(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])) // mcpChunk1 (8B)
|
||
w3e.bytes(new Uint8Array([101, 37, 117, 183])) // IP 101.37.117.183
|
||
w3e.u16BE(6113) // port in network byte order at offset 20
|
||
w3e.u16LE(0)
|
||
w3e.bytes(new Uint8Array(48).fill(0x55)) // mcpChunk2 (48B)
|
||
w3e.cstring('d2webbot1')
|
||
const [realmPkt] = new BncsFramer().push(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w3e.toUint8Array()))
|
||
const decoded = decodeBncsS2cPacket(realmPkt!)
|
||
expect(decoded.type).toBe('SID_LOGONREALMEX')
|
||
if (decoded.type !== 'SID_LOGONREALMEX') throw new Error('expected SID_LOGONREALMEX')
|
||
|
||
const startupFrame = encodeMcpStartup(decoded.data)
|
||
expect(startupFrame.byteLength).toBe(77)
|
||
|
||
const [mcpClient, mcpServer] = createMemoryStreamPair()
|
||
const mcp = new McpSession(mcpClient)
|
||
let initSeen = false
|
||
const mcpFramer = new McpFramer()
|
||
mcpServer.onData((chunk) => {
|
||
let data = chunk
|
||
if (!initSeen && data[0] === 0x01) {
|
||
initSeen = true
|
||
data = data.subarray(1)
|
||
}
|
||
for (const pkt of mcpFramer.push(data)) {
|
||
if (pkt.id === McpOpcode.MCP_STARTUP) {
|
||
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0])))
|
||
} else if (pkt.id === McpOpcode.MCP_CHARLIST2) {
|
||
const w = new ByteWriter()
|
||
w.u16LE(8)
|
||
w.u32LE(1)
|
||
w.u16LE(1)
|
||
w.u32LE(0x7fffffff)
|
||
w.cstring('d2webbot_sorc')
|
||
const stat = new Uint8Array(33).fill(0xff)
|
||
stat[0] = 0x84
|
||
stat[1] = 0x80
|
||
stat[13] = 0x02 // Sorceress
|
||
stat[25] = 80 // Level 80
|
||
stat[26] = 0xa0 // Expansion
|
||
stat[27] = 0x80
|
||
w.bytes(stat)
|
||
w.u8(0)
|
||
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, w.toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
|
||
const status = await mcp.startup(decoded.data)
|
||
expect(status).toBe(0)
|
||
const chars = await mcp.listChars(8)
|
||
expect(chars).toHaveLength(1)
|
||
expect(chars[0]!.name).toBe('d2webbot_sorc')
|
||
expect(chars[0]!.level).toBe(80)
|
||
mcp.close()
|
||
})
|
||
|
||
it('X5 (F5 × F9): McpSession.joinGame credentials feed encodeC2sGameLogon (0x68, 37B) and validateC2sPacket', async () => {
|
||
const [mcpClient, mcpServer] = createMemoryStreamPair()
|
||
const mcp = new McpSession(mcpClient)
|
||
let initSeen = false
|
||
const mcpFramer = new McpFramer()
|
||
mcpServer.onData((chunk) => {
|
||
let data = chunk
|
||
if (!initSeen && data[0] === 0x01) {
|
||
initSeen = true
|
||
data = data.subarray(1)
|
||
}
|
||
for (const pkt of mcpFramer.push(data)) {
|
||
if (pkt.id === McpOpcode.MCP_STARTUP) {
|
||
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0])))
|
||
} else if (pkt.id === McpOpcode.MCP_CHARLOGON) {
|
||
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_CHARLOGON, new Uint8Array([0, 0, 0, 0])))
|
||
} else if (pkt.id === McpOpcode.MCP_JOINGAME) {
|
||
const reqId = new ByteReader(pkt.payload).u16LE()
|
||
const w = new ByteWriter()
|
||
w.u16LE(reqId)
|
||
w.u16LE(0x0042) // gameToken
|
||
w.u16LE(0) // unknown
|
||
w.bytes(new Uint8Array([101, 37, 117, 183])) // d2gsIp
|
||
w.u32LE(0xdeadbeef) // gameHash
|
||
w.u32LE(0) // status = 0 (OK)
|
||
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_JOINGAME, w.toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
|
||
await mcp.startup({
|
||
mcpCookie: 1,
|
||
mcpStatus: 0,
|
||
mcpChunk1: new Uint8Array(8),
|
||
mcpIp: '101.37.117.183',
|
||
mcpPort: 6113,
|
||
mcpChunk2: new Uint8Array(48),
|
||
uniqueName: 'bot',
|
||
})
|
||
await mcp.charLogon('d2webbot_sorc')
|
||
const joinRes = await mcp.joinGame('testgame', 'pw')
|
||
expect(joinRes.status).toBe(0)
|
||
expect(joinRes.gameToken).toBe(0x0042)
|
||
expect(joinRes.gameHash).toBe(0xdeadbeef)
|
||
|
||
const logonPkt = encodeC2sGameLogon({
|
||
gameHash: joinRes.gameHash,
|
||
gameToken: joinRes.gameToken,
|
||
charClass: 1,
|
||
charName: 'd2webbot_sorc',
|
||
})
|
||
expect(logonPkt.byteLength).toBe(37)
|
||
expect(() => validateC2sPacket(logonPkt)).not.toThrow()
|
||
mcp.close()
|
||
})
|
||
|
||
it('X6 (F6 × F7): Huffman compression + D2gsS2cFramer + decodeD2gsS2cPacket decode coalesced S2C world block', () => {
|
||
const framer = new D2gsS2cFramer()
|
||
framer.push(new Uint8Array([0xaf, 0x01]))
|
||
|
||
// Build raw block containing: 0x03 (LoadAct, 12B) + 0x07 (AddRoomData, 6B) + 0x15 (ReassignPlayer, 11B)
|
||
const w = new ByteWriter()
|
||
w.u8(0x03)
|
||
w.u8(0)
|
||
w.u32LE(0x12345678)
|
||
w.u16LE(1)
|
||
w.u32LE(0)
|
||
w.u8(0x07)
|
||
w.u16LE(1020)
|
||
w.u16LE(1000)
|
||
w.u8(1)
|
||
w.u8(0x15)
|
||
w.u8(0)
|
||
w.u32LE(1)
|
||
w.u16LE(5100)
|
||
w.u16LE(5050)
|
||
w.u8(1)
|
||
|
||
const block = wrapD2gsCompressedBlock(defaultD2gsHuffmanCodec.compress(w.toUint8Array()))
|
||
const framed = framer.push(block)
|
||
expect(framed.map((p) => p.packetId)).toEqual([0x03, 0x07, 0x15])
|
||
|
||
const events = framed.map((p) => decodeD2gsS2cPacket(p.bytes, { tables: itemTables }))
|
||
expect(events.map((e) => e.type)).toEqual(['LoadAct', 'RoomReveal', 'UnitReassign'])
|
||
})
|
||
|
||
it('X7 (F6 × F8): Huffman + D2gsS2cFramer + 0x9C ItemAction + 0x26 Chat + 0x28 QuestInfo decode from single compressed block', () => {
|
||
const framer = new D2gsS2cFramer()
|
||
framer.push(new Uint8Array([0xaf, 0x01]))
|
||
|
||
const itemPkt = buildSynthetic0x9CWorldItemPacket(88, 5112, 5044)
|
||
const chatW = new ByteWriter()
|
||
chatW.u8(0x26)
|
||
chatW.u8(0x01)
|
||
chatW.u8(0x00)
|
||
chatW.u8(0x00)
|
||
chatW.u32LE(1)
|
||
chatW.u8(0x00)
|
||
chatW.u8(0x00)
|
||
chatW.cstring('Akara')
|
||
chatW.cstring('There is a place of great evil in the wilderness.')
|
||
const chatPkt = chatW.toUint8Array()
|
||
|
||
const questPkt = new Uint8Array(103)
|
||
questPkt[0] = 0x28
|
||
questPkt[1] = 0
|
||
questPkt[2] = 1
|
||
|
||
const combined = new Uint8Array(itemPkt.length + chatPkt.length + questPkt.length)
|
||
combined.set(itemPkt, 0)
|
||
combined.set(chatPkt, itemPkt.length)
|
||
combined.set(questPkt, itemPkt.length + chatPkt.length)
|
||
|
||
const block = wrapD2gsCompressedBlock(defaultD2gsHuffmanCodec.compress(combined))
|
||
const pkts = framer.push(block)
|
||
expect(pkts.map((p) => p.packetId)).toEqual([0x9c, 0x26, 0x28])
|
||
|
||
const decoded = pkts.map((p) => decodeD2gsS2cPacket(p.bytes, { tables: itemTables }))
|
||
expect(decoded[0]!.type).toBe('ItemEvent')
|
||
expect(decoded[1]!.type).toBe('Chat')
|
||
expect(decoded[2]!.type).toBe('QuestState')
|
||
})
|
||
|
||
it('X8 (F7 × F11): S2C map, entity, movement, and attribute decoders populate ClientWorld state', () => {
|
||
const world = new ClientWorld()
|
||
|
||
// 0x03 LoadAct
|
||
const w03 = new ByteWriter()
|
||
w03.u8(0x03)
|
||
w03.u8(0)
|
||
w03.u32LE(0xdeadbeef)
|
||
w03.u16LE(1)
|
||
w03.u32LE(0)
|
||
world.apply(decodeD2gsS2cPacket(w03.toUint8Array(), { tables: itemTables }))
|
||
|
||
// 0x59 AssignPlayer (self spawn)
|
||
const w59 = new ByteWriter()
|
||
w59.u8(0x59)
|
||
w59.u32LE(1)
|
||
w59.u8(1) // Sorceress
|
||
const nameBytes = new Uint8Array(16)
|
||
nameBytes.set(encodeText('d2webbot_sorc', 'utf-8'))
|
||
w59.bytes(nameBytes)
|
||
w59.u16LE(5100)
|
||
w59.u16LE(5050)
|
||
world.apply(decodeD2gsS2cPacket(w59.toUint8Array(), { tables: itemTables }))
|
||
|
||
// 0xAC AssignNpc
|
||
const wac = new ByteWriter()
|
||
wac.u8(0xac)
|
||
wac.u32LE(300)
|
||
wac.u16LE(148) // Akara
|
||
wac.u16LE(5120)
|
||
wac.u16LE(5040)
|
||
wac.u8(128)
|
||
wac.u8(13)
|
||
world.apply(decodeD2gsS2cPacket(wac.toUint8Array(), { tables: itemTables }))
|
||
|
||
// 0x1D AttributeByte (Stat 0 Strength = 85)
|
||
world.apply(decodeD2gsS2cPacket(new Uint8Array([0x1d, 0, 85]), { tables: itemTables }))
|
||
|
||
expect(world.mapSeed).toBe(0xdeadbeef)
|
||
expect(world.areaId).toBe(1)
|
||
expect(world.selfId).toBe(1)
|
||
expect(world.self.x).toBe(5100)
|
||
expect(world.getUnit('monster', 300)?.classId).toBe(148)
|
||
expect(world.self.stats.get(0)).toBe(85)
|
||
})
|
||
|
||
it('X9 (F8 × F11): S2C item, skill, chat, quest, and waypoint decoders update ClientWorld state', () => {
|
||
const world = new ClientWorld()
|
||
world.apply(decodeD2gsS2cPacket(buildSynthetic0x9CWorldItemPacket(99, 5105, 5055), { tables: itemTables }))
|
||
expect(world.getUnit('item', 99)?.x).toBe(5105)
|
||
|
||
// 0x23 AssignSkill
|
||
const w23 = new ByteWriter()
|
||
w23.u8(0x23)
|
||
w23.u8(0x00)
|
||
w23.u32LE(0)
|
||
w23.u8(0) // right hand
|
||
w23.u16LE(54) // Teleport
|
||
w23.u32LE(0xffffffff)
|
||
world.apply(decodeD2gsS2cPacket(w23.toUint8Array(), { tables: itemTables }))
|
||
expect(world.self.rightSkill).toBe(54)
|
||
|
||
// 0x63 OpenWaypoint (21B)
|
||
const w63 = new ByteWriter()
|
||
w63.u8(0x63)
|
||
w63.u32LE(500) // waypoint unitId
|
||
w63.bytes(new Uint8Array(16).fill(0xff))
|
||
world.apply(decodeD2gsS2cPacket(w63.toUint8Array(), { tables: itemTables }))
|
||
expect(world.waypoints.size).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('X10 (F9 × F10): D2gsAdapter.send encodes ClientCommand variants and writes valid C2S frames over D2gsSession', async () => {
|
||
const [client, server] = createMemoryStreamPair()
|
||
const c2sReceived: Uint8Array[] = []
|
||
server.onData((chunk) => {
|
||
c2sReceived.push(chunk)
|
||
if (chunk[0] === 0x68) {
|
||
server.write(
|
||
wrapD2gsCompressedBlock(
|
||
defaultD2gsHuffmanCodec.compress(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04])),
|
||
),
|
||
)
|
||
}
|
||
})
|
||
|
||
const session = new D2gsSession({
|
||
stream: client,
|
||
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'd2webbot_sorc' },
|
||
})
|
||
const adapter = new D2gsAdapter({ session, tables: itemTables })
|
||
server.write(new Uint8Array([0xaf, 0x01]))
|
||
|
||
adapter.send({ type: 'Move', run: true, to: { x: 5110, y: 5060 } })
|
||
adapter.send({ type: 'SelectSkill', skill: 54, hand: 'right' })
|
||
adapter.send({ type: 'SkillAt', hand: 'right', at: { x: 5120, y: 5070 } })
|
||
adapter.send({ type: 'Interact', target: { unitType: 2, id: 500 } })
|
||
adapter.send({ type: 'WaypointGo', waypointId: 500, destAreaId: 3 })
|
||
|
||
for (const pkt of c2sReceived) {
|
||
expect(() => validateC2sPacket(pkt)).not.toThrow()
|
||
}
|
||
const opcodes = c2sReceived.map((p) => p[0])
|
||
expect(opcodes).toEqual(expect.arrayContaining([0x68, 0x6b, 0x03, 0x3c, 0x0c, 0x13, 0x49]))
|
||
await adapter.close()
|
||
})
|
||
|
||
it('X11 (F10 × F11): D2gsSession + D2gsAdapter stream S2C events directly into ClientWorld', async () => {
|
||
const [client, server] = createMemoryStreamPair()
|
||
server.onData((chunk) => {
|
||
if (chunk[0] === 0x68) {
|
||
const w = new ByteWriter()
|
||
w.bytes(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1]))
|
||
w.u8(0x02)
|
||
w.u8(0x03)
|
||
w.u8(0)
|
||
w.u32LE(0x55aa55aa)
|
||
w.u16LE(1)
|
||
w.u32LE(0)
|
||
w.u8(0x04)
|
||
w.u8(0x15)
|
||
w.u8(0)
|
||
w.u32LE(1)
|
||
w.u16LE(5112)
|
||
w.u16LE(5068)
|
||
w.u8(1)
|
||
server.write(wrapD2gsCompressedBlock(defaultD2gsHuffmanCodec.compress(w.toUint8Array())))
|
||
}
|
||
})
|
||
|
||
const session = new D2gsSession({
|
||
stream: client,
|
||
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'd2webbot_sorc' },
|
||
})
|
||
const adapter = new D2gsAdapter({ session, tables: itemTables })
|
||
const world = new ClientWorld()
|
||
adapter.onEvent((ev) => world.apply(ev))
|
||
|
||
server.write(new Uint8Array([0xaf, 0x01]))
|
||
expect(world.mapSeed).toBe(0x55aa55aa)
|
||
expect(world.areaId).toBe(1)
|
||
expect(world.self.x).toBe(5112)
|
||
expect(world.self.y).toBe(5068)
|
||
await adapter.close()
|
||
})
|
||
|
||
it('X12 (F11 × F12): ClientWorld LoadAct and RoomReveal drive MapService DRLG room reveal and collision alignment', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({ type: 'LoadAct', act: 0, mapSeed: 0x00abcdef, areaId: 1 })
|
||
const fullView = sharedMapService.onLoadActPacket({
|
||
act: 0,
|
||
mapSeed: world.mapSeed,
|
||
areaId: world.areaId,
|
||
})!
|
||
expect(fullView.rooms.length).toBeGreaterThan(0)
|
||
|
||
const room0 = fullView.rooms[0]!
|
||
world.apply({ type: 'RoomReveal', areaId: 1, tileX: room0.tileX, tileY: room0.tileY })
|
||
const addRes = sharedMapService.onAddRoomDataPacket(
|
||
{ areaId: 1, roomX: room0.tileX, roomY: room0.tileY },
|
||
{ rebuildView: true, revealMode: 'revealed-only' },
|
||
)
|
||
expect(addRes.room).not.toBeNull()
|
||
expect(sharedMapService.getActiveLevelView()?.rooms.length).toBeGreaterThanOrEqual(1)
|
||
expect(sharedTileAtlas.verifyUniversalDt1s().allValid).toBe(true)
|
||
})
|
||
|
||
it('X13 (F12 × F13): MapService collision grid feeds findPathLocal A* and LocalMovementPredictor', () => {
|
||
const view = sharedMapService.loadAct({ act: 0, seed: 0x00abcdef, areaId: 1 })!
|
||
const walkable = new Uint8Array(view.collision.length)
|
||
for (let i = 0; i < view.collision.length; i++) {
|
||
walkable[i] = (view.collision[i]! & 0x01) === 0 ? 1 : 0
|
||
}
|
||
|
||
let startX = Math.floor(view.subWidth / 2)
|
||
let startY = Math.floor(view.subHeight / 2)
|
||
outer: for (let y = 10; y < view.subHeight - 20; y++) {
|
||
for (let x = 10; x < view.subWidth - 20; x++) {
|
||
if (walkable[y * view.subWidth + x] === 1 && walkable[y * view.subWidth + (x + 8)] === 1) {
|
||
startX = x
|
||
startY = y
|
||
break outer
|
||
}
|
||
}
|
||
}
|
||
|
||
const pathRes = findPathLocal(
|
||
{ subWidth: view.subWidth, subHeight: view.subHeight, walkable },
|
||
startX,
|
||
startY,
|
||
startX + 8,
|
||
startY,
|
||
{ smooth: true },
|
||
)
|
||
expect(pathRes.reachedGoal).toBe(true)
|
||
|
||
const predictor = new LocalMovementPredictor({ initialX: startX, initialY: startY })
|
||
predictor.setPath(pathRes.path, true)
|
||
const stepped = predictor.stepTicks(15)
|
||
expect(stepped.x).toBeGreaterThan(startX)
|
||
})
|
||
|
||
it('X14 (F7 × F13): S2C 0x15 ReassignPlayer and 0x0F PlayerMove drive LocalMovementPredictor and RemoteEntityInterpolator', () => {
|
||
const predictor = new LocalMovementPredictor({ initialX: 5100, initialY: 5050 })
|
||
const interp = new RemoteEntityInterpolator()
|
||
|
||
const w15Small = new ByteWriter()
|
||
w15Small.u8(0x15)
|
||
w15Small.u8(0)
|
||
w15Small.u32LE(1)
|
||
w15Small.u16LE(5103)
|
||
w15Small.u16LE(5050)
|
||
w15Small.u8(0)
|
||
const evSmall = decodeD2gsS2cPacket(w15Small.toUint8Array(), { tables: itemTables })
|
||
if (evSmall.type === 'UnitReassign') {
|
||
const rec = predictor.reconcileServerPosition(evSmall.at, evSmall.snap)
|
||
expect(rec.mode).toBe('rubber-band')
|
||
}
|
||
|
||
const w0f = new ByteWriter()
|
||
w0f.u8(0x0f)
|
||
w0f.u8(0)
|
||
w0f.u32LE(2)
|
||
w0f.u8(0x17)
|
||
w0f.u16LE(5210)
|
||
w0f.u16LE(5100)
|
||
w0f.u8(0)
|
||
w0f.u16LE(5200)
|
||
w0f.u16LE(5100)
|
||
const evMove = decodeD2gsS2cPacket(w0f.toUint8Array(), { tables: itemTables })
|
||
expect(evMove.type).toBe('UnitMove')
|
||
if (evMove.type === 'UnitMove') {
|
||
interp.spawnOrSnap({ unitType: 0, unitId: evMove.id, x: 5200, y: 5100 })
|
||
interp.startMove({
|
||
unitType: 0,
|
||
unitId: evMove.id,
|
||
currentX: 5200,
|
||
currentY: 5100,
|
||
targetX: evMove.to.x,
|
||
targetY: evMove.to.y,
|
||
moveType: 0x17,
|
||
})
|
||
interp.update(200)
|
||
expect(interp.getEntityState(0, 2)!.x).toBeGreaterThan(5200)
|
||
}
|
||
})
|
||
|
||
it('X15 (F5 × F14): validateBotCharacterName + unlockD2sBuffer + verifyD2sUnlocked prepare bot save for MCP logon', () => {
|
||
const botName = 'd2webbot_sorc'
|
||
expect(() => validateBotCharacterName(botName)).not.toThrow()
|
||
const rawD2s = createSyntheticD2sBuffer(botName)
|
||
const unlocked = unlockD2sBuffer(rawD2s)
|
||
const verification = verifyD2sUnlocked(unlocked)
|
||
expect(verification.characterName).toBe(botName)
|
||
expect(verification.checksumValid).toBe(true)
|
||
expect(verification.allUnlocked).toBe(true)
|
||
})
|
||
|
||
it('X16 (F15 × F16): SettingsStore and Toolbar synchronize viewport, lighting, automapReveal, and language', () => {
|
||
const storage = new MemoryStorage()
|
||
const store = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
|
||
const toolbar = new Toolbar({ store })
|
||
|
||
toolbar.toggleViewport()
|
||
toolbar.setLightingPreset('fullbright')
|
||
toolbar.setAutomapReveal('act')
|
||
toolbar.toggleLanguage()
|
||
|
||
const snap = toolbar.getSnapshot()
|
||
expect(snap.settings.viewport).toBe('1024x768')
|
||
expect(snap.settings.lightingPreset).toBe('fullbright')
|
||
expect(snap.settings.automapReveal).toBe('act')
|
||
expect(snap.settings.lang).toBe('zh')
|
||
expect(snap.statusLabel).toBe(tToolbar('toolbar.status.disconnected', 'zh'))
|
||
|
||
const reloaded = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
|
||
expect(reloaded.get().viewport).toBe('1024x768')
|
||
expect(reloaded.get().lightingPreset).toBe('fullbright')
|
||
expect(reloaded.get().automapReveal).toBe('act')
|
||
expect(reloaded.get().lang).toBe('zh')
|
||
toolbar.destroy()
|
||
})
|
||
|
||
it('X17 (F2 × F15): SettingsStore serverHost drives deriveRealmWsEndpoints and WssBnet concurrency guard', async () => {
|
||
resetActiveWssBnetConnectionsForTesting()
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const res = store.setServerHost('www.laiseek.xyz', 'https:')
|
||
expect(res.valid).toBe(true)
|
||
|
||
const endpoints = deriveRealmWsEndpoints(store.get().serverHost, 'https:')
|
||
expect(endpoints.bncsUrl).toBe('wss://www.laiseek.xyz/ws/bncs')
|
||
expect(resolveEndpointUrl('bnet', store.get().serverHost, 6112, { scheme: 'wss', basePath: '/d2net' })).toBe(
|
||
'wss://www.laiseek.xyz/d2net/bnet',
|
||
)
|
||
|
||
const resolver = createWsBridgeResolver({
|
||
host: store.get().serverHost,
|
||
maxConcurrentBnetWss: 2,
|
||
streamFactory: () => createMemoryStreamPair()[0],
|
||
})
|
||
const s1 = await resolver.open('bnet', store.get().serverHost, 6112)
|
||
const s2 = await resolver.open('bnet', store.get().serverHost, 6112)
|
||
await expect(resolver.open('bnet', store.get().serverHost, 6112)).rejects.toThrow(/concurrency limit/i)
|
||
s1.close()
|
||
s2.close()
|
||
})
|
||
|
||
it('X18 (F11 × F17): ClientWorld units project into ClientWorldSceneSource and screen click translates to ClientCommand', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({ type: 'LoadAct', act: 0, mapSeed: 0x12345678, areaId: 1 })
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'player', id: 1, classId: 1, x: 100, y: 100, name: 'd2webbot_sorc' },
|
||
})
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'monster', id: 77, classId: 10, x: 106, y: 100, lifePct: 100, name: 'Fallen' },
|
||
})
|
||
|
||
const automap = new AutomapView({ initialMode: 'level' })
|
||
const levelView = automap.setContext(world.mapSeed, 0, world.areaId)
|
||
const units: RenderableUnitInput[] = [
|
||
{ kind: 'player', id: 1, classId: 1, x: 100, y: 100 },
|
||
{ kind: 'monster', id: 77, classId: 10, x: 106, y: 100, lifePct: 100, name: 'Fallen' },
|
||
]
|
||
const source = new ClientWorldSceneSource(
|
||
(): ClientSceneWorldAdapter => ({
|
||
tick: 1,
|
||
actId: 1,
|
||
levelId: 1,
|
||
playerSubX: 100,
|
||
playerSubY: 100,
|
||
units,
|
||
levelView,
|
||
}),
|
||
'1024x768',
|
||
)
|
||
|
||
const monsterScreen = subTileToScreen(106, 100, 100, 100, VIEWPORT_1024x768)
|
||
const cmd = translateScreenClickToClientCommand(monsterScreen.x, monsterScreen.y - 16, source, {
|
||
button: 'right',
|
||
runMode: true,
|
||
})
|
||
expect(cmd).toEqual({
|
||
type: 'CastRightSkillOnTarget',
|
||
unitKind: 'monster',
|
||
unitId: asUnitId(77),
|
||
})
|
||
})
|
||
|
||
it('X19 (F16 × F17): Toolbar viewport and lighting toggles drive ClientWorldSceneSource and LightGrid rendering', () => {
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const toolbar = new Toolbar({ store })
|
||
const automap = new AutomapView({ initialMode: 'level' })
|
||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||
|
||
const source = new ClientWorldSceneSource(
|
||
(): ClientSceneWorldAdapter => ({
|
||
tick: 1,
|
||
actId: 1,
|
||
levelId: 1,
|
||
playerSubX: 100,
|
||
playerSubY: 100,
|
||
units: [{ kind: 'player', id: 1, classId: 1, x: 100, y: 100 }],
|
||
levelView,
|
||
}),
|
||
store.get().viewport,
|
||
)
|
||
|
||
toolbar.toggleViewport()
|
||
source.setViewportProfile(store.get().viewport)
|
||
|
||
const lightGrid = new LightGrid()
|
||
let lastRendererLighting: unknown = 'unset'
|
||
const mockRenderer = {
|
||
setLighting(params: unknown) {
|
||
lastRendererLighting = params
|
||
},
|
||
}
|
||
|
||
toolbar.setLightingPreset('torch')
|
||
const snapTorch = renderSceneSourceFrame({
|
||
source,
|
||
lightingPreset: store.get().lightingPreset,
|
||
lightingTargets: { lightGrid, renderer: mockRenderer },
|
||
})
|
||
expect(snapTorch.viewportProfile.width).toBe(1024)
|
||
expect(lastRendererLighting).not.toBeNull()
|
||
|
||
toolbar.setLightingPreset('fullbright')
|
||
applyLightingInputs(source.buildLightingInputs(store.get().lightingPreset), {
|
||
lightGrid,
|
||
renderer: mockRenderer,
|
||
})
|
||
expect(lastRendererLighting).toBeNull()
|
||
toolbar.destroy()
|
||
})
|
||
|
||
it('X20 (F12 × F18): AutomapView off/level/act modes + S2C room reveal + 1024x768 HUD docking layout', () => {
|
||
const automap = new AutomapView({
|
||
initialMode: 'off',
|
||
actRevealLevelIds: { 1: [1, 2], 2: [40], 3: [75], 4: [103], 5: [109] },
|
||
})
|
||
const view = automap.setContext(0x00abcdef, 0, 1)
|
||
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
|
||
|
||
const firstRoom = view.rooms[0]!
|
||
automap.onServerAddRoom(firstRoom.tileX, firstRoom.tileY, 1)
|
||
expect(automap.buildSnapshot().visibleRoomCount).toBeGreaterThanOrEqual(1)
|
||
|
||
automap.setMode('level')
|
||
expect(automap.buildSnapshot().visibleRoomCount).toBe(view.rooms.length)
|
||
|
||
automap.setMode('act')
|
||
expect(automap.buildSnapshot().visibleAreaIds).toEqual([1, 2])
|
||
|
||
const dock = computeDockingLayout(1024, 768, 1, getViewportProfile('1024x768'))
|
||
expect(dock.uiScale).toBe(1)
|
||
expect(dock.channelWidthLogical).toBe(224)
|
||
})
|
||
|
||
it('X21 (F15 × F19): SettingsStore audioMuted synchronizes SoundService BGM/SFX and OpfsMpqManager caches chunks', async () => {
|
||
const playedBgm: number[] = []
|
||
const playedSfx: string[] = []
|
||
const backend: AudioBackendLike = {
|
||
async init() {},
|
||
setMuted() {},
|
||
async playBGM(act) {
|
||
playedBgm.push(act)
|
||
},
|
||
playSFX(id) {
|
||
playedSfx.push(id)
|
||
},
|
||
playButtonClick() {},
|
||
playFootstep() {},
|
||
playPortalEnter() {},
|
||
playWaypointActivate() {},
|
||
}
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const svc = new SoundService({ store, backend })
|
||
await svc.init()
|
||
|
||
await svc.onEnterArea(109) // Act 5 Harrogath
|
||
expect(playedBgm).toEqual([5])
|
||
|
||
const spatial = computeSpatialAudioMetrics(104, 100, 100, 100)
|
||
expect(spatial.culled).toBe(false)
|
||
expect(svc.playSoundById(47, { sourceSubX: 104, sourceSubY: 100, listenerSubX: 100, listenerSubY: 100 })).not.toBeNull()
|
||
|
||
store.set({ audioMuted: true })
|
||
expect(svc.isMuted()).toBe(true)
|
||
expect(svc.playSoundById(47)).toBeNull()
|
||
|
||
const cache = new InMemoryChunkCache()
|
||
const mgr = new OpfsMpqManager({
|
||
baseUrl: '/diablo2/data',
|
||
cache,
|
||
fetchFn: async () => ({
|
||
ok: true,
|
||
status: 206,
|
||
headers: { get: () => 'bytes 0-15/64' },
|
||
arrayBuffer: async () => new Uint8Array(16).fill(0xaa).buffer,
|
||
}),
|
||
})
|
||
const src = mgr.getSource('d2sfx.mpq') as HttpRangeMpqSource
|
||
await src.read(0, 16)
|
||
await src.read(0, 16)
|
||
expect(src.cacheHitsCount).toBe(1)
|
||
svc.destroy()
|
||
})
|
||
|
||
it('X22 (F9 × F20): OutboundRateLimiter coalesces rapid moves and encodes only allowed C2S commands', () => {
|
||
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
|
||
const sentFrames: Uint8Array[] = []
|
||
|
||
const trySendMove = (x: number, y: number, nowMs: number) => {
|
||
const decision = limiter.tryAcquire(
|
||
{ type: 'RunToLocation', x: asSubTile(x), y: asSubTile(y) },
|
||
nowMs,
|
||
)
|
||
if (decision.allowed) {
|
||
const frames = encodeD2gsClientCommand({ type: 'Move', run: true, to: { x, y } })
|
||
sentFrames.push(...frames)
|
||
}
|
||
}
|
||
|
||
trySendMove(5100, 5050, 1000)
|
||
trySendMove(5105, 5055, 1030)
|
||
trySendMove(5110, 5060, 1060)
|
||
expect(sentFrames).toHaveLength(1)
|
||
|
||
const flushed = limiter.flushPendingMove(1150)
|
||
expect(flushed).not.toBeNull()
|
||
if (flushed?.type === 'RunToLocation') {
|
||
const frames = encodeD2gsClientCommand({ type: 'Move', run: true, to: { x: flushed.x, y: flushed.y } })
|
||
sentFrames.push(...frames)
|
||
}
|
||
expect(sentFrames).toHaveLength(2)
|
||
expect(new ByteReader(sentFrames[1]!.subarray(1)).u16LE()).toBe(5110)
|
||
})
|
||
|
||
it('X23 (F2 × F20): InMemoryPacketTap + PacketInspector filter, export .d2cap JSONL, and replay into ClientWorld', () => {
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const tap = new InMemoryPacketTap()
|
||
const world = new ClientWorld()
|
||
const inspector = new PacketInspector({
|
||
store,
|
||
tap,
|
||
onReplayRecord: (frame, rawBytes) => {
|
||
if (frame.phase === 'd2gs' && frame.dir === 's2c') {
|
||
world.apply(decodeD2gsS2cPacket(rawBytes, { tables: itemTables }))
|
||
}
|
||
},
|
||
})
|
||
|
||
const w03 = new ByteWriter()
|
||
w03.u8(0x03)
|
||
w03.u8(0)
|
||
w03.u32LE(0x13572468)
|
||
w03.u16LE(1)
|
||
w03.u32LE(0)
|
||
tap.record({ t: 10, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() })
|
||
tap.record({ t: 20, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 77]) })
|
||
|
||
const jsonl = inspector.exportD2CapJsonl()
|
||
inspector.clear()
|
||
inspector.loadD2CapJsonl(jsonl)
|
||
inspector.replayAll()
|
||
|
||
expect(world.mapSeed).toBe(0x13572468)
|
||
expect(world.self.stats.get(0)).toBe(77)
|
||
inspector.destroy()
|
||
})
|
||
|
||
it('X24 (F16 × F20): Toolbar toggleInspector and reportDiagnostic synchronize with SettingsStore and PacketInspector', () => {
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const tap = new InMemoryPacketTap()
|
||
const toolbar = new Toolbar({ store })
|
||
const inspector = new PacketInspector({ store, tap })
|
||
|
||
expect(inspector.isOpen()).toBe(false)
|
||
toolbar.setInspectorOpen(true)
|
||
expect(inspector.isOpen()).toBe(true)
|
||
|
||
toolbar.reportDiagnostic('custom', 'Unknown S2C 0xFE')
|
||
const snap = toolbar.getSnapshot()
|
||
expect(snap.connection.phase).toBe('error')
|
||
expect(snap.diagnosticBannerText).toContain('0xFE')
|
||
expect(validateServerHost(snap.settings.serverHost, 'https:').valid).toBe(true)
|
||
|
||
inspector.destroy()
|
||
toolbar.destroy()
|
||
})
|
||
})
|