diablo2-web/tests/e2e-netproto/tier4-real-world-scenarios....

855 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Tier 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()
})
})