diablo2-web/tests/client/minimap-level-switch-549.te...

245 lines
10 KiB
TypeScript

/**
* Issue #549 regression: Minimap / Automap must switch synchronously with the main map
* when the player transitions between levels/areas within an Act (outdoor border crossing,
* dungeon warp, town portal, or waypoint) without a new `0x03 LoadAct` packet.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import '../../tools/lib/node-map-loader.ts'
import { AutomapView } from '../../src/client/automap/automap-view.ts'
import { MapService } from '../../src/client/map/map-service.ts'
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { createSettingsStore } from '../../src/client/settings/settings-store.ts'
import { Minimap } from '../../src/client/ui/minimap.ts'
import { levelDisplayName } from '../../src/common/world/level-names-zh.ts'
const FIXTURE_DIR = resolve('tests/fixtures/netproto')
const ROGUE_ENCAMPMENT = 1
const BLOOD_MOOR = 2
const DEN_OF_EVIL = 8
function createNoopSpriteRenderer() {
return {
begin() {},
draw() {},
drawSolidQuad() {},
setLighting() {},
setPalette() {},
flush() {},
getGl() {
return null
},
}
}
function createFakeTileGpuCache() {
return {
loadedPageCount: 1,
ensureActPalette() {},
getPageHandle() {
return { index: 1, width: 2048, height: 1932 }
},
}
}
describe('Issue #549: Minimap / Automap switches synchronously with main map on area transitions', () => {
let mapService: MapService
beforeAll(async () => {
mapService = await MapService.fromPackedDir(resolve('public/act-packs/tiles'))
})
it('AutomapView.syncActiveLevel switches currentAreaId without wiping serverRevealedRooms across the Act', () => {
const automap = new AutomapView({ mapService, initialMode: 'off' })
const townView = automap.setContext(0x52e4e6ff, 0, ROGUE_ENCAMPMENT)
const townRoom = townView.rooms[0]!
automap.onServerAddRoom(townRoom.tileX, townRoom.tileY, ROGUE_ENCAMPMENT)
expect(automap.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT)
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all')
const bmRoom = bloodMoorView.rooms[0]!
automap.onServerAddRoom(bmRoom.tileX, bmRoom.tileY, BLOOD_MOOR)
// Switch to Blood Moor via syncActiveLevel: preserves revealed rooms in both Area 1 and Area 2
automap.syncActiveLevel(BLOOD_MOOR, bloodMoorView)
expect(automap.getCurrentAreaId()).toBe(BLOOD_MOOR)
expect(automap.buildSnapshot().currentAreaId).toBe(BLOOD_MOOR)
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
const bmMinimapLevel = automap.buildMinimapLevel('ENG')
expect(bmMinimapLevel.levelId).toBe(BLOOD_MOOR)
expect(bmMinimapLevel.levelName).toBe(levelDisplayName(BLOOD_MOOR, 'ENG'))
expect(bmMinimapLevel.cellsX).toBe(bloodMoorView.cellsX)
expect(bmMinimapLevel.cellsY).toBe(bloodMoorView.cellsY)
expect(bmMinimapLevel.markers.some((m) => m.kind === 'exit')).toBe(true)
// Switch back to Rogue Encampment: Area 1 revealed rooms are still intact
automap.syncActiveLevel(ROGUE_ENCAMPMENT, townView)
expect(automap.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT)
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
})
it('OnlineSession keeps automapView and world.areaId in lockstep across Area 1 -> Area 2 -> Area 8 -> Area 1 transitions', () => {
const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8')
const settingsStore = createSettingsStore()
settingsStore.set({ automapReveal: 'level' })
const session = new OnlineSession({ settingsStore, mapService })
session.loadD2CapReplay(text)
session.replayAllD2Cap()
// Initial state: standing in Rogue Encampment (Area 1), even though Area 2 rooms were pre-revealed by 0x07
expect(session.world.getRevealedAreaIds()).toContain(BLOOD_MOOR)
expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT)
expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT)
expect(session.automapView.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT)
expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(ROGUE_ENCAMPMENT)
// 1. Player walks/moves across border into Blood Moor (Area 2) without a new LoadAct packet
const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all')
const bmSubX = bloodMoorView.subOriginX + bloodMoorView.spawnX * 5 + 2
const bmSubY = bloodMoorView.subOriginY + bloodMoorView.spawnY * 5 + 2
session.handleServerEvent({
type: 'UnitReassign',
unitType: 'player',
id: session.world.selfId,
at: { x: bmSubX, y: bmSubY },
snap: true,
})
expect(session.sceneSource.getLevelView()?.areaId).toBe(BLOOD_MOOR)
expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(BLOOD_MOOR)
expect(session.world.areaId).toBe(BLOOD_MOOR)
expect(session.automapView.getCurrentAreaId()).toBe(BLOOD_MOOR)
expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(BLOOD_MOOR)
expect(session.automapView.buildMinimapLevel('ENG').levelName).toBe(
levelDisplayName(BLOOD_MOOR, 'ENG'),
)
// 2. Player enters Den of Evil (Area 8): server reveals Area 8 room via 0x07 and reassigns player
const denView = mapService.getLevelView(DEN_OF_EVIL, 'all')
const denRoom = denView.rooms[0]!
session.handleServerEvent({
type: 'RoomReveal',
areaId: DEN_OF_EVIL,
tileX: denRoom.tileX,
tileY: denRoom.tileY,
})
const denSubX = denView.subOriginX + denView.spawnX * 5 + 2
const denSubY = denView.subOriginY + denView.spawnY * 5 + 2
session.handleServerEvent({
type: 'UnitReassign',
unitType: 'player',
id: session.world.selfId,
at: { x: denSubX, y: denSubY },
snap: true,
})
expect(session.sceneSource.getLevelView()?.areaId).toBe(DEN_OF_EVIL)
expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(DEN_OF_EVIL)
expect(session.world.areaId).toBe(DEN_OF_EVIL)
expect(session.automapView.getCurrentAreaId()).toBe(DEN_OF_EVIL)
expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(DEN_OF_EVIL)
expect(session.automapView.buildMinimapLevel('ENG').levelName).toBe(
levelDisplayName(DEN_OF_EVIL, 'ENG'),
)
// 3. Player returns to Rogue Encampment (Area 1) via Town Portal / walk back
const townView = mapService.getLevelView(ROGUE_ENCAMPMENT, 'all')
const townSubX = townView.subOriginX + townView.spawnX * 5 + 2
const townSubY = townView.subOriginY + townView.spawnY * 5 + 2
session.handleServerEvent({
type: 'UnitReassign',
unitType: 'player',
id: session.world.selfId,
at: { x: townSubX, y: townSubY },
snap: true,
})
expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT)
expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(ROGUE_ENCAMPMENT)
expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT)
expect(session.automapView.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT)
expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(ROGUE_ENCAMPMENT)
})
it('renderSceneSourceFrame + Minimap switches level geometry, HUD title, and resets manual pan when moving across areas', () => {
const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8')
const settingsStore = createSettingsStore()
settingsStore.set({ automapReveal: 'level' })
const session = new OnlineSession({ settingsStore, mapService })
session.loadD2CapReplay(text)
session.replayAllD2Cap()
const minimap = new Minimap()
minimap.mode = 'full'
const renderer = createNoopSpriteRenderer()
const tileGpuCache = createFakeTileGpuCache()
// Frame 1: in Rogue Encampment (Area 1)
renderSceneSourceFrame({
source: session.sceneSource,
lightingPreset: 'fullbright',
renderer: renderer as never,
tileGpuCache: tileGpuCache as never,
minimap,
automapView: session.automapView,
})
expect(minimap.hudTextLastFrame?.levelName).toBe(levelDisplayName(ROGUE_ENCAMPMENT))
// User manually pans the full automap in Area 1
minimap.pan(32, -24)
expect(minimap.panX).toBe(32)
expect(minimap.panY).toBe(-24)
// Local predictor moves player into Blood Moor (Area 2) via session.tick()
const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all')
const bmSubX = bloodMoorView.subOriginX + bloodMoorView.spawnX * 5 + 2
const bmSubY = bloodMoorView.subOriginY + bloodMoorView.spawnY * 5 + 2
session.predictor.setPosition(bmSubX, bmSubY)
session.tick(40, 2000)
// Frame 2: in Blood Moor (Area 2)
const snap2 = renderSceneSourceFrame({
source: session.sceneSource,
lightingPreset: 'fullbright',
renderer: renderer as never,
tileGpuCache: tileGpuCache as never,
minimap,
automapView: session.automapView,
})
expect(snap2.levelId).toBe(BLOOD_MOOR)
expect(session.automapView.getCurrentAreaId()).toBe(BLOOD_MOOR)
expect(minimap.hudTextLastFrame?.levelName).toBe(levelDisplayName(BLOOD_MOOR))
// Manual pan from Area 1 is automatically reset to (0, 0) upon switching to Area 2
expect(minimap.panX).toBe(0)
expect(minimap.panY).toBe(0)
expect(minimap.revealedRoomsCount).toBeGreaterThan(0)
})
it('CommandMapper resolves local pathfinding against the level containing the player even when 0x07 revealed a neighbouring level', () => {
const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8')
const settingsStore = createSettingsStore()
settingsStore.set({ automapReveal: 'level' })
const session = new OnlineSession({ settingsStore, mapService })
session.loadD2CapReplay(text)
session.replayAllD2Cap()
// Precondition: MapService.getActiveLevelView() was switched to Area 2 by 0x07 RoomReveal,
// while player is standing in Area 1 (Rogue Encampment).
expect(mapService.getActiveLevelView()?.areaId).toBe(BLOOD_MOOR)
expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT)
const playerX = Math.round(session.predictor.getSnapshot().logicalX)
const playerY = Math.round(session.predictor.getSnapshot().logicalY)
const targetScreen = session.camera.subtileToScreen(playerX + 3, playerY)
session.commandMapper.handlePointerDown(targetScreen.x, targetScreen.y, 0, 5000)
session.commandMapper.handlePointerUp(0)
const predAfterClick = session.predictor.getSnapshot()
expect(predAfterClick.moving).toBe(true)
})
})