Merge branch 'fix/issue-549-minimap-level-switch' into main (Fixes #549)

This commit is contained in:
troytt 2026-10-02 08:03:13 +00:00
commit 47d18fce72
6 changed files with 437 additions and 44 deletions

View File

@ -288,6 +288,25 @@ export class AutomapView {
return this.levelViews.get(areaId)! return this.levelViews.get(areaId)!
} }
/**
* Switch the active level (`currentAreaId` / `currentActId`) within the current game session
* without clearing `levelViews` or `serverRevealedRooms` (#549).
*/
syncActiveLevel(areaId: number, existingLevelView?: LevelView): LevelView {
if (areaId > 0) {
this.currentAreaId = areaId
this.currentActId = getActForArea(areaId)
}
const targetAreaId = this.currentAreaId
if (existingLevelView && existingLevelView.areaId === targetAreaId) {
this.levelViews.set(targetAreaId, existingLevelView)
} else if (!this.levelViews.has(targetAreaId)) {
this.ensureLevelView(targetAreaId)
}
this.applyRevealModeToViews()
return this.levelViews.get(targetAreaId)!
}
/** /**
* Record a server `0x07 AddMapRoom` event. * Record a server `0x07 AddMapRoom` event.
*/ */
@ -498,13 +517,38 @@ export class AutomapView {
/** /**
* Build a `MinimapLevel` descriptor suitable for `Minimap.draw(...)` (`src/ui/minimap.ts`). * Build a `MinimapLevel` descriptor suitable for `Minimap.draw(...)` (`src/ui/minimap.ts`).
*/ */
buildMinimapLevel(lang: TblLang = getTblLang()): MinimapLevel { buildMinimapLevel(lang: TblLang = getTblLang(), areaId?: number): MinimapLevel {
if (areaId !== undefined && areaId > 0 && areaId !== this.currentAreaId) {
this.syncActiveLevel(areaId)
}
const snap = this.buildSnapshot([], lang) const snap = this.buildSnapshot([], lang)
const view = this.ensureLevelView(this.currentAreaId) const view = this.ensureLevelView(this.currentAreaId)
const allRooms = view.rooms const allRooms = view.rooms
let minCellX = 0 let minCellX = 0
let minCellY = 0 let minCellY = 0
let cellsX = 40
let cellsY = 40
let subW = 200
let blocked: Uint8Array
let originX = 0
let originY = 0
if (
view.cellsX > 0 &&
view.cellsY > 0 &&
view.blocked &&
view.blocked.length === view.cellsX * 5 * view.cellsY * 5
) {
minCellX = view.tileOriginX ?? Math.floor((view.subOriginX ?? 0) / 5)
minCellY = view.tileOriginY ?? Math.floor((view.subOriginY ?? 0) / 5)
cellsX = view.cellsX
cellsY = view.cellsY
subW = view.gridWidth || cellsX * 5
blocked = view.blocked
originX = view.isoOriginX ?? (minCellX - minCellY) * 80
originY = view.isoOriginY ?? (minCellX + minCellY) * 40
} else {
let maxCellX = 40 let maxCellX = 40
let maxCellY = 40 let maxCellY = 40
if (allRooms.length > 0) { if (allRooms.length > 0) {
@ -513,13 +557,11 @@ export class AutomapView {
maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW)) maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW))
maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH)) maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH))
} }
cellsX = Math.max(1, maxCellX - minCellX)
const cellsX = Math.max(1, maxCellX - minCellX) cellsY = Math.max(1, maxCellY - minCellY)
const cellsY = Math.max(1, maxCellY - minCellY) subW = cellsX * 5
const subW = cellsX * 5
const subH = cellsY * 5 const subH = cellsY * 5
const blocked = new Uint8Array(subW * subH) blocked = new Uint8Array(subW * subH)
for (let sy = 0; sy < subH; sy++) { for (let sy = 0; sy < subH; sy++) {
for (let sx = 0; sx < subW; sx++) { for (let sx = 0; sx < subW; sx++) {
const absSubX = minCellX * 5 + sx const absSubX = minCellX * 5 + sx
@ -529,6 +571,9 @@ export class AutomapView {
} }
} }
} }
originX = (minCellX - minCellY) * 80
originY = (minCellX + minCellY) * 40
}
const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({ const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({
cellX: wm.cellX - minCellX, cellX: wm.cellX - minCellX,
@ -538,6 +583,20 @@ export class AutomapView {
targetName: wm.label, targetName: wm.label,
})) }))
if (Array.isArray(view.entrances)) {
for (const ent of view.entrances) {
if (ent.toLevelId > 0 && ent.toLevelId !== this.currentAreaId) {
markers.push({
cellX: ent.x,
cellY: ent.y,
kind: 'exit',
toLevelId: ent.toLevelId,
targetName: levelDisplayName(ent.toLevelId, lang),
})
}
}
}
return { return {
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`, key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`,
cellsX, cellsX,
@ -545,8 +604,8 @@ export class AutomapView {
blocked, blocked,
gridWidth: subW, gridWidth: subW,
markers, markers,
originX: (minCellX - minCellY) * 80, originX,
originY: (minCellX + minCellY) * 40, originY,
levelId: this.currentAreaId, levelId: this.currentAreaId,
levelName: levelDisplayName(this.currentAreaId, lang), levelName: levelDisplayName(this.currentAreaId, lang),
...(view.automap !== undefined ? { automap: view.automap } : {}), ...(view.automap !== undefined ? { automap: view.automap } : {}),

View File

@ -897,13 +897,22 @@ export class CommandMapper {
return this.lastPath return this.lastPath
} }
const levelView =
this.mapService?.getActiveLevelView() ?? this.automapView?.getLevelView() ?? null
if (levelView && levelView.walkable.length > 0) {
const snap = this.predictor.getSnapshot() const snap = this.predictor.getSnapshot()
const sx = Math.round(snap.logicalX) const sx = Math.round(snap.logicalX)
const sy = Math.round(snap.logicalY) const sy = Math.round(snap.logicalY)
const levelView =
(this.mapService && this.world.areaId > 0 && (sx !== 0 || sy !== 0)
? this.mapService.findLevelViewAtWorldSubTile(sx, sy, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null) ??
(this.world.areaId > 0 ? this.automapView?.getLevelView(this.world.areaId) : undefined) ??
this.mapService?.getActiveLevelView() ??
this.automapView?.getLevelView() ??
null
if (levelView && levelView.walkable.length > 0) {
const ox = levelView.subOriginX ?? 0 const ox = levelView.subOriginX ?? 0
const oy = levelView.subOriginY ?? 0 const oy = levelView.subOriginY ?? 0
const inBounds = const inBounds =

View File

@ -927,11 +927,19 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
ctx.minimap.visible = false ctx.minimap.visible = false
} else { } else {
ctx.minimap.visible = true ctx.minimap.visible = true
const baseMinimapLevel = ctx.automapView.buildMinimapLevel() if (levelView && levelView.areaId > 0) {
ctx.automapView.syncActiveLevel(levelView.areaId, levelView)
} else if (snapshot.levelId > 0 && ctx.automapView.getCurrentAreaId() !== snapshot.levelId) {
ctx.automapView.syncActiveLevel(snapshot.levelId)
}
const baseMinimapLevel = ctx.automapView.buildMinimapLevel(
undefined,
levelView?.areaId ?? snapshot.levelId,
)
const effectiveMinimapLevel: MinimapLevel = { const effectiveMinimapLevel: MinimapLevel = {
...baseMinimapLevel, ...baseMinimapLevel,
originX: levelView?.isoOriginX ?? 0, originX: levelView?.isoOriginX ?? baseMinimapLevel.originX ?? 0,
originY: levelView?.isoOriginY ?? 0, originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0,
preRevealTown: isTownLevel(snapshot.levelId), preRevealTown: isTownLevel(snapshot.levelId),
} }
const localSub = snapshot.playerLocalSubTile const localSub = snapshot.playerLocalSubTile

View File

@ -270,6 +270,7 @@ export class OnlineSession {
private disconnectReasonValue: string | null = null private disconnectReasonValue: string | null = null
private pingMsValue = 0 private pingMsValue = 0
private tickCounter = 0 private tickCounter = 0
private readonly knownAreaIds = new Set<number>()
private readonly outboundCommandsLog: ClientCommand[] = [] private readonly outboundCommandsLog: ClientCommand[] = []
private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>() private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
@ -373,17 +374,11 @@ export class OnlineSession {
// such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG // such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG
// canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a // canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a
// level exists the scene reports not-ready. // level exists the scene reports not-ready.
// #549: resolving the active levelView also keeps `world.areaId` and `automapView` in lockstep.
const predSnap = this.predictor.getSnapshot() const predSnap = this.predictor.getSnapshot()
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
const levelView = const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
this.levelView ??
(this.mapService && this.world.areaId > 0 && (playerSubX !== 0 || playerSubY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null)
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0) const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
return { return {
tick: this.tickCounter, tick: this.tickCounter,
@ -694,6 +689,7 @@ export class OnlineSession {
const savedName = this.world.self.name const savedName = this.world.self.name
const savedClass = this.world.self.charClass const savedClass = this.world.self.charClass
this.world.reset() this.world.reset()
this.knownAreaIds.clear()
this.world.self.name = savedName this.world.self.name = savedName
this.world.self.charClass = savedClass this.world.self.charClass = savedClass
this.predictor.setPosition(0, 0) this.predictor.setPosition(0, 0)
@ -921,6 +917,66 @@ export class OnlineSession {
} }
} }
private getCandidateAreaIds(): number[] {
const out: number[] = []
if (this.world.areaId > 0) {
out.push(this.world.areaId)
}
for (const id of this.world.getRevealedAreaIds()) {
if (id > 0 && !out.includes(id)) {
out.push(id)
}
}
for (const id of this.knownAreaIds) {
if (id > 0 && !out.includes(id)) {
out.push(id)
}
}
return out
}
/**
* Resolve the `LevelView` containing the local player's current world sub-tile and synchronize
* `world.areaId` and `automapView` whenever the player crosses into a different area (#549).
*/
private syncActiveAreaFromPlayerPosition(options?: {
readonly emitHudSync?: boolean
}): LevelView | null {
const predSnap = this.predictor.getSnapshot()
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
const candidateIds = this.getCandidateAreaIds()
const levelView =
this.levelView ??
(this.mapService && candidateIds.length > 0 && (playerSubX !== 0 || playerSubY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, candidateIds)
: null)
if (levelView && levelView.areaId > 0) {
if (this.world.areaId > 0) {
this.knownAreaIds.add(this.world.areaId)
}
this.knownAreaIds.add(levelView.areaId)
const areaChanged = this.world.areaId !== levelView.areaId
const automapOutOfSync =
this.automapView.getCurrentAreaId() !== levelView.areaId ||
this.automapView.getLevelView(levelView.areaId) === undefined
if (areaChanged) {
this.world.areaId = levelView.areaId
}
if (areaChanged || automapOutOfSync) {
this.automapView.syncActiveLevel(levelView.areaId, levelView)
}
if (areaChanged) {
void this.soundService.onEnterArea(levelView.areaId)
if (options?.emitHudSync) {
this.hudModel.syncFromWorld()
}
}
}
return levelView
}
/** /**
* `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a * `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a
* town level, or `null` while no loaded level contains it. The sub-tile is the one the gate * town level, or `null` while no loaded level contains it. The sub-tile is the one the gate
@ -932,7 +988,7 @@ export class OnlineSession {
const levelView = this.mapService.findLevelViewAtWorldSubTile( const levelView = this.mapService.findLevelViewAtWorldSubTile(
Math.round(snap.logicalX), Math.round(snap.logicalX),
Math.round(snap.logicalY), Math.round(snap.logicalY),
[this.world.areaId, ...this.world.getRevealedAreaIds()], this.getCandidateAreaIds(),
) )
return levelView === null ? null : isTownLevel(levelView.areaId) return levelView === null ? null : isTownLevel(levelView.areaId)
} }
@ -1070,7 +1126,11 @@ export class OnlineSession {
break break
} }
} }
const activeView = this.mapService?.getActiveLevelView() ?? undefined this.knownAreaIds.clear()
this.knownAreaIds.add(ev.areaId)
const activeView =
this.mapService?.getActiveLevelView() ??
(this.levelView && this.levelView.areaId === ev.areaId ? this.levelView : undefined)
this.automapView.setContext( this.automapView.setContext(
ev.mapSeed, ev.mapSeed,
this.world.difficulty, this.world.difficulty,
@ -1082,6 +1142,9 @@ export class OnlineSession {
} }
case 'RoomReveal': { case 'RoomReveal': {
if (ev.areaId > 0) {
this.knownAreaIds.add(ev.areaId)
}
if (this.mapService) { if (this.mapService) {
try { try {
this.mapService.onAddRoomDataPacket({ this.mapService.onAddRoomDataPacket({
@ -1271,6 +1334,9 @@ export class OnlineSession {
} }
} }
// Synchronize active area and AutomapView from current player position (#549)
this.syncActiveAreaFromPlayerPosition({ emitHudSync: false })
// Synchronize HudManager via HudModel // Synchronize HudManager via HudModel
this.hudModel.syncFromWorld() this.hudModel.syncFromWorld()
@ -1305,6 +1371,8 @@ export class OnlineSession {
unit.direction = state.dir64 unit.direction = state.dir64
} }
} }
this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
} }
/** /**
@ -1359,10 +1427,7 @@ export class OnlineSession {
const activeLevelView = const activeLevelView =
this.levelView ?? this.levelView ??
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0) (this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [ ? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, this.getCandidateAreaIds())
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null) ?? : null) ??
this.mapService?.getActiveLevelView() ?? this.mapService?.getActiveLevelView() ??
this.automapView.getLevelView() this.automapView.getLevelView()

View File

@ -498,6 +498,9 @@ export class Minimap {
/** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */ /** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */
private readonly lastRevealSubPos = new Map<string, { x: number; y: number }>() private readonly lastRevealSubPos = new Map<string, { x: number; y: number }>()
/** Last drawn level key so manual pan resets when switching levels (#549). */
private lastLevelKey: string | null = null
/** Diagnostic telemetry from the most recent `draw()` invocation. */ /** Diagnostic telemetry from the most recent `draw()` invocation. */
readonly lastRenderStats: AutomapRenderTelemetry = { readonly lastRenderStats: AutomapRenderTelemetry = {
mode: 'minimap', mode: 'minimap',
@ -869,6 +872,11 @@ export class Minimap {
viewport: { readonly width: number; readonly height: number }, viewport: { readonly width: number; readonly height: number },
options?: MinimapDrawOptions, options?: MinimapDrawOptions,
): void { ): void {
if (this.lastLevelKey !== null && this.lastLevelKey !== level.key) {
this.resetPan()
}
this.lastLevelKey = level.key
const screenW = Math.max(1, Math.trunc(viewport.width)) const screenW = Math.max(1, Math.trunc(viewport.width))
const screenH = Math.max(1, Math.trunc(viewport.height)) const screenH = Math.max(1, Math.trunc(viewport.height))
const vp = computeAutomapViewport( const vp = computeAutomapViewport(

View File

@ -0,0 +1,244 @@
/**
* 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)
})
})