diablo2-web/tests/client/settings-toolbar-view.test.ts

801 lines
27 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
HttpRangeMpqSource,
InMemoryChunkCache,
OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts'
import {
computeSpatialAudioMetrics,
resolveSoundEnvironmentForLevel,
SoundService,
type AudioBackendLike,
} from '../../src/client/audio/sound-service.ts'
import { AutomapView } from '../../src/client/automap/automap-view.ts'
import {
formatHexDump,
PacketInspector,
} from '../../src/client/inspector/packet-inspector.ts'
import { MapService } from '../../src/client/map/map-service.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import {
DEFAULT_SERVER_HOST,
deriveRealmWsEndpoints,
parseClientSettingsJson,
sanitizeClientSettings,
SETTINGS_STORAGE_KEY,
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 {
ClientWorldSceneSource,
type ClientSceneWorldAdapter,
} from '../../src/client/view/scene-source.ts'
import {
buildSceneDrawables,
type RenderableUnitInput,
} from '../../src/client/view/unit-drawables.ts'
import {
applyViewportProfileToDom,
computeViewportAnchors,
computeViewportFitSize,
getViewportProfile,
screenToSubTile,
screenToWorld,
subTileToScreen,
VIEWPORT_1024x768,
VIEWPORT_800x600,
worldToScreen,
} from '../../src/client/view/viewport-profile.ts'
import { LightGrid } from '../../src/common/drlg/light-grid.ts'
import {
getLang,
onLangChange,
setLang,
tToolbar,
} from '../../src/client/i18n/lang.ts'
import {
asSubTile,
asUnitId,
InMemoryPacketTap,
} from '../../src/netproto/index.ts'
import {
applyLightingInputs,
LIGHTING_PRESETS,
} from '../../src/client/scene/lighting-presets.ts'
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import {
computeDockingLayout,
computeHudLayout,
HudManager,
} from '../../src/client/ui/hud-manager.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)
}
}
describe('ClientSettings & SettingsStore (F14)', () => {
it('persists defaults to d2web.settings.v1 and sanitizes corrupt/unknown fields without throwing', () => {
const storage = new MemoryStorage()
storage.setItem(
SETTINGS_STORAGE_KEY,
JSON.stringify({
version: 999,
serverHost: '34.187.177.211', // bare IP rejected on https:
lang: 'invalid_lang',
lightingPreset: 'non_existent_preset',
automapReveal: 'invalid_mode',
viewport: '1920x1080',
inspectorOpen: 'yes',
audioMuted: 123,
masterVolume: 5.5,
extraHackField: 'should_be_stripped',
}),
)
const store = new SettingsStore({
storage,
defaultLang: 'zh',
pageProtocol: 'https:',
syncGlobalLang: false,
})
const settings = store.get()
expect(settings).toEqual({
version: 1,
serverHost: DEFAULT_SERVER_HOST,
lang: 'zh',
lightingPreset: 'auto',
automapReveal: 'off',
viewport: '800x600',
inspectorOpen: false,
audioMuted: false,
masterVolume: 1.0,
})
expect('extraHackField' in (settings as unknown as Record<string, unknown>)).toBe(false)
// Corrupt non-JSON string falls back cleanly
const fallback = parseClientSettingsJson('{not-valid-json', { defaultLang: 'en' })
expect(fallback.serverHost).toBe('www.laiseek.xyz')
expect(fallback.lang).toBe('en')
expect( sanitizeClientSettings(null, { defaultLang: 'zh' }).viewport ).toBe('800x600')
})
it('validates serverHost and rejects bare IPv4/IPv6 on HTTPS with an actionable WSS TLS message', () => {
const emptyRes = validateServerHost(' ', 'https:')
expect(emptyRes.valid).toBe(false)
expect(emptyRes.reason).toBe('empty')
const ipv4Https = validateServerHost('34.187.177.211', 'https:')
expect(ipv4Https.valid).toBe(false)
expect(ipv4Https.reason).toBe('bare_ip_on_https')
expect(ipv4Https.errorMessage).toContain('www.laiseek.xyz')
const ipv6Https = validateServerHost('[::1]:6112', 'https:')
expect(ipv6Https.valid).toBe(false)
expect(ipv6Https.reason).toBe('bare_ip_on_https')
// Local HTTP development allows bare IP
const ipv4Http = validateServerHost('127.0.0.1:8080', 'http:')
expect(ipv4Http.valid).toBe(true)
expect(ipv4Http.normalizedHost).toBe('127.0.0.1:8080')
// Valid domain with optional scheme/path stripped
const domainRes = validateServerHost('wss://WWW.LAISEEK.XYZ/ws/bncs', 'https:')
expect(domainRes.valid).toBe(true)
expect(domainRes.normalizedHost).toBe('www.laiseek.xyz')
const endpoints = deriveRealmWsEndpoints('www.laiseek.xyz', 'https:')
expect(endpoints).toEqual({
bncsUrl: 'wss://www.laiseek.xyz/ws/bncs',
mcpUrl: 'wss://www.laiseek.xyz/ws/mcp',
d2gsUrl: 'wss://www.laiseek.xyz/ws/d2gs',
})
})
})
describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
it('supports all 6 controls, rejects bare IP on HTTPS, and prompts confirmation when changing host mid-session', () => {
const storage = new MemoryStorage()
const store = new SettingsStore({
storage,
defaultLang: 'zh',
pageProtocol: 'https:',
})
const reconnectSpy = vi.fn()
const lightingSpy = vi.fn()
const automapSpy = vi.fn()
const viewportSpy = vi.fn()
const inspectorSpy = vi.fn()
let confirmResponse = false
const toolbar = new Toolbar({
store,
pageProtocol: 'https:',
confirmReconnect: () => confirmResponse,
callbacks: {
onServerHostReconnect: reconnectSpy,
onLightingChange: lightingSpy,
onAutomapRevealChange: automapSpy,
onViewportChange: viewportSpy,
onInspectorToggle: inspectorSpy,
},
})
// 1. Connection status + RTT + 5-per-IP quota diagnostic
toolbar.setConnectionState({ phase: 'd2gs', rttMs: 34 })
expect(toolbar.getSnapshot().statusLabel).toBe(tToolbar('toolbar.status.d2gs', 'zh'))
expect(toolbar.getSnapshot().rttLabel).toBe('34 ms')
toolbar.reportDiagnostic('quota_exceeded')
expect(toolbar.getSnapshot().diagnosticBannerText).toContain('5')
toolbar.setConnectionState({ phase: 'd2gs', rttMs: 28, diagnosticKind: 'none' })
// 2. Server Host validation & mid-game confirmation
const bareIpAttempt = toolbar.submitServerHost('34.187.177.211')
expect(bareIpAttempt.valid).toBe(false)
expect(bareIpAttempt.applied).toBe(false)
expect(store.get().serverHost).toBe('www.laiseek.xyz')
expect(toolbar.getSnapshot().diagnosticBannerText).toContain('www.laiseek.xyz')
// Valid new host while in active D2GS session: user cancels confirmation first
confirmResponse = false
const cancelledAttempt = toolbar.submitServerHost('eu.laiseek.xyz')
expect(cancelledAttempt.valid).toBe(true)
expect(cancelledAttempt.applied).toBe(false)
expect(store.get().serverHost).toBe('www.laiseek.xyz')
expect(reconnectSpy).not.toHaveBeenCalled()
// User confirms reconnect
confirmResponse = true
const confirmedAttempt = toolbar.submitServerHost('eu.laiseek.xyz')
expect(confirmedAttempt.valid).toBe(true)
expect(confirmedAttempt.applied).toBe(true)
expect(store.get().serverHost).toBe('eu.laiseek.xyz')
expect(reconnectSpy).toHaveBeenCalledWith('eu.laiseek.xyz', 'www.laiseek.xyz')
// 3. Live Language Toggle (中 / EN) without page reload
const langChanges: string[] = []
const unsubLang = onLangChange((l) => langChanges.push(l))
toolbar.toggleLanguage()
expect(store.get().lang).toBe('en')
expect(getLang()).toBe('en')
expect(toolbar.getSnapshot().statusLabel).toBe('In-Game (D2GS)')
expect(langChanges).toContain('en')
unsubLang()
// 4. Lighting Preset Selector
toolbar.setLightingPreset('torch')
expect(store.get().lightingPreset).toBe('torch')
expect(lightingSpy).toHaveBeenCalledWith('torch')
toolbar.setLightingPreset('fullbright')
expect(store.get().lightingPreset).toBe('fullbright')
// 5. Automap Reveal Selector (off / level / act)
toolbar.setAutomapReveal('level')
expect(store.get().automapReveal).toBe('level')
expect(automapSpy).toHaveBeenCalledWith('level')
toolbar.setAutomapReveal('act')
expect(store.get().automapReveal).toBe('act')
expect(automapSpy).toHaveBeenCalledWith('act')
// 6. Viewport Size Toggle (800x600 / 1024x768)
const nextProfile = toolbar.toggleViewport()
expect(nextProfile).toEqual(VIEWPORT_1024x768)
expect(store.get().viewport).toBe('1024x768')
expect(viewportSpy).toHaveBeenCalledWith(VIEWPORT_1024x768)
// Packet Inspector Toggle
expect(toolbar.toggleInspector()).toBe(true)
expect(store.get().inspectorOpen).toBe(true)
expect(inspectorSpy).toHaveBeenCalledWith(true)
toolbar.destroy()
setLang('zh')
})
})
describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms (F15)', () => {
it('computes exact 800x600 and 1024x768 panel/bar anchors with a 224px center gap in 1024x768', () => {
const anchors800 = computeViewportAnchors('800x600')
expect(anchors800.bottomBar).toEqual({ x: 0, y: 552, width: 800, height: 48 })
expect(anchors800.leftPanel).toEqual({ x: 0, y: 0, width: 400, height: 552 })
expect(anchors800.rightPanel).toEqual({ x: 400, y: 0, width: 400, height: 552 })
expect(anchors800.centerGapWidth).toBe(0)
const anchors1024 = computeViewportAnchors('1024x768')
expect(anchors1024.profile).toEqual({
id: '1024x768',
width: 1024,
height: 768,
barOffsetX: 112,
barOffsetY: 168,
panelOffsetY: 84,
})
expect(anchors1024.bottomBar).toEqual({ x: 112, y: 720, width: 800, height: 48 })
expect(anchors1024.leftPanel).toEqual({ x: 0, y: 84, width: 400, height: 552 })
expect(anchors1024.rightPanel).toEqual({ x: 624, y: 84, width: 400, height: 552 })
expect(anchors1024.centerGapWidth).toBe(224)
const dock1024 = computeDockingLayout(1024, 768, 1, VIEWPORT_1024x768)
expect(dock1024.uiScale).toBe(1)
expect(dock1024.leftDockX).toBe(0)
expect(dock1024.rightDockX).toBe(624)
expect(dock1024.channelWidthLogical).toBe(224)
expect(dock1024.barOffsetX).toBe(112)
expect(dock1024.barOffsetY).toBe(168)
expect(dock1024.panelOffsetY).toBe(84)
const layout800 = computeHudLayout(800, 600, 1)
expect(layout800.uiScale).toBe(1)
})
it('intercepts HUD clicks accurately in 1024x768 while leaving the 224px center gap open for world clicks', () => {
const mockCanvas = {
width: 1024,
height: 768,
clientWidth: 1024,
clientHeight: 768,
style: { cursor: '', pointerEvents: '' },
getBoundingClientRect: () => ({ left: 0, top: 0, width: 1024, height: 768 }),
} as unknown as HTMLCanvasElement
const hud = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud.setViewportProfile('1024x768')
hud.toggleLeftPanel('char')
hud.toggleRightPanel('inv')
// Left panel (0..400, 84..637) is intercepted
expect(hud.isPointInterceptedByHud(200, 300)).toBe(true)
// Above left panel (y = 40 < 84) is NOT intercepted
expect(hud.isPointInterceptedByHud(200, 40)).toBe(false)
// Right panel (624..1024, 84..637) is intercepted
expect(hud.isPointInterceptedByHud(800, 300)).toBe(true)
// 224px center gap (400 < x < 624) is NOT intercepted even with both panels open
expect(hud.isPointInterceptedByHud(512, 300)).toBe(false)
// Centered bottom control bar (x = 112..912, y = 720..768) is intercepted
expect(hud.isPointInterceptedByHud(512, 740)).toBe(true)
// Left/right of the 800px bottom bar at the bottom corners (x = 50, y = 740) is NOT intercepted
expect(hud.isPointInterceptedByHud(50, 740)).toBe(false)
})
it('round-trips world/sub-tile <-> screen coordinates and resizes DOM targets in lockstep', () => {
for (const profile of [VIEWPORT_800x600, VIEWPORT_1024x768]) {
const camX = 1280
const camY = 640
const screen = worldToScreen(1440, 720, camX, camY, profile)
const world = screenToWorld(screen.x, screen.y, camX, camY, profile)
expect(world.x).toBeCloseTo(1440, 5)
expect(world.y).toBeCloseTo(720, 5)
const sSub = subTileToScreen(110, 95, 100, 100, profile)
const bSub = screenToSubTile(sSub.x, sSub.y, 100, 100, profile)
expect(bSub.x).toBeCloseTo(110, 5)
expect(bSub.y).toBeCloseTo(95, 5)
}
const stageEl = { style: { width: '', height: '' } }
const glCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
const hudCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
applyViewportProfileToDom('1024x768', { stageEl, glCanvas, hudCanvas })
expect(stageEl.style.width).toBe('1024px')
expect(stageEl.style.height).toBe('768px')
expect(glCanvas.width).toBe(1024)
expect(hudCanvas.height).toBe(768)
})
it('stretches both 800x600 and 1024x768 to fit the browser container while retaining 4:3 aspect ratio and side black bars', () => {
// Widescreen 1920x896 wrapper (1920x932 browser minus 36px toolbar):
const fit800 = computeViewportFitSize('800x600', 1920, 896)
expect(fit800.cssHeight).toBe(896)
expect(fit800.cssWidth).toBe(Math.round(896 * (4 / 3)))
expect(fit800.letterboxLeft).toBeGreaterThan(0)
expect(fit800.letterboxTop).toBe(0)
const fit1024 = computeViewportFitSize('1024x768', 1920, 896)
expect(fit1024.cssHeight).toBe(896)
expect(fit1024.cssWidth).toBe(fit800.cssWidth)
expect(fit1024.letterboxLeft).toBe(fit800.letterboxLeft)
// Narrow/portrait 800x1000 container:
const fitNarrow = computeViewportFitSize('1024x768', 800, 1000)
expect(fitNarrow.cssWidth).toBe(800)
expect(fitNarrow.cssHeight).toBe(600)
expect(fitNarrow.letterboxLeft).toBe(0)
expect(fitNarrow.letterboxTop).toBe(200)
// DOM application with containerEl stretches stage CSS to fit while keeping internal canvas resolution exact
const containerEl = { clientWidth: 1920, clientHeight: 1080 }
const stageEl = { style: { width: '', height: '' } }
const glCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
const hudCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
applyViewportProfileToDom('800x600', { containerEl, stageEl, glCanvas, hudCanvas })
expect(stageEl.style.width).toBe('1440px')
expect(stageEl.style.height).toBe('1080px')
expect(glCanvas.width).toBe(800)
expect(glCanvas.height).toBe(600)
expect(glCanvas.style.width).toBe('100%')
expect(glCanvas.style.height).toBe('100%')
expect(hudCanvas.style.width).toBe('100%')
expect(hudCanvas.style.height).toBe('100%')
applyViewportProfileToDom('1024x768', { containerEl, stageEl, glCanvas, hudCanvas })
expect(stageEl.style.width).toBe('1440px')
expect(stageEl.style.height).toBe('1080px')
expect(glCanvas.width).toBe(1024)
expect(glCanvas.height).toBe(768)
expect(glCanvas.style.width).toBe('100%')
expect(glCanvas.style.height).toBe('100%')
})
})
describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => {
it('normalizes D2GS world sub-tiles by LevelView.subOriginX/Y and submits tile + unit quads to SpriteRenderer', () => {
const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(0x11223344, 0, 1)
const localSubX = Math.floor((levelView.cellsX * 5) / 2)
const localSubY = Math.floor((levelView.cellsY * 5) / 2)
const worldSubX = levelView.subOriginX + localSubX
const worldSubY = levelView.subOriginY + localSubY
const playerOrthoX = levelView.isoOriginX + (localSubX - localSubY) * 16
const playerOrthoY = levelView.isoOriginY + (localSubX + localSubY) * 8
;(levelView.floors as unknown[]).push({
x: playerOrthoX,
y: playerOrthoY,
cellX: Math.floor(localSubX / 5),
cellY: Math.floor(localSubY / 5),
layer: 0,
kind: 'floor',
library: 0,
tile: 0,
dt1Path: 'data/global/tiles/act1/town/floor.dt1',
placement: {
act: 1,
page: 0,
x: 0,
y: 0,
w: 160,
h: 80,
dx: -80,
dy: -40,
},
})
const units: RenderableUnitInput[] = [
{
kind: 'player',
id: 1,
classId: 1,
x: worldSubX,
y: worldSubY,
isLocalPlayer: true,
},
{
kind: 'monster',
id: 2,
classId: 148, // Akara
x: worldSubX + 5,
y: worldSubY + 5,
name: 'Akara',
},
]
const source = new ClientWorldSceneSource(
(): ClientSceneWorldAdapter => ({
tick: 12,
actId: 1,
levelId: 1,
playerSubX: worldSubX,
playerSubY: worldSubY,
units,
levelView,
}),
'800x600',
)
let beginCalls = 0
let drawCalls = 0
let solidCalls = 0
let flushCalls = 0
const fakeRenderer = {
begin() {
beginCalls += 1
},
draw() {
drawCalls += 1
},
drawSolid() {
solidCalls += 1
},
setLighting() {},
setPalette() {},
addIndexedAtlas() {
return { index: 1, width: 2048, height: 1932 }
},
flush() {
flushCalls += 1
},
}
const fakeTileGpuCache = {
loadedPageCount: 1,
ensureActPalette() {},
getPageHandle() {
return { index: 1, width: 2048, height: 1932 }
},
}
const snap = renderSceneSourceFrame({
source,
lightingPreset: 'fullbright',
renderer: fakeRenderer as never,
tileGpuCache: fakeTileGpuCache as never,
})
expect(snap.floorQuads.length).toBeGreaterThan(0)
expect(beginCalls).toBe(1)
expect(drawCalls).toBeGreaterThanOrEqual(snap.floorQuads.length)
expect(solidCalls).toBeGreaterThanOrEqual(2)
expect(flushCalls).toBe(1)
})
})
describe('AutomapView Reveal Modes: off / level / act (F17)', () => {
it('switches cleanly between off (server rooms only), level (all current area rooms), and act (multi-level Act rooms)', () => {
const automap = new AutomapView({
initialMode: 'off',
actRevealLevelIds: {
1: [1, 2, 3],
2: [40],
3: [75],
4: [103],
5: [109],
},
})
const view = automap.setContext(0xdeadbeef, 0, 1)
const allRoomsInArea1 = view.rooms
expect(allRoomsInArea1.length).toBeGreaterThan(0)
// In 'off' mode with 0 server rooms revealed, 0 rooms are visible
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
// Reveal first room via server 0x07 AddMapRoom
const firstRoom = allRoomsInArea1[0]!
automap.onServerAddRoom(firstRoom.tileX, firstRoom.tileY, 1)
const offSnap = automap.buildSnapshot()
expect(offSnap.visibleRoomCount).toBeGreaterThanOrEqual(1)
expect(offSnap.serverRevealedRoomCount).toBe(1)
// Switch to 'level' mode: all rooms in area 1 become visible
automap.setMode('level')
const levelSnap = automap.buildSnapshot()
expect(levelSnap.visibleAreaIds).toEqual([1])
expect(levelSnap.visibleRoomCount).toBe(allRoomsInArea1.length)
// Switch to 'act' mode: levels 1, 2, and 3 in Act 1 are generated and revealed
automap.setMode('act')
const actSnap = automap.buildSnapshot()
expect(actSnap.visibleAreaIds).toEqual([1, 2, 3])
expect(actSnap.visibleRoomCount).toBeGreaterThan(allRoomsInArea1.length)
expect(actSnap.warpMarkers.length).toBeGreaterThanOrEqual(3)
// Switch back to 'off' and remove the server room
automap.setMode('off')
automap.onServerRemoveRoom(firstRoom.tileX, firstRoom.tileY, 1)
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
})
})
describe('HttpRangeMpqSource, OpfsMpqManager & SoundService (F19)', () => {
it('fetches byte ranges with Range header and caches chunks to avoid duplicate network fetches', async () => {
const fakeArchive = new Uint8Array(1024)
for (let i = 0; i < fakeArchive.length; i++) {
fakeArchive[i] = i & 0xff
}
const fetchSpy = vi.fn(async (_url: string, init?: { readonly headers?: Readonly<Record<string, string>> }) => {
const rangeHeader = init?.headers?.Range ?? ''
const m = /^bytes=(\d+)-(\d+)$/.exec(rangeHeader)
const start = m ? Number(m[1]) : 0
const end = m ? Number(m[2]) : fakeArchive.length - 1
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
},
},
async arrayBuffer() {
return slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength)
},
}
})
const cache = new InMemoryChunkCache()
const source = new HttpRangeMpqSource({
url: '/diablo2/data/d2sfx.mpq',
label: 'd2sfx.mpq',
cache,
fetchFn: fetchSpy,
})
const firstRead = await source.read(0, 32)
expect(firstRead).toHaveLength(32)
expect(firstRead[15]).toBe(15)
expect(source.size).toBe(1024)
expect(source.rangeRequestsCount).toBe(1)
expect(source.cacheHitsCount).toBe(0)
// Second read of same range hits chunk cache without invoking fetch
const secondRead = await source.read(0, 32)
expect(secondRead).toEqual(firstRead)
expect(source.rangeRequestsCount).toBe(1)
expect(source.cacheHitsCount).toBe(1)
const manager = new OpfsMpqManager({
baseUrl: '/diablo2/data',
cache,
fetchFn: fetchSpy,
})
expect(manager.getSource('d2sfx.mpq').url).toBe('/diablo2/data/d2sfx.mpq')
})
it('maps Sounds.txt IDs, SoundEnviron.txt level music, spatial attenuation, and SettingsStore mute/volume', async () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'zh',
syncGlobalLang: false,
})
const playedSfx: { id: string; volume: number }[] = []
const playedBgm: number[] = []
let mutedState = false
const mockBackend: AudioBackendLike = {
async init() {},
setMuted(m) {
mutedState = m
},
async playBGM(actOrId) {
playedBgm.push(actOrId)
},
playSFX(id, volume = 1) {
playedSfx.push({ id, volume })
},
playButtonClick() {
playedSfx.push({ id: 'button', volume: 1 })
},
playFootstep() {},
playPortalEnter() {
playedSfx.push({ id: 'portal_enter', volume: 1 })
},
playWaypointActivate() {
playedSfx.push({ id: 'waypoint_activate', volume: 1 })
},
}
const soundService = new SoundService({ store, backend: mockBackend })
await soundService.init()
const envAct4 = await soundService.onEnterArea(103)
expect(envAct4.actId).toBe(4)
expect(playedBgm).toEqual([4])
expect(resolveSoundEnvironmentForLevel(109).actId).toBe(5)
// Play FireBall (47) near player
const rec = soundService.playSoundById(47, {
sourceSubX: 105,
sourceSubY: 100,
listenerSubX: 100,
listenerSubY: 100,
})
expect(rec).not.toBeNull()
expect(rec!.sfxId).toBe('skill_fire')
expect(rec!.pan).toBeGreaterThan(0)
expect(playedSfx).toHaveLength(1)
// Far-away emitter (>45 sub-tiles) is culled
const culled = computeSpatialAudioMetrics(200, 200, 100, 100)
expect(culled.culled).toBe(true)
// Muting suppresses playback
soundService.setMuted(true)
expect(mutedState).toBe(true)
expect(soundService.playSoundById(47)).toBeNull()
soundService.destroy()
})
})
describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
it('throttles rapid movement packets, coalesces trailing move target, and never drops Ping heartbeats', () => {
const limiter = new OutboundRateLimiter({
moveMinIntervalMs: 120,
skillMinIntervalMs: 100,
burstCapacity: 5,
refillPerSec: 10,
})
const move1 = { type: 'RunToLocation', x: asSubTile(100), y: asSubTile(100) } as const
const move2 = { type: 'RunToLocation', x: asSubTile(105), y: asSubTile(102) } as const
// First movement at t=1000ms is allowed
const d1 = limiter.tryAcquire(move1, 1000)
expect(d1.allowed).toBe(true)
// Identical duplicate move at t=1050ms is suppressed
const dDup = limiter.tryAcquire(move1, 1050)
expect(dDup.allowed).toBe(false)
expect(dDup.reason).toBe('duplicate_move')
// Different movement target at t=1060ms is throttled by cooldown and coalesced
const d2 = limiter.tryAcquire(move2, 1060)
expect(d2.allowed).toBe(false)
expect(d2.reason).toBe('category_cooldown')
expect(d2.coalesced).toBe(true)
// At t=1130ms (after 120ms cooldown), flushPendingMove emits the coalesced move2
const flushed = limiter.flushPendingMove(1130)
expect(flushed).toEqual(move2)
// Ping heartbeat is always allowed
expect(limiter.tryAcquire({ type: 'Ping' }, 1131).allowed).toBe(true)
})
it('captures PacketTap traffic, filters by phase/direction/opcode, exports .d2cap JSONL, and replays deterministically', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const tap = new InMemoryPacketTap()
const replayed: number[] = []
const inspector = new PacketInspector({
store,
tap,
onReplayRecord: frame => replayed.push(frame.opcode),
})
tap.record({
t: 10,
conn: 'bnet',
dir: 'c2s',
packetId: 0x50,
raw: new Uint8Array([0xff, 0x50, 0x3a, 0x00]),
})
tap.record({
t: 25,
conn: 'game',
dir: 's2c',
packetId: 0x15,
raw: new Uint8Array([0x15, 0x00, 0x01, 0x00, 0x00, 0x00]),
})
tap.record({
t: 40,
conn: 'game',
dir: 'c2s',
packetId: 0x03,
raw: new Uint8Array([0x03, 0x64, 0x00, 0x64, 0x00]),
})
expect(inspector.getAllRows()).toHaveLength(3)
expect(formatHexDump(new Uint8Array([0x15, 0x41, 0x42]))).toContain('15 41 42')
// Filter by d2gs + s2c
inspector.setFilter({ phase: 'd2gs', direction: 's2c' })
const s2cRows = inspector.getFilteredRows()
expect(s2cRows).toHaveLength(1)
expect(s2cRows[0]!.opcode).toBe(0x15)
// Filter by hex opcode string
inspector.setFilter({ phase: 'all', direction: 'all', opcode: '0x03' })
expect(inspector.getFilteredRows()).toHaveLength(1)
// Export to .d2cap JSONL and replay into a fresh inspector
const jsonl = inspector.exportD2CapJsonl()
expect(jsonl.split('\n')).toHaveLength(3)
inspector.clear()
inspector.setFilter({ phase: 'all', direction: 'all', opcode: null })
const loaded = inspector.loadD2CapJsonl(jsonl)
expect(loaded).toHaveLength(3)
const step1 = inspector.stepReplay()
expect(step1?.opcode).toBe(0x50)
expect(replayed).toEqual([0x50])
const remaining = inspector.replayAll()
expect(remaining).toBe(2)
expect(replayed).toEqual([0x50, 0x15, 0x03])
inspector.destroy()
})
})