886 lines
29 KiB
TypeScript
886 lines
29 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,
|
|
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 {
|
|
resolveClientCanvasPoint,
|
|
translateScreenClickToClientCommand,
|
|
} from '../../src/client/scene/mouse-controller.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)
|
|
})
|
|
})
|
|
|
|
describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => {
|
|
it('builds depth-sorted unit, missile (Trans: 1 additive), and overlay drawables and translates clicks to ClientCommands', () => {
|
|
const automap = new AutomapView({ initialMode: 'level' })
|
|
const levelView = automap.setContext(0x12345678, 0, 1)
|
|
|
|
const units: RenderableUnitInput[] = [
|
|
{
|
|
kind: 'player',
|
|
id: 1,
|
|
classId: 1, // Sorceress
|
|
x: 100,
|
|
y: 100,
|
|
states: [32], // BattleOrders overlay
|
|
},
|
|
{
|
|
kind: 'monster',
|
|
id: 50,
|
|
classId: 10,
|
|
x: 104,
|
|
y: 100,
|
|
lifePct: 80,
|
|
name: 'Fallen',
|
|
states: [1], // Freeze blue tint
|
|
},
|
|
{
|
|
kind: 'missile',
|
|
id: 200,
|
|
classId: 29, // FireBall (Trans: 1 additive)
|
|
x: 102,
|
|
y: 100,
|
|
targetX: 110,
|
|
targetY: 100,
|
|
},
|
|
{
|
|
kind: 'missile',
|
|
id: 201,
|
|
classId: 1, // Arrow (Trans: 0 normal)
|
|
x: 101,
|
|
y: 101,
|
|
},
|
|
]
|
|
|
|
const drawables = buildSceneDrawables(units, { tick: 5 })
|
|
expect(drawables.unitDrawables).toHaveLength(2)
|
|
expect(drawables.missileDrawables).toHaveLength(2)
|
|
expect(drawables.overlayDrawables.length).toBeGreaterThanOrEqual(2)
|
|
|
|
const fireball = drawables.missileDrawables.find((m) => m.unitId === 200)!
|
|
const arrow = drawables.missileDrawables.find((m) => m.unitId === 201)!
|
|
expect(fireball.blendMode).toBe('additive')
|
|
expect(fireball.lightRadius).toBeGreaterThan(0)
|
|
expect(arrow.blendMode).toBe('normal')
|
|
|
|
const frozenMonster = drawables.unitDrawables.find((u) => u.unitId === 50)!
|
|
expect(frozenMonster.tintColor).toEqual([90, 155, 255])
|
|
|
|
const source = new ClientWorldSceneSource(
|
|
(): ClientSceneWorldAdapter => ({
|
|
tick: 10,
|
|
actId: 1,
|
|
levelId: 1,
|
|
playerSubX: 100,
|
|
playerSubY: 100,
|
|
units,
|
|
levelView,
|
|
}),
|
|
'1024x768',
|
|
)
|
|
|
|
// Click directly on monster at (104, 100) in 1024x768
|
|
const monsterScreen = subTileToScreen(104, 100, 100, 100, VIEWPORT_1024x768)
|
|
const attackCmd = translateScreenClickToClientCommand(
|
|
monsterScreen.x,
|
|
monsterScreen.y - 16,
|
|
source,
|
|
{ button: 'left', runMode: true },
|
|
)
|
|
expect(attackCmd).toEqual({
|
|
type: 'RunToTarget',
|
|
unitKind: 'monster',
|
|
unitId: asUnitId(50),
|
|
})
|
|
|
|
// Right-click in empty space casts right skill on location
|
|
const castRightCmd = translateScreenClickToClientCommand(200, 200, source, {
|
|
button: 'right',
|
|
})
|
|
expect(castRightCmd.type).toBe('CastRightSkillOnLocation')
|
|
|
|
// Shift + Left-click in empty space casts left skill on location
|
|
const shiftLeftCmd = translateScreenClickToClientCommand(200, 200, source, {
|
|
button: 'left',
|
|
shiftHeld: true,
|
|
})
|
|
expect(shiftLeftCmd.type).toBe('CastLeftSkillOnLocation')
|
|
|
|
// Canvas CSS scaling helper
|
|
const normPt = resolveClientCanvasPoint(
|
|
{ clientX: 512, clientY: 384 },
|
|
{ left: 0, top: 0, width: 1024, height: 768 },
|
|
getViewportProfile('1024x768'),
|
|
)
|
|
expect(normPt).toEqual({ x: 512, y: 384 })
|
|
|
|
// Lighting pass via applyLightingInputs & renderSceneSourceFrame
|
|
const lightGrid = new LightGrid()
|
|
let lastLightingCall: unknown = 'unset'
|
|
const mockRenderer = {
|
|
setLighting(params: unknown) {
|
|
lastLightingCall = params
|
|
},
|
|
}
|
|
|
|
const snapTorch = renderSceneSourceFrame({
|
|
source,
|
|
lightingPreset: 'torch',
|
|
lightingTargets: { lightGrid, renderer: mockRenderer },
|
|
})
|
|
expect(snapTorch.viewportProfile.width).toBe(1024)
|
|
expect(lastLightingCall).not.toBeNull()
|
|
|
|
applyLightingInputs(source.buildLightingInputs('fullbright'), {
|
|
lightGrid,
|
|
renderer: mockRenderer,
|
|
})
|
|
expect(lastLightingCall).toBeNull()
|
|
expect(LIGHTING_PRESETS.fullbright.enabled).toBe(false)
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|