diff --git a/src/client/automap/automap-view.ts b/src/client/automap/automap-view.ts index 230aa59..50fe585 100644 --- a/src/client/automap/automap-view.ts +++ b/src/client/automap/automap-view.ts @@ -288,6 +288,25 @@ export class AutomapView { 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. */ @@ -498,36 +517,62 @@ export class AutomapView { /** * 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 view = this.ensureLevelView(this.currentAreaId) const allRooms = view.rooms let minCellX = 0 let minCellY = 0 - let maxCellX = 40 - let maxCellY = 40 - if (allRooms.length > 0) { - minCellX = Math.min(...allRooms.map(r => r.tileX)) - minCellY = Math.min(...allRooms.map(r => r.tileY)) - maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW)) - maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH)) - } + let cellsX = 40 + let cellsY = 40 + let subW = 200 + let blocked: Uint8Array + let originX = 0 + let originY = 0 - const cellsX = Math.max(1, maxCellX - minCellX) - const cellsY = Math.max(1, maxCellY - minCellY) - const subW = cellsX * 5 - const subH = cellsY * 5 - const blocked = new Uint8Array(subW * subH) - - for (let sy = 0; sy < subH; sy++) { - for (let sx = 0; sx < subW; sx++) { - const absSubX = minCellX * 5 + sx - const absSubY = minCellY * 5 + sy - if (!isWalkableWorldSubTile(view, absSubX, absSubY)) { - blocked[sy * subW + sx] = 1 + 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 maxCellY = 40 + if (allRooms.length > 0) { + minCellX = Math.min(...allRooms.map(r => r.tileX)) + minCellY = Math.min(...allRooms.map(r => r.tileY)) + maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW)) + maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH)) + } + cellsX = Math.max(1, maxCellX - minCellX) + cellsY = Math.max(1, maxCellY - minCellY) + subW = cellsX * 5 + const subH = cellsY * 5 + blocked = new Uint8Array(subW * subH) + for (let sy = 0; sy < subH; sy++) { + for (let sx = 0; sx < subW; sx++) { + const absSubX = minCellX * 5 + sx + const absSubY = minCellY * 5 + sy + if (!isWalkableWorldSubTile(view, absSubX, absSubY)) { + blocked[sy * subW + sx] = 1 + } } } + originX = (minCellX - minCellY) * 80 + originY = (minCellX + minCellY) * 40 } const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({ @@ -538,6 +583,20 @@ export class AutomapView { 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 { key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`, cellsX, @@ -545,8 +604,8 @@ export class AutomapView { blocked, gridWidth: subW, markers, - originX: (minCellX - minCellY) * 80, - originY: (minCellX + minCellY) * 40, + originX, + originY, levelId: this.currentAreaId, levelName: levelDisplayName(this.currentAreaId, lang), ...(view.automap !== undefined ? { automap: view.automap } : {}), diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 8ef5dda..b021064 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -897,13 +897,22 @@ export class CommandMapper { return this.lastPath } + const snap = this.predictor.getSnapshot() + const sx = Math.round(snap.logicalX) + const sy = Math.round(snap.logicalY) const levelView = - this.mapService?.getActiveLevelView() ?? this.automapView?.getLevelView() ?? null + (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 snap = this.predictor.getSnapshot() - const sx = Math.round(snap.logicalX) - const sy = Math.round(snap.logicalY) const ox = levelView.subOriginX ?? 0 const oy = levelView.subOriginY ?? 0 const inBounds = diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index e7680d2..20b45b0 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -927,11 +927,19 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram ctx.minimap.visible = false } else { 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 = { ...baseMinimapLevel, - originX: levelView?.isoOriginX ?? 0, - originY: levelView?.isoOriginY ?? 0, + originX: levelView?.isoOriginX ?? baseMinimapLevel.originX ?? 0, + originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0, preRevealTown: isTownLevel(snapshot.levelId), } const localSub = snapshot.playerLocalSubTile diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..abc30c7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -270,6 +270,7 @@ export class OnlineSession { private disconnectReasonValue: string | null = null private pingMsValue = 0 private tickCounter = 0 + private readonly knownAreaIds = new Set() private readonly outboundCommandsLog: ClientCommand[] = [] 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 // canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a // 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 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 levelView = - 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 levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0) return { tick: this.tickCounter, @@ -694,6 +689,7 @@ export class OnlineSession { const savedName = this.world.self.name const savedClass = this.world.self.charClass this.world.reset() + this.knownAreaIds.clear() this.world.self.name = savedName this.world.self.charClass = savedClass 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 * 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( Math.round(snap.logicalX), Math.round(snap.logicalY), - [this.world.areaId, ...this.world.getRevealedAreaIds()], + this.getCandidateAreaIds(), ) return levelView === null ? null : isTownLevel(levelView.areaId) } @@ -1070,7 +1126,11 @@ export class OnlineSession { 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( ev.mapSeed, this.world.difficulty, @@ -1082,6 +1142,9 @@ export class OnlineSession { } case 'RoomReveal': { + if (ev.areaId > 0) { + this.knownAreaIds.add(ev.areaId) + } if (this.mapService) { try { 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 this.hudModel.syncFromWorld() @@ -1305,6 +1371,8 @@ export class OnlineSession { unit.direction = state.dir64 } } + + this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) } /** @@ -1359,10 +1427,7 @@ export class OnlineSession { const activeLevelView = this.levelView ?? (this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0) - ? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [ - this.world.areaId, - ...this.world.getRevealedAreaIds(), - ]) + ? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, this.getCandidateAreaIds()) : null) ?? this.mapService?.getActiveLevelView() ?? this.automapView.getLevelView() diff --git a/src/client/ui/minimap.ts b/src/client/ui/minimap.ts index 2d7f3a2..71e38c6 100644 --- a/src/client/ui/minimap.ts +++ b/src/client/ui/minimap.ts @@ -498,6 +498,9 @@ export class Minimap { /** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */ private readonly lastRevealSubPos = new Map() + /** 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. */ readonly lastRenderStats: AutomapRenderTelemetry = { mode: 'minimap', @@ -869,6 +872,11 @@ export class Minimap { viewport: { readonly width: number; readonly height: number }, options?: MinimapDrawOptions, ): 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 screenH = Math.max(1, Math.trunc(viewport.height)) const vp = computeAutomapViewport( diff --git a/tests/client/minimap-level-switch-549.test.ts b/tests/client/minimap-level-switch-549.test.ts new file mode 100644 index 0000000..01bdc0d --- /dev/null +++ b/tests/client/minimap-level-switch-549.test.ts @@ -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) + }) +})