855 lines
33 KiB
TypeScript
855 lines
33 KiB
TypeScript
/**
|
||
* Tier 4 — End-to-End Real-World Application Scenarios (`>= 10` scenarios, 12 total).
|
||
*
|
||
* Exercises full multi-step user & system workflows across F1–F20:
|
||
* S1 : Replay `01-bncs-mcp-lobby.d2cap` end-to-end verifying BNCS auth, MCP realm startup, character list & game join
|
||
* S2 : Replay `02-d2gs-town-walk-chat.d2cap` into ClientWorld, MapService, AutomapView, Predictor, Interpolator & SoundService
|
||
* S3 : Replay `03-d2gs-items-npc-skills.d2cap` into ClientWorld verifying item bitstream, NPCs, skills & canonical ItemDataTables
|
||
* S4 : Replay `04-d2gs-idle-60s.d2cap` into ClientWorld verifying 60-second heartbeat stability, Pong RTT & zero errors
|
||
* S5 : Multi-capture aggregate strict replay audit across all 4 `.d2cap` fixtures (0 unknown, 0 opaque, 0 protocol errors)
|
||
* S6 : Full `createD2OnlineFlow` lifecycle (BNCS -> MCP -> D2GS -> leaveToLobby -> close) over in-memory EndpointResolver
|
||
* S7 : End-to-end `.d2s` bot progression unlock (`unlockD2sBuffer`) + MCP bot char validation + D2GS cross-Act waypoint travel
|
||
* S8 : Full Client UI, Viewport (`800x600` <-> `1024x768`), Automap docking, i18n (`en` <-> `zh`), & Lighting (`torch` <-> `fullbright`) workflow
|
||
* S9 : Interactive screen-click -> A* pathfinding -> movement prediction -> rate-limited C2S -> S2C rubber-band & interpolation loop
|
||
* S10: End-to-end Audio & Asset streaming workflow (SettingsStore <-> SoundService spatial SFX/BGM + OPFS HTTP Range 206 cache)
|
||
* S11: Anti-flood OutboundRateLimiter + InMemoryPacketTap + PacketInspector JSONL export & strict `.d2cap` replay round-trip
|
||
* S12: Server host reconfiguration, WssBnet concurrency guard, malformed packet isolation, and clean reconnect lifecycle
|
||
*/
|
||
|
||
import path from 'node:path'
|
||
import { beforeAll, describe, expect, it } from 'vitest'
|
||
|
||
import {
|
||
asSubTile,
|
||
asUnitId,
|
||
createD2OnlineFlow,
|
||
createMemoryStreamPair,
|
||
createWsBridgeResolver,
|
||
InMemoryPacketTap,
|
||
type ByteStream,
|
||
type D2OnlineState,
|
||
type EndpointResolver,
|
||
type EndpointRole,
|
||
} from '../../src/netproto/index.ts'
|
||
import { BitWriter } from '../../src/netproto/core/bit-writer.ts'
|
||
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
|
||
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
||
import { FakeClock } from '../../src/netproto/core/clock.ts'
|
||
import { resetActiveWssBnetConnectionsForTesting } from '../../src/netproto/transport/endpoint.ts'
|
||
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
|
||
import { BncsOpcode, encodeBncsPing } from '../../src/netproto/bncs/packets.ts'
|
||
import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts'
|
||
import { McpOpcode } from '../../src/netproto/mcp/packets.ts'
|
||
import {
|
||
defaultD2gsHuffmanCodec,
|
||
wrapD2gsCompressedBlock,
|
||
} from '../../src/netproto/d2gs/compression.ts'
|
||
import {
|
||
decodeD2gsS2cPacket,
|
||
encodeD2gsClientCommand,
|
||
} from '../../src/netproto/d2gs/registry.ts'
|
||
import { ITEM_FLAG_COMPACT } from '../../src/netproto/d2gs/items/item-types.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 '../../scripts/d2s-unlock.ts'
|
||
import {
|
||
replayD2CapFile,
|
||
replayD2CapText,
|
||
} from '../../scripts/netproto-replay.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 { getLang, setLang, tToolbar } from '../../src/i18n/lang.ts'
|
||
import {
|
||
computeViewportAnchors,
|
||
getViewportProfile,
|
||
subTileToScreen,
|
||
VIEWPORT_1024x768,
|
||
VIEWPORT_800x600,
|
||
} 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 { LightGrid } from '../../src/game/engine/light-grid.ts'
|
||
import { applyLightingInputs } from '../../src/scene/frontend/lighting-presets.ts'
|
||
import { translateScreenClickToClientCommand } from '../../src/scene/frontend/mouse-controller.ts'
|
||
import { renderSceneSourceFrame } from '../../src/scene/frontend/world-renderer.ts'
|
||
import { computeDockingLayout, computeHudLayout } from '../../src/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'
|
||
|
||
// ─── Shared Test Helpers ─────────────────────────────────────────────────────
|
||
|
||
const FIXTURES_DIR = path.resolve('tests/fixtures/netproto')
|
||
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)
|
||
|
||
class MemoryStorage implements StorageLike {
|
||
readonly data = new Map<string, string>()
|
||
getItem(key: string): string | null {
|
||
return this.data.get(key) ?? null
|
||
}
|
||
setItem(key: string, value: string): void {
|
||
this.data.set(key, value)
|
||
}
|
||
removeItem(key: string): void {
|
||
this.data.delete(key)
|
||
}
|
||
}
|
||
|
||
function createMockAudioBackend(playedBgm: number[], playedSfx: string[]): AudioBackendLike {
|
||
return {
|
||
async init() {},
|
||
setMuted() {},
|
||
async playBGM(actOrId) {
|
||
playedBgm.push(actOrId)
|
||
},
|
||
playSFX(id) {
|
||
playedSfx.push(id)
|
||
},
|
||
playButtonClick() {
|
||
playedSfx.push('button')
|
||
},
|
||
playFootstep() {
|
||
playedSfx.push('step')
|
||
},
|
||
playPortalEnter() {
|
||
playedSfx.push('portal')
|
||
},
|
||
playWaypointActivate() {
|
||
playedSfx.push('wp')
|
||
},
|
||
}
|
||
}
|
||
|
||
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 4 — End-to-End Real-World Application Scenarios (S1–S12)', () => {
|
||
it('S1: Replays 01-bncs-mcp-lobby.d2cap end-to-end verifying BNCS auth, MCP realm startup, character list & game join', () => {
|
||
const capPath = path.join(FIXTURES_DIR, '01-bncs-mcp-lobby.d2cap')
|
||
const replay = replayD2CapFile(capPath, { strict: true })
|
||
|
||
expect(replay.protocolErrors).toBe(0)
|
||
expect(replay.unknownPackets).toBe(0)
|
||
expect(replay.opaquePackets).toBe(0)
|
||
expect(replay.bnetPackets).toBeGreaterThan(0)
|
||
expect(replay.realmPackets).toBeGreaterThan(0)
|
||
expect(replay.entries.some((e) => e.conn === 'bnet' && e.dir === 's2c')).toBe(true)
|
||
expect(replay.entries.some((e) => e.conn === 'realm' && e.dir === 's2c')).toBe(true)
|
||
})
|
||
|
||
it('S2: Replays 02-d2gs-town-walk-chat.d2cap end-to-end into ClientWorld, MapService, AutomapView, Predictor & SoundService', async () => {
|
||
const capPath = path.join(FIXTURES_DIR, '02-d2gs-town-walk-chat.d2cap')
|
||
const replay = replayD2CapFile(capPath, { strict: true })
|
||
|
||
expect(replay.protocolErrors).toBe(0)
|
||
expect(replay.unknownPackets).toBe(0)
|
||
expect(replay.opaquePackets).toBe(0)
|
||
expect(replay.gameS2cPackets).toBeGreaterThan(0)
|
||
|
||
const world = replay.world
|
||
expect(world.mapSeed).toBeGreaterThan(0)
|
||
expect(world.areaId).toBe(1)
|
||
expect(world.self.x).toBeGreaterThan(0)
|
||
expect(world.self.y).toBeGreaterThan(0)
|
||
|
||
// Synchronize world seed & area into MapService and AutomapView
|
||
const levelView = sharedMapService.onLoadActPacket({
|
||
act: world.act,
|
||
mapSeed: world.mapSeed,
|
||
areaId: world.areaId,
|
||
})!
|
||
expect(levelView.rooms.length).toBeGreaterThan(0)
|
||
expect(sharedTileAtlas.verifyUniversalDt1s().allValid).toBe(true)
|
||
|
||
const automap = new AutomapView({ initialMode: 'off' })
|
||
const amView = automap.setContext(world.mapSeed, world.act as 0 | 1 | 2, world.areaId)
|
||
const firstRoom = amView.rooms[0]!
|
||
automap.onServerAddRoom(firstRoom.tileX, firstRoom.tileY, world.areaId)
|
||
for (const ev of replay.events) {
|
||
if (ev.type === 'RoomReveal') {
|
||
automap.onServerAddRoom(ev.tileX, ev.tileY, ev.areaId)
|
||
}
|
||
}
|
||
expect(automap.buildSnapshot().visibleRoomCount).toBeGreaterThan(0)
|
||
|
||
// Reconcile predictor and play Act 1 Town BGM
|
||
const predictor = new LocalMovementPredictor({ initialX: world.self.x, initialY: world.self.y })
|
||
const rec = predictor.reconcileServerPosition({ x: world.self.x + 2, y: world.self.y })
|
||
expect(rec.mode).toBe('rubber-band')
|
||
|
||
const playedBgm: number[] = []
|
||
const playedSfx: string[] = []
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const sound = new SoundService({ store, backend: createMockAudioBackend(playedBgm, playedSfx) })
|
||
await sound.init()
|
||
const env = await sound.onEnterArea(world.areaId)
|
||
expect(env.actId).toBe(1)
|
||
expect(playedBgm).toEqual([1])
|
||
sound.destroy()
|
||
})
|
||
|
||
it('S3: Replays 03-d2gs-items-npc-skills.d2cap end-to-end verifying item bitstream, NPCs, skills & canonical ItemDataTables', () => {
|
||
const capPath = path.join(FIXTURES_DIR, '03-d2gs-items-npc-skills.d2cap')
|
||
const replay = replayD2CapFile(capPath, { strict: true })
|
||
|
||
expect(replay.protocolErrors).toBe(0)
|
||
expect(replay.unknownPackets).toBe(0)
|
||
expect(replay.opaquePackets).toBe(0)
|
||
expect(replay.gameS2cPackets).toBeGreaterThan(0)
|
||
|
||
const itemEvents = replay.events.filter((e) => e.type === 'ItemEvent')
|
||
expect(itemEvents.length).toBeGreaterThan(0)
|
||
|
||
for (const ev of itemEvents) {
|
||
if (ev.type === 'ItemEvent') {
|
||
expect(ev.item.code.trim().length).toBeGreaterThanOrEqual(3)
|
||
const meta = itemTables.getItemBaseMeta(ev.item.code.trim())
|
||
if (meta) {
|
||
expect(meta.invWidth).toBeGreaterThanOrEqual(1)
|
||
expect(meta.invHeight).toBeGreaterThanOrEqual(1)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
it('S4: Replays 04-d2gs-idle-60s.d2cap end-to-end verifying 60-second heartbeat stability, Pong RTT & BGM transition', async () => {
|
||
const capPath = path.join(FIXTURES_DIR, '04-d2gs-idle-60s.d2cap')
|
||
const replay = replayD2CapFile(capPath, { strict: true })
|
||
|
||
expect(replay.protocolErrors).toBe(0)
|
||
expect(replay.unknownPackets).toBe(0)
|
||
expect(replay.opaquePackets).toBe(0)
|
||
expect(replay.gameS2cPackets).toBeGreaterThan(0)
|
||
expect(replay.s2cOpcodesSeen).toContain(0x8f) // Pong heartbeat
|
||
|
||
const playedBgm: number[] = []
|
||
const playedSfx: string[] = []
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const sound = new SoundService({ store, backend: createMockAudioBackend(playedBgm, playedSfx) })
|
||
await sound.init()
|
||
await sound.onEnterArea(1) // Act 1 Rogue Encampment
|
||
await sound.onEnterArea(40) // Act 2 Lut Gholein
|
||
expect(playedBgm).toEqual([1, 2])
|
||
sound.destroy()
|
||
})
|
||
|
||
it('S5: Multi-capture aggregate strict replay audit across all 4 .d2cap fixtures achieves >= 36 S2C opcodes, >= 10 C2S opcodes, and 0 errors', () => {
|
||
const files = [
|
||
'01-bncs-mcp-lobby.d2cap',
|
||
'02-d2gs-town-walk-chat.d2cap',
|
||
'03-d2gs-items-npc-skills.d2cap',
|
||
'04-d2gs-idle-60s.d2cap',
|
||
]
|
||
|
||
const allS2c = new Set<number>()
|
||
const allC2s = new Set<number>()
|
||
let totalRecords = 0
|
||
let totalErrors = 0
|
||
let totalUnknown = 0
|
||
let totalOpaque = 0
|
||
|
||
for (const f of files) {
|
||
const res = replayD2CapFile(path.join(FIXTURES_DIR, f), { strict: true })
|
||
totalRecords += res.totalRecords
|
||
totalErrors += res.protocolErrors
|
||
totalUnknown += res.unknownPackets
|
||
totalOpaque += res.opaquePackets
|
||
for (const op of res.s2cOpcodesSeen) allS2c.add(op)
|
||
for (const op of res.c2sOpcodesSeen) allC2s.add(op)
|
||
}
|
||
|
||
expect(totalRecords).toBeGreaterThan(50)
|
||
expect(totalErrors).toBe(0)
|
||
expect(totalUnknown).toBe(0)
|
||
expect(totalOpaque).toBe(0)
|
||
expect(allS2c.size).toBeGreaterThanOrEqual(30)
|
||
expect(allC2s.size).toBeGreaterThanOrEqual(5)
|
||
})
|
||
|
||
it('S6: Full createD2OnlineFlow end-to-end session lifecycle (BNCS -> MCP -> D2GS -> leaveToLobby -> close) over in-memory EndpointResolver', async () => {
|
||
const tap = new InMemoryPacketTap()
|
||
const clock = new FakeClock(1000)
|
||
const stateHistory: D2OnlineState[] = []
|
||
|
||
const [bnetClient, bnetServer] = createMemoryStreamPair()
|
||
const [realmClient, realmServer] = createMemoryStreamPair()
|
||
const [gameClient, gameServer] = createMemoryStreamPair()
|
||
|
||
// Wire BNCS server responder
|
||
const bncsFramer = new BncsFramer()
|
||
let bncsInitSeen = false
|
||
bnetServer.onData((chunk) => {
|
||
let data = chunk
|
||
if (!bncsInitSeen && data[0] === 0x01) {
|
||
bncsInitSeen = true
|
||
data = data.subarray(1)
|
||
}
|
||
for (const pkt of bncsFramer.push(data)) {
|
||
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
|
||
bnetServer.write(encodeBncsPing(0x11223344))
|
||
const w50 = new ByteWriter()
|
||
w50.u32LE(0)
|
||
w50.u32LE(0xabcdef01)
|
||
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')
|
||
bnetServer.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w50.toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
|
||
bnetServer.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0])))
|
||
} else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
|
||
bnetServer.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0])))
|
||
} else if (pkt.id === BncsOpcode.SID_QUERYREALMS2) {
|
||
const rW = new ByteWriter()
|
||
rW.u32LE(0)
|
||
rW.u32LE(1)
|
||
rW.u32LE(1)
|
||
rW.cstring('D2CS')
|
||
rW.cstring('Local Test Realm')
|
||
bnetServer.write(encodeBncsFrame(BncsOpcode.SID_QUERYREALMS2, rW.toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_LOGONREALMEX) {
|
||
const w3e = new ByteWriter()
|
||
w3e.u32LE(0x11223344)
|
||
w3e.u32LE(0)
|
||
w3e.bytes(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))
|
||
w3e.bytes(new Uint8Array([127, 0, 0, 1]))
|
||
w3e.u16BE(6113)
|
||
w3e.u16LE(0)
|
||
w3e.bytes(new Uint8Array(48).fill(0x55))
|
||
w3e.cstring('d2webbot_sorc')
|
||
bnetServer.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w3e.toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
|
||
// Wire MCP server responder
|
||
const mcpFramer = new McpFramer()
|
||
let mcpInitSeen = false
|
||
realmServer.onData((chunk) => {
|
||
let data = chunk
|
||
if (!mcpInitSeen && data[0] === 0x01) {
|
||
mcpInitSeen = true
|
||
data = data.subarray(1)
|
||
}
|
||
for (const pkt of mcpFramer.push(data)) {
|
||
if (pkt.id === McpOpcode.MCP_STARTUP) {
|
||
realmServer.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
|
||
stat[26] = 0xa0
|
||
stat[27] = 0x80
|
||
w.bytes(stat)
|
||
w.u8(0)
|
||
realmServer.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, w.toUint8Array()))
|
||
} else if (pkt.id === McpOpcode.MCP_CHARLOGON) {
|
||
realmServer.write(encodeMcpFrame(McpOpcode.MCP_CHARLOGON, new Uint8Array([0, 0, 0, 0])))
|
||
} else if (pkt.id === McpOpcode.MCP_CREATEGAME) {
|
||
const reqId = new ByteReader(pkt.payload).u16LE()
|
||
const w = new ByteWriter()
|
||
w.u16LE(reqId)
|
||
w.u16LE(1)
|
||
w.u16LE(0)
|
||
w.u32LE(0)
|
||
realmServer.write(encodeMcpFrame(McpOpcode.MCP_CREATEGAME, w.toUint8Array()))
|
||
} else if (pkt.id === McpOpcode.MCP_JOINGAME) {
|
||
const reqId = new ByteReader(pkt.payload).u16LE()
|
||
const w = new ByteWriter()
|
||
w.u16LE(reqId)
|
||
w.u16LE(0x0042)
|
||
w.u16LE(0)
|
||
w.bytes(new Uint8Array([127, 0, 0, 1]))
|
||
w.u32LE(0xdeadbeef)
|
||
w.u32LE(0)
|
||
realmServer.write(encodeMcpFrame(McpOpcode.MCP_JOINGAME, w.toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
|
||
// Wire D2GS server responder
|
||
gameServer.onData((chunk) => {
|
||
if (chunk[0] === 0x68) {
|
||
const block = wrapD2gsCompressedBlock(
|
||
defaultD2gsHuffmanCodec.compress(
|
||
new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04]),
|
||
),
|
||
)
|
||
gameServer.write(block)
|
||
}
|
||
})
|
||
|
||
const resolver: EndpointResolver = {
|
||
open: async (role: EndpointRole): Promise<ByteStream> => {
|
||
if (role === 'bnet') return bnetClient
|
||
if (role === 'realm') return realmClient
|
||
setTimeout(() => {
|
||
gameServer.write(new Uint8Array([0xaf, 0x01]))
|
||
}, 5)
|
||
return gameClient
|
||
},
|
||
}
|
||
|
||
const flow = createD2OnlineFlow({
|
||
bnetHost: '127.0.0.1',
|
||
resolver,
|
||
clock,
|
||
tap,
|
||
itemTables,
|
||
})
|
||
|
||
flow.onState((s) => stateHistory.push(s))
|
||
|
||
await flow.connectBnet()
|
||
await flow.login('d2webbot_sorc', 'secret123')
|
||
expect(flow.state).toBe('authenticated')
|
||
|
||
const realms = await flow.listRealms()
|
||
expect(realms).toHaveLength(1)
|
||
expect(realms[0]!.title).toBe('D2CS')
|
||
|
||
const chars = await flow.enterRealm('D2CS')
|
||
expect(flow.state).toBe('realm_ready')
|
||
expect(chars).toHaveLength(1)
|
||
expect(chars[0]!.name).toBe('d2webbot_sorc')
|
||
|
||
await flow.selectCharacter('d2webbot_sorc')
|
||
await flow.createGame({ name: 'e2e-game', password: '123', difficulty: 0 })
|
||
const adapter = await flow.joinGame('e2e-game', '123')
|
||
expect(flow.state).toBe('ingame')
|
||
expect(adapter).toBeDefined()
|
||
|
||
await flow.leaveToLobby()
|
||
expect(flow.state).toBe('realm_ready')
|
||
|
||
await flow.close()
|
||
expect(flow.state).toBe('closed')
|
||
expect(stateHistory).toEqual([
|
||
'connecting_bnet',
|
||
'authenticated',
|
||
'connecting_realm',
|
||
'realm_ready',
|
||
'connecting_game',
|
||
'ingame',
|
||
'realm_ready',
|
||
'closed',
|
||
])
|
||
})
|
||
|
||
it('S7: End-to-end .d2s bot progression unlock + MCP character validation + D2GS cross-Act waypoint travel', () => {
|
||
const botName = 'd2webbot_pal'
|
||
expect(() => validateBotCharacterName(botName)).not.toThrow()
|
||
|
||
const rawD2s = createSyntheticD2sBuffer(botName)
|
||
const unlockedD2s = unlockD2sBuffer(rawD2s)
|
||
const verification = verifyD2sUnlocked(unlockedD2s)
|
||
expect(verification.validMagic).toBe(true)
|
||
expect(verification.checksumValid).toBe(true)
|
||
expect(verification.allUnlocked).toBe(true)
|
||
expect(verification.characterName).toBe(botName)
|
||
|
||
// Simulate entering game with unlocked bot and opening Waypoint Menu (0x63)
|
||
const world = new ClientWorld()
|
||
const w63 = new ByteWriter()
|
||
w63.u8(0x63)
|
||
w63.u32LE(500)
|
||
w63.bytes(new Uint8Array(16).fill(0xff))
|
||
world.apply(decodeD2gsS2cPacket(w63.toUint8Array(), { tables: itemTables }))
|
||
expect(world.waypoints.size).toBeGreaterThan(0)
|
||
|
||
// Encode WaypointGo C2S command to Act IV Pandemonium Fortress (areaId = 103)
|
||
const [wpFrame] = encodeD2gsClientCommand({
|
||
type: 'WaypointGo',
|
||
waypointId: 500,
|
||
destAreaId: 103,
|
||
})
|
||
expect(wpFrame![0]).toBe(0x49)
|
||
expect(wpFrame!.length).toBe(9)
|
||
|
||
// Server responds with 0x03 LoadAct for Act IV (act = 3, areaId = 103)
|
||
const w03 = new ByteWriter()
|
||
w03.u8(0x03)
|
||
w03.u8(3)
|
||
w03.u32LE(0x99887766)
|
||
w03.u16LE(103)
|
||
w03.u32LE(0)
|
||
world.apply(decodeD2gsS2cPacket(w03.toUint8Array(), { tables: itemTables }))
|
||
|
||
expect(world.act).toBe(3)
|
||
expect(world.areaId).toBe(103)
|
||
})
|
||
|
||
it('S8: Full Client UI, Viewport (800x600 <-> 1024x768), Automap docking, i18n (en <-> zh), & Lighting (torch <-> fullbright) workflow', () => {
|
||
const prevLang = getLang()
|
||
try {
|
||
const store = new SettingsStore({
|
||
storage: new MemoryStorage(),
|
||
defaultLang: 'en',
|
||
syncGlobalLang: true,
|
||
})
|
||
const toolbar = new Toolbar({ store })
|
||
const automap = new AutomapView({ initialMode: 'off' })
|
||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||
|
||
expect(tToolbar('toolbar.status.disconnected', 'en')).toBeDefined()
|
||
const hud800 = computeHudLayout(800, 600, 1)
|
||
expect(hud800.uiScale).toBe(1)
|
||
|
||
// Switch to 1024x768 HD, ZH, torch lighting, and act automap reveal
|
||
toolbar.toggleViewport()
|
||
toolbar.toggleLanguage()
|
||
toolbar.setLightingPreset('torch')
|
||
toolbar.setAutomapReveal('act')
|
||
automap.setMode(store.get().automapReveal)
|
||
|
||
expect(store.get().viewport).toBe('1024x768')
|
||
expect(store.get().lang).toBe('zh')
|
||
expect(getLang()).toBe('zh')
|
||
expect(tToolbar('toolbar.status.disconnected', 'zh')).not.toBe(
|
||
tToolbar('toolbar.status.disconnected', 'en'),
|
||
)
|
||
|
||
const dock1024 = computeDockingLayout(1024, 768, 1, getViewportProfile('1024x768'))
|
||
const anchors1024 = computeViewportAnchors('1024x768')
|
||
expect(dock1024.uiScale).toBe(1)
|
||
expect(dock1024.channelWidthLogical).toBe(224)
|
||
expect(anchors1024.centerGapWidth).toBe(224)
|
||
|
||
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,
|
||
)
|
||
const lightGrid = new LightGrid()
|
||
let lastLighting: unknown = 'unset'
|
||
const mockRenderer = {
|
||
setLighting(params: unknown) {
|
||
lastLighting = params
|
||
},
|
||
}
|
||
|
||
const snapTorch = renderSceneSourceFrame({
|
||
source,
|
||
lightingPreset: store.get().lightingPreset,
|
||
lightingTargets: { lightGrid, renderer: mockRenderer },
|
||
})
|
||
expect(snapTorch.viewportProfile.width).toBe(1024)
|
||
expect(lastLighting).not.toBeNull()
|
||
|
||
toolbar.setLightingPreset('fullbright')
|
||
applyLightingInputs(source.buildLightingInputs(store.get().lightingPreset), {
|
||
lightGrid,
|
||
renderer: mockRenderer,
|
||
})
|
||
expect(lastLighting).toBeNull()
|
||
toolbar.destroy()
|
||
} finally {
|
||
setLang(prevLang)
|
||
}
|
||
})
|
||
|
||
it('S9: Interactive screen-click -> A* pathfinding -> movement prediction -> rate-limited C2S -> S2C rubber-band & interpolation loop', () => {
|
||
const automap = new AutomapView({ initialMode: 'level' })
|
||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||
const selfPos = { x: 100, y: 100 }
|
||
const targetPos = { x: 108, y: 100 }
|
||
|
||
const source = new ClientWorldSceneSource(
|
||
(): ClientSceneWorldAdapter => ({
|
||
tick: 1,
|
||
actId: 1,
|
||
levelId: 1,
|
||
playerSubX: selfPos.x,
|
||
playerSubY: selfPos.y,
|
||
units: [{ kind: 'player', id: 1, classId: 1, x: selfPos.x, y: selfPos.y }],
|
||
levelView,
|
||
}),
|
||
'800x600',
|
||
)
|
||
|
||
const targetScreen = subTileToScreen(targetPos.x, targetPos.y, selfPos.x, selfPos.y, VIEWPORT_800x600)
|
||
const clickCmd = translateScreenClickToClientCommand(targetScreen.x, targetScreen.y, source, {
|
||
button: 'left',
|
||
runMode: true,
|
||
})
|
||
expect(clickCmd?.type).toBe('RunToLocation')
|
||
|
||
// Compute local A* path and step LocalMovementPredictor
|
||
const walkable = new Uint8Array(200 * 200).fill(1)
|
||
const pathRes = findPathLocal(
|
||
{ subWidth: 200, subHeight: 200, walkable },
|
||
selfPos.x,
|
||
selfPos.y,
|
||
targetPos.x,
|
||
targetPos.y,
|
||
{ smooth: true },
|
||
)
|
||
expect(pathRes.reachedGoal).toBe(true)
|
||
|
||
const predictor = new LocalMovementPredictor({ initialX: selfPos.x, initialY: selfPos.y })
|
||
predictor.setPath(pathRes.path, true)
|
||
const stepped = predictor.stepTicks(10)
|
||
expect(stepped.x).toBeGreaterThan(selfPos.x)
|
||
|
||
// Pass command through OutboundRateLimiter and encode to C2S
|
||
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
|
||
const decision = limiter.tryAcquire(clickCmd!, 1000)
|
||
expect(decision.allowed).toBe(true)
|
||
const [wire] = encodeD2gsClientCommand({ type: 'Move', run: true, to: targetPos })
|
||
expect(wire![0]).toBe(0x03)
|
||
|
||
// Simulate S2C 0x15 ReassignPlayer reconciliation + remote unit interpolation
|
||
const rec = predictor.reconcileServerPosition(targetPos, true)
|
||
expect(rec.mode).toBe('hard-snap')
|
||
expect(predictor.stepTicks(0).x).toBe(targetPos.x)
|
||
|
||
const interp = new RemoteEntityInterpolator()
|
||
interp.spawnOrSnap({ unitType: 1, unitId: 50, x: 100, y: 100 })
|
||
interp.startMove({
|
||
unitType: 1,
|
||
unitId: 50,
|
||
currentX: 100,
|
||
currentY: 100,
|
||
targetX: 108,
|
||
targetY: 100,
|
||
moveType: 0x01,
|
||
})
|
||
interp.update(300)
|
||
expect(interp.getEntityState(1, 50)!.x).toBeGreaterThan(100)
|
||
})
|
||
|
||
it('S10: End-to-End Audio & Asset streaming workflow (SettingsStore <-> SoundService spatial SFX/BGM + OPFS HTTP Range 206 cache)', async () => {
|
||
const playedBgm: number[] = []
|
||
const playedSfx: string[] = []
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const sound = new SoundService({ store, backend: createMockAudioBackend(playedBgm, playedSfx) })
|
||
await sound.init()
|
||
|
||
await sound.onEnterArea(1)
|
||
expect(playedBgm).toEqual([1])
|
||
|
||
const spatial = computeSpatialAudioMetrics(104, 100, 100, 100)
|
||
expect(spatial.culled).toBe(false)
|
||
const sfxRec = sound.playSoundById(47, {
|
||
sourceSubX: 104,
|
||
sourceSubY: 100,
|
||
listenerSubX: 100,
|
||
listenerSubY: 100,
|
||
})
|
||
expect(sfxRec?.sfxId).toBe('skill_fire')
|
||
expect(playedSfx).toContain('skill_fire')
|
||
|
||
// Mute via SettingsStore and verify SFX suppressed
|
||
store.set({ audioMuted: true })
|
||
expect(sound.isMuted()).toBe(true)
|
||
expect(sound.playSoundById(47)).toBeNull()
|
||
|
||
// Stream MPQ audio/tile bytes via OpfsMpqManager + HttpRangeMpqSource
|
||
const fakeArchive = new Uint8Array(1024).fill(0x5a)
|
||
const cache = new InMemoryChunkCache()
|
||
const mgr = new OpfsMpqManager({
|
||
baseUrl: '/diablo2/data',
|
||
cache,
|
||
fetchFn: async (_url, init) => {
|
||
const range = init?.headers?.Range ?? 'bytes=0-63'
|
||
const m = /^bytes=(\d+)-(\d+)$/.exec(range)!
|
||
const start = Number(m[1])
|
||
const end = Number(m[2])
|
||
const slice = fakeArchive.slice(start, end + 1)
|
||
return {
|
||
ok: true,
|
||
status: 206,
|
||
headers: {
|
||
get(name: string) {
|
||
return name.toLowerCase() === 'content-range'
|
||
? `bytes ${start}-${end}/${fakeArchive.length}`
|
||
: null
|
||
},
|
||
},
|
||
arrayBuffer: async () => slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength),
|
||
}
|
||
},
|
||
})
|
||
|
||
const src = mgr.getSource('d2sfx.mpq') as HttpRangeMpqSource
|
||
const slice1 = await src.read(0, 64)
|
||
const slice2 = await src.read(0, 64)
|
||
expect(slice1.byteLength).toBe(64)
|
||
expect(slice2).toEqual(slice1)
|
||
expect(src.rangeRequestsCount).toBe(1)
|
||
expect(src.cacheHitsCount).toBe(1)
|
||
sound.destroy()
|
||
})
|
||
|
||
it('S11: Anti-flood OutboundRateLimiter + InMemoryPacketTap + PacketInspector JSONL export & strict .d2cap replay round-trip', () => {
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const tap = new InMemoryPacketTap()
|
||
const inspector = new PacketInspector({ store, tap })
|
||
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
|
||
|
||
// Rapid movement burst coalesces while preserving the latest destination
|
||
for (let i = 0; i < 5; i++) {
|
||
const cmd = { type: 'RunToLocation', x: asSubTile(5000 + i * 2), y: asSubTile(5000 + i * 2) } as const
|
||
const d = limiter.tryAcquire(cmd, 1000 + i * 20)
|
||
if (d.allowed) {
|
||
const [raw] = encodeD2gsClientCommand({ type: 'Move', run: true, to: { x: cmd.x, y: cmd.y } })
|
||
tap.record({ t: 1000 + i * 20, conn: 'game', dir: 'c2s', packetId: raw![0]!, raw: raw! })
|
||
}
|
||
}
|
||
const flushed = limiter.flushPendingMove(1200)
|
||
expect(flushed).not.toBeNull()
|
||
if (flushed?.type === 'RunToLocation') {
|
||
const [raw] = encodeD2gsClientCommand({ type: 'Move', run: true, to: { x: flushed.x, y: flushed.y } })
|
||
tap.record({ t: 1200, conn: 'game', dir: 'c2s', packetId: raw![0]!, raw: raw! })
|
||
}
|
||
|
||
// Record S2C LoadAct + AttributeByte into tap
|
||
const w03 = new ByteWriter()
|
||
w03.u8(0x03)
|
||
w03.u8(0)
|
||
w03.u32LE(0x11223344)
|
||
w03.u16LE(1)
|
||
w03.u32LE(0)
|
||
tap.record({ t: 1210, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() })
|
||
tap.record({ t: 1220, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 99]) })
|
||
|
||
expect(inspector.getAllRows().length).toBe(4)
|
||
|
||
const jsonl = inspector.exportD2CapJsonl()
|
||
const replayed = replayD2CapText(jsonl, { strict: true })
|
||
expect(replayed.protocolErrors).toBe(0)
|
||
expect(replayed.unknownPackets).toBe(0)
|
||
expect(replayed.world.mapSeed).toBe(0x11223344)
|
||
expect(replayed.world.self.stats.get(0)).toBe(99)
|
||
inspector.destroy()
|
||
})
|
||
|
||
it('S12: Server host reconfiguration, WssBnet concurrency guard, malformed packet isolation, and clean reconnect lifecycle', async () => {
|
||
resetActiveWssBnetConnectionsForTesting()
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
|
||
// Validate host reconfiguration and endpoint derivation
|
||
expect(validateServerHost('www.laiseek.xyz', 'https:').valid).toBe(true)
|
||
expect(validateServerHost('101.37.117.183', 'http:').valid).toBe(true)
|
||
store.setServerHost('www.laiseek.xyz', 'https:')
|
||
const endpoints = deriveRealmWsEndpoints(store.get().serverHost, 'https:')
|
||
expect(endpoints.bncsUrl).toBe('wss://www.laiseek.xyz/ws/bncs')
|
||
|
||
// Verify WssBnet concurrency guard and release-on-close
|
||
const resolver = createWsBridgeResolver({
|
||
host: store.get().serverHost,
|
||
maxConcurrentBnetWss: 1,
|
||
streamFactory: () => createMemoryStreamPair()[0],
|
||
})
|
||
|
||
const bnetConn1 = await resolver.open('bnet', store.get().serverHost, 6112)
|
||
await expect(resolver.open('bnet', store.get().serverHost, 6112)).rejects.toThrow(/concurrency limit/i)
|
||
|
||
// Malformed packet isolation: replayD2CapText with strict=false records error without crashing ClientWorld
|
||
const mixedJsonl = [
|
||
'{"d2cap":1,"createdAt":"2026-01-01T00:00:00Z","host":"127.0.0.1","scenario":"s12"}',
|
||
'{"t":1,"conn":"game","dir":"s2c","hex":"030044332211010000000000","len":12}',
|
||
'{"t":2,"conn":"game","dir":"s2c","hex":"fe0102","len":3}',
|
||
].join('\n')
|
||
const replayRes = replayD2CapText(mixedJsonl, { strict: false })
|
||
expect(replayRes.protocolErrors).toBe(1)
|
||
expect(replayRes.world.mapSeed).toBe(0x11223344)
|
||
|
||
// Closing bnetConn1 allows clean reconnect
|
||
bnetConn1.close()
|
||
const bnetConn2 = await resolver.open('bnet', store.get().serverHost, 6112)
|
||
expect(bnetConn2).toBeDefined()
|
||
bnetConn2.close()
|
||
resetActiveWssBnetConnectionsForTesting()
|
||
})
|
||
})
|