Fix automap boundaries, unhandled setContext, town NPC dots, and background exploration (Batch 2 Gate Remediation)

This commit is contained in:
troytt 2026-10-02 18:27:26 +00:00
parent 036d36bccb
commit 11cd616847
9 changed files with 1468 additions and 148 deletions

View File

@ -1,9 +1,6 @@
import { DRLG_GetActNoFromLevelId } from '../../common/drlg/drlg-drlg.ts' import { DRLG_GetActNoFromLevelId } from '../../common/drlg/drlg-drlg.ts'
import type {
PackedDrlgBundleManifest,
PackedTileCatalogIndex,
} from '../../common/pack-contract/index.ts'
import { getMonsterKindByClassId } from '../../common/units/monsters-data.ts' import { getMonsterKindByClassId } from '../../common/units/monsters-data.ts'
import { NPC_HCIDX_TO_DESCRIPTOR_ID } from '../../common/units/npc-table.ts'
import { levelDisplayName } from '../../common/world/level-names-zh.ts' import { levelDisplayName } from '../../common/world/level-names-zh.ts'
import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts' import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts'
import type { import type {
@ -128,69 +125,6 @@ function makeRoomKey(areaId: number, roomX: number, roomY: number): string {
return `${areaId}:${roomX},${roomY}` return `${areaId}:${roomX},${roomY}`
} }
interface NodeFsLike {
readFileSync(path: string, encoding: string): string
readFileSync(path: string): { buffer: ArrayBuffer; byteOffset: number; byteLength: number }
existsSync(path: string): boolean
}
interface NodePathLike {
resolve(...args: string[]): string
join(...args: string[]): string
}
/**
* Synchronously loads the packed tile bundle in Node/test environments when MapService
* was not injected via AutomapViewOptions.
*/
function tryLoadPackedBundleNodeSync(): MapService | null {
const proc = (
globalThis as unknown as {
process?: { versions?: { node?: string }; cwd?: () => string }
}
).process
if (!proc || !proc.versions?.node) {
return null
}
try {
const req =
typeof (globalThis as unknown as { require?: unknown }).require === 'function'
? (globalThis as unknown as { require: (id: string) => unknown }).require
: null
if (!req) return null
const fs = req('node:fs') as NodeFsLike | undefined
const path = req('node:path') as NodePathLike | undefined
if (!fs || !path) return null
const cwd = typeof proc.cwd === 'function' ? proc.cwd() : '.'
const candidates = [
path.resolve(cwd, 'public/act-packs/tiles'),
path.resolve(cwd, 'public/assets/act-packs/tiles'),
path.resolve('/usr/local/google/home/taodao/diablo2-web/public/act-packs/tiles'),
]
let resolvedDir: string | null = null
for (const dir of candidates) {
if (fs.existsSync(path.join(dir, 'drlg-bundle.json'))) {
resolvedDir = dir
break
}
}
if (!resolvedDir) return null
const indexJson = JSON.parse(
fs.readFileSync(path.join(resolvedDir, 'index.json'), 'utf8'),
) as PackedTileCatalogIndex
const manifest = JSON.parse(
fs.readFileSync(path.join(resolvedDir, 'drlg-bundle.json'), 'utf8'),
) as PackedDrlgBundleManifest
const buf = fs.readFileSync(path.join(resolvedDir, 'drlg-bundle.bin'))
const bundleBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
return MapService.fromPackedBundle(manifest, bundleBytes, indexJson)
} catch (err) {
throw new Error(
`[AutomapView] Failed to synchronously load packed tile bundle from public/act-packs/tiles: ${err instanceof Error ? err.message : String(err)}`,
)
}
}
/** /**
* Online & offline Automap Reveal controller (`off` | `level` | `act`) consuming * Online & offline Automap Reveal controller (`off` | `level` | `act`) consuming
* `LevelView` and `MapService` (`src/client/map/`). * `LevelView` and `MapService` (`src/client/map/`).
@ -217,9 +151,6 @@ export class AutomapView {
this.mapService = options?.mapService ?? null this.mapService = options?.mapService ?? null
this.mode = options?.initialMode ?? 'off' this.mode = options?.initialMode ?? 'off'
this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS
if (!this.mapService) {
this.mapService = tryLoadPackedBundleNodeSync()
}
} }
getMode(): AutomapRevealMode { getMode(): AutomapRevealMode {
@ -257,26 +188,27 @@ export class AutomapView {
if (seedChanged) { if (seedChanged) {
this.levelViews.clear() this.levelViews.clear()
this.serverRevealedRooms.clear() this.serverRevealedRooms.clear()
if (!this.mapService) { if (this.mapService && typeof (this.mapService as Partial<MapService>).loadAct === 'function') {
this.mapService = tryLoadPackedBundleNodeSync() try {
} const loaded = this.mapService.loadAct({
if (this.mapService) { act: this.currentActId - 1,
const loaded = this.mapService.loadAct({ seed: this.mapSeed,
act: this.currentActId - 1, difficulty: this.difficulty,
seed: this.mapSeed, areaId,
difficulty: this.difficulty, revealMode: 'all',
areaId, })
revealMode: 'all', if (loaded) {
}) this.levelViews.set(areaId, loaded)
if (loaded) { }
this.levelViews.set(areaId, loaded) } catch {
// Ignore if mapService fails synchronously
} }
} }
} }
if (existingLevelView) { if (existingLevelView) {
this.levelViews.set(areaId, existingLevelView) this.levelViews.set(areaId, existingLevelView)
} else if (!this.levelViews.has(areaId)) { } else if (this.mapService && !this.levelViews.has(areaId)) {
this.ensureLevelView(areaId) this.ensureLevelView(areaId)
} }
@ -296,7 +228,7 @@ export class AutomapView {
const targetAreaId = this.currentAreaId const targetAreaId = this.currentAreaId
if (existingLevelView && existingLevelView.areaId === targetAreaId) { if (existingLevelView && existingLevelView.areaId === targetAreaId) {
this.levelViews.set(targetAreaId, existingLevelView) this.levelViews.set(targetAreaId, existingLevelView)
} else if (!this.levelViews.has(targetAreaId)) { } else if (this.mapService && !this.levelViews.has(targetAreaId)) {
this.ensureLevelView(targetAreaId) this.ensureLevelView(targetAreaId)
} }
this.applyRevealModeToViews() this.applyRevealModeToViews()
@ -307,7 +239,9 @@ export class AutomapView {
* Record a server `0x07 AddMapRoom` event. * Record a server `0x07 AddMapRoom` event.
*/ */
onServerAddRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void { onServerAddRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void {
this.ensureLevelView(areaId) if (this.mapService) {
this.ensureLevelView(areaId)
}
this.serverRevealedRooms.set(makeRoomKey(areaId, roomX, roomY), { this.serverRevealedRooms.set(makeRoomKey(areaId, roomX, roomY), {
areaId, areaId,
roomX, roomX,
@ -338,40 +272,47 @@ export class AutomapView {
return this.levelViews.get(areaId) return this.levelViews.get(areaId)
} }
private ensureLevelView(areaId: number): LevelView { private ensureLevelView(areaId: number): LevelView | undefined {
let view = this.levelViews.get(areaId) let view = this.levelViews.get(areaId)
if (!view) { if (!view) {
if (!this.mapService) { if (!this.mapService) {
this.mapService = tryLoadPackedBundleNodeSync() return undefined
} }
if (!this.mapService) { try {
throw new Error( if (typeof (this.mapService as Partial<MapService>).loadAct === 'function') {
`[AutomapView] Failed to load LevelView for area ${areaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty}): mapService is not initialized and packed tile bundle is unavailable`, const activeAct =
) typeof (this.mapService as Partial<MapService>).getActiveAct === 'function'
? this.mapService.getActiveAct()
: null
if (activeAct !== getActForArea(areaId) - 1) {
this.mapService.loadAct({
act: getActForArea(areaId) - 1,
seed: this.mapSeed,
difficulty: this.difficulty,
areaId,
revealMode: 'all',
})
}
}
if (typeof (this.mapService as Partial<MapService>).getLevelView === 'function') {
view = this.mapService.getLevelView(areaId, 'all') ?? undefined
}
if (view) {
this.levelViews.set(areaId, view)
}
} catch {
return undefined
} }
if (this.mapService.getActiveAct() !== getActForArea(areaId) - 1) {
this.mapService.loadAct({
act: getActForArea(areaId) - 1,
seed: this.mapSeed,
difficulty: this.difficulty,
areaId,
revealMode: 'all',
})
}
view = this.mapService.getLevelView(areaId, 'all')
if (!view) {
throw new Error(
`[AutomapView] MapService failed to produce LevelView for area ${areaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty})`,
)
}
this.levelViews.set(areaId, view)
} }
return view return view
} }
private applyRevealModeToViews(): void { private applyRevealModeToViews(): void {
if (!this.mapService) {
return
}
this.ensureLevelView(this.currentAreaId) this.ensureLevelView(this.currentAreaId)
if (this.mode === 'act') { if (this.mode === 'act' && this.mapService) {
const actLevels = this.actRevealLevelIds[this.currentActId] ?? [this.currentAreaId] const actLevels = this.actRevealLevelIds[this.currentActId] ?? [this.currentAreaId]
const idsToReveal = actLevels.includes(this.currentAreaId) const idsToReveal = actLevels.includes(this.currentAreaId)
? actLevels ? actLevels
@ -493,7 +434,8 @@ export class AutomapView {
const cid = u.classId ?? 0 const cid = u.classId ?? 0
const isAncientStatue = cid >= 537 && cid <= 539 const isAncientStatue = cid >= 537 && cid <= 539
const mk = typeof u.classId === 'number' ? getMonsterKindByClassId(u.classId) : undefined const mk = typeof u.classId === 'number' ? getMonsterKindByClassId(u.classId) : undefined
const isInteract = mk ? mk.interact : Boolean((u as { isNpc?: boolean }).isNpc) const isTownNpc = Boolean(NPC_HCIDX_TO_DESCRIPTOR_ID.has(cid) || NPC_HCIDX_TO_DESCRIPTOR_ID[cid])
const isInteract = Boolean(mk?.interact || (u as { isNpc?: boolean }).isNpc || isTownNpc)
if (isInteract && !isAncientStatue) { if (isInteract && !isAncientStatue) {
entityDots.push({ entityDots.push({
worldX, worldX,
@ -537,6 +479,11 @@ export class AutomapView {
} }
const snap = this.buildSnapshot([], lang) const snap = this.buildSnapshot([], lang)
const view = this.ensureLevelView(this.currentAreaId) const view = this.ensureLevelView(this.currentAreaId)
if (!view) {
throw new Error(
`[AutomapView] Failed to load LevelView for area ${this.currentAreaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty}): mapService is not initialized and packed tile bundle is unavailable`,
)
}
if ( if (
view.cellsX <= 0 || view.cellsX <= 0 ||

View File

@ -1143,36 +1143,37 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
// 5. Automap / Minimap via WebGL2 SpriteRenderer // 5. Automap / Minimap via WebGL2 SpriteRenderer
if (ctx.minimap && ctx.automapView) { if (ctx.minimap && ctx.automapView) {
const mode = ctx.automapView.getMode() const mode = ctx.automapView.getMode()
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 ?? baseMinimapLevel.originX ?? 0,
originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0,
preRevealTown: isTownLevel(snapshot.levelId),
}
const localSub = snapshot.playerLocalSubTile
const playerCellX = Math.floor(localSub.x / 5)
const playerCellY = Math.floor(localSub.y / 5)
ctx.minimap.reveal(
effectiveMinimapLevel,
playerCellX,
playerCellY,
undefined,
undefined,
true,
)
if (mode === 'off') { if (mode === 'off') {
ctx.minimap.visible = false ctx.minimap.visible = false
} else { } else {
ctx.minimap.visible = true ctx.minimap.visible = true
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 ?? baseMinimapLevel.originX ?? 0,
originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0,
preRevealTown: isTownLevel(snapshot.levelId),
}
const localSub = snapshot.playerLocalSubTile
const playerCellX = Math.floor(localSub.x / 5)
const playerCellY = Math.floor(localSub.y / 5)
ctx.minimap.reveal(
effectiveMinimapLevel,
playerCellX,
playerCellY,
undefined,
undefined,
true,
)
const isEn = getTblLang() === 'ENG' const isEn = getTblLang() === 'ENG'
const automapEntities: AutomapEntityMarker[] = [] const automapEntities: AutomapEntityMarker[] = []
const automapPortals: { const automapPortals: {

View File

@ -92,7 +92,7 @@ export class ControlBarHud {
lang: TblLang = 'CHI' lang: TblLang = 'CHI'
isMultiplayer = true isMultiplayer = true
pressedAction: MiniPanelAction | null = null pressedAction: MiniPanelAction | null = null
hoveredElement: 'exp' | 'stamina' | 'run' | 'menu' | 'statPlus' | 'skillPlus' | MiniPanelAction | null = null hoveredElement: 'exp' | 'stamina' | 'run' | 'menuToggle' | 'statPlus' | 'skillPlus' | MiniPanelAction | null = null
get currentMiniPanelItems(): readonly MiniPanelItemDef[] { get currentMiniPanelItems(): readonly MiniPanelItemDef[] {
return this.isMultiplayer ? MULTIPLAYER_MINIPANEL_ITEMS : SINGLEPLAYER_MINIPANEL_ITEMS return this.isMultiplayer ? MULTIPLAYER_MINIPANEL_ITEMS : SINGLEPLAYER_MINIPANEL_ITEMS
@ -129,7 +129,7 @@ export class ControlBarHud {
} else if (this.inRect(logicalX, logicalY, RUN_BTN_BOUNDS)) { } else if (this.inRect(logicalX, logicalY, RUN_BTN_BOUNDS)) {
this.hoveredElement = 'run' this.hoveredElement = 'run'
} else if (this.inRect(logicalX, logicalY, MENU_TOGGLE_BOUNDS)) { } else if (this.inRect(logicalX, logicalY, MENU_TOGGLE_BOUNDS)) {
this.hoveredElement = 'menu' this.hoveredElement = 'menuToggle'
} else if (this.inRect(logicalX, logicalY, STAT_PLUS_BTN_BOUNDS)) { } else if (this.inRect(logicalX, logicalY, STAT_PLUS_BTN_BOUNDS)) {
this.hoveredElement = 'statPlus' this.hoveredElement = 'statPlus'
} else if (this.inRect(logicalX, logicalY, SKILL_PLUS_BTN_BOUNDS)) { } else if (this.inRect(logicalX, logicalY, SKILL_PLUS_BTN_BOUNDS)) {
@ -352,7 +352,7 @@ export class ControlBarHud {
SKILL_PLUS_BTN_BOUNDS.x + 15, SKILL_PLUS_BTN_BOUNDS.x + 15,
518, 518,
) )
} else if (this.hoveredElement === 'menu') { } else if (this.hoveredElement === 'menuToggle') {
const tip = this.miniPanelOpen const tip = this.miniPanelOpen
? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板')) ? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板'))
: (resolveTblKey('panelmini', this.lang) ?? (isEn ? 'Open Mini Panel' : '開啟迷你面板')) : (resolveTblKey('panelmini', this.lang) ?? (isEn ? 'Open Mini Panel' : '開啟迷你面板'))

View File

@ -2908,6 +2908,8 @@ export class HudManager {
const centerX = Math.round(profile.width / 2) const centerX = Math.round(profile.width / 2)
const centerY = Math.round(profile.height / 2) const centerY = Math.round(profile.height / 2)
ctx.save() ctx.save()
ctx.fillStyle = 'rgba(80, 0, 0, 0.45)'
ctx.fillRect(0, 0, profile.width, profile.height)
const hardcoreImg = this.images.get('deathHardcore') const hardcoreImg = this.images.get('deathHardcore')
const instImg = this.images.get('deathInst') const instImg = this.images.get('deathInst')
const y1 = centerY - 94 const y1 = centerY - 94

View File

@ -44,6 +44,8 @@ import {
computeAutomapTilePixelCoords, computeAutomapTilePixelCoords,
getCrosshairSegments, getCrosshairSegments,
} from '../../common/formats/automap-table.ts' } from '../../common/formats/automap-table.ts'
export type { AutomapLayerData }
import { lookupTbl } from '../i18n/lang.ts' import { lookupTbl } from '../i18n/lang.ts'
import { reportMissingArt } from '../scene/world-renderer.ts' import { reportMissingArt } from '../scene/world-renderer.ts'
import type { D2ColorCode, D2FontRenderer } from './font.ts' import type { D2ColorCode, D2FontRenderer } from './font.ts'

View File

@ -0,0 +1,674 @@
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 {
AUTOMAP_CROSSHAIR_COLORS,
AUTOMAP_FADE_ALPHAS,
type AutomapEntityMarker,
type AutomapLayerData,
computeAutomapViewport,
Minimap,
type MinimapLevel,
} from '../src/client/ui/minimap.ts'
import {
AutomapView,
type MinimapEntityDot,
} 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 { getMonsterKindByClassId } from '../src/common/units/monsters-data.ts'
import { NPC_HCIDX_TO_DESCRIPTOR_ID } from '../src/common/units/npc-table.ts'
import type { AtlasFrame } from '../src/client/render/atlas.ts'
import type { AtlasHandle, SpriteRenderer } from '../src/client/render/renderer.ts'
import type { RenderableUnitInput } from '../src/client/view/unit-drawables.ts'
interface RecordedSpriteDraw {
readonly kind: 'sprite'
readonly frame: AtlasFrame
readonly x: number
readonly y: number
readonly atlas?: AtlasHandle | undefined
readonly width?: number | undefined
readonly height?: number | undefined
readonly tint?: readonly [number, number, number, number] | undefined
}
interface RecordedSolidDraw {
readonly kind: 'solid'
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly color: readonly [number, number, number, number]
}
interface RecordedQuadDraw {
readonly kind: 'quad'
readonly x0: number
readonly y0: number
readonly x1: number
readonly y1: number
readonly x2: number
readonly y2: number
readonly x3: number
readonly y3: number
readonly color: readonly [number, number, number, number]
}
type RecordedDraw = RecordedSpriteDraw | RecordedSolidDraw | RecordedQuadDraw
function createMockRenderer(): {
renderer: SpriteRenderer
draws: RecordedDraw[]
scissors: ({ x: number; y: number; width: number; height: number } | null)[]
} {
const draws: RecordedDraw[] = []
const scissors: ({ x: number; y: number; width: number; height: number } | null)[] = []
let activeScissor: { x: number; y: number; width: number; height: number } | null = null
const mockGl = {
SCISSOR_TEST: 0x0c11,
enable(cap: number) {
if (cap === 0x0c11) {
activeScissor = { x: 0, y: 0, width: 0, height: 0 }
}
},
scissor(x: number, y: number, width: number, height: number) {
activeScissor = { x, y, width, height }
scissors.push(activeScissor)
},
disable(cap: number) {
if (cap === 0x0c11) {
activeScissor = null
scissors.push(null)
}
},
}
const renderer = {
gl: mockGl,
flush() {},
draw(
frame: AtlasFrame,
x: number,
y: number,
options?: {
atlas?: AtlasHandle
width?: number
height?: number
tint?: readonly [number, number, number, number]
},
) {
draws.push({
kind: 'sprite',
frame,
x,
y,
atlas: options?.atlas,
width: options?.width,
height: options?.height,
tint: options?.tint,
})
},
drawSolid(
x: number,
y: number,
width: number,
height: number,
color: readonly [number, number, number, number],
) {
draws.push({ kind: 'solid', x, y, width, height, color })
},
drawQuad(
x0: number,
y0: number,
x1: number,
y1: number,
x2: number,
y2: number,
x3: number,
y3: number,
color: readonly [number, number, number, number],
) {
draws.push({ kind: 'quad', x0, y0, x1, y1, x2, y2, x3, y3, color })
},
} as unknown as SpriteRenderer
return { renderer, draws, scissors }
}
function registerSyntheticAtlases(minimap: Minimap): void {
const keys = [
'MaxiMap',
'MaxiMapS',
'Act2Map',
'Act2MapS',
'Act4Map',
'Act4MapS',
'ExTnMap',
'ExTnMapS',
] as const
for (const key of keys) {
const meta = minimap.getSheetMeta(key)
minimap.setAtlasHandle(key, {
texture: key as unknown as WebGLTexture,
width: meta.atlasWidth,
height: meta.atlasHeight,
})
}
}
function makeSyntheticLevel(
key: string,
cellsX: number,
cellsY: number,
overrides?: Partial<MinimapLevel>,
): MinimapLevel {
const gridWidth = cellsX * 5
const blocked = new Uint8Array(gridWidth * cellsY * 5)
const floors: AutomapLayerData['floors'][number][] = []
for (let cy = 0; cy < cellsY; cy += 4) {
for (let cx = 0; cx < cellsX; cx += 4) {
floors.push({
cellNo: 1,
xPixel: (cx - cy) * 16,
yPixel: (cx + cy) * 8,
cellX: cx,
cellY: cy,
fSaved: 0,
weight: 0,
})
}
}
const automap: AutomapLayerData = {
layerId: 1,
levelTypeId: 1,
floors,
walls: [],
objects: [],
extras: [],
}
return {
key,
cellsX,
cellsY,
blocked,
gridWidth,
originX: 0,
originY: 0,
automap,
levelId: 1,
levelName: 'Test Area',
difficulty: 'Normal',
markers: [],
...overrides,
}
}
let sharedMapService: MapService
beforeAll(async () => {
sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles')
})
describe('Challenger 1 Adversarial Stress Suite: Batch 2 Parity Gate', () => {
describe('Dimension 1: Fog of War, Monotonicity & Exploration State', () => {
it('reveals cells strictly monotonically across 500 random walk room transitions', () => {
const minimap = new Minimap()
const level = makeSyntheticLevel('test-wilderness', 100, 100)
let prevRevealedRooms = 0
let prevExploredRatio = 0
let playerCellX = 10
let playerCellY = 10
let seed = 1337
const nextRandom = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let step = 0; step < 500; step++) {
const dx = Math.floor(nextRandom() * 5) - 2
const dy = Math.floor(nextRandom() * 5) - 2
playerCellX = Math.max(2, Math.min(level.cellsX - 3, playerCellX + dx))
playerCellY = Math.max(2, Math.min(level.cellsY - 3, playerCellY + dy))
minimap.reveal(level, playerCellX, playerCellY)
const currentRevealedRooms = minimap.revealedRoomCount(level.key)
const currentExploredRatio = minimap.exploredFraction(level)
expect(currentRevealedRooms).toBeGreaterThanOrEqual(prevRevealedRooms)
expect(currentExploredRatio).toBeGreaterThanOrEqual(prevExploredRatio)
prevRevealedRooms = currentRevealedRooms
prevExploredRatio = currentExploredRatio
}
expect(prevRevealedRooms).toBeGreaterThan(10)
expect(prevExploredRatio).toBeGreaterThan(0.05)
})
it('shares and preserves exploration grid identically between Corner Minimap and Full Automap modes', () => {
const minimap = new Minimap()
const level = makeSyntheticLevel('test-shared', 40, 40)
minimap.mode = 'minimap'
minimap.reveal(level, 8, 8)
const countMinimap = minimap.revealedRoomCount(level.key)
const ratioMinimap = minimap.exploredFraction(level)
minimap.mode = 'full'
const countFull = minimap.revealedRoomCount(level.key)
const ratioFull = minimap.exploredFraction(level)
expect(countFull).toBe(countMinimap)
expect(ratioFull).toBe(ratioMinimap)
minimap.reveal(level, 24, 24)
const countUpdatedFull = minimap.revealedRoomCount(level.key)
expect(countUpdatedFull).toBeGreaterThan(countFull)
minimap.mode = 'minimap'
const countRestoredMinimap = minimap.revealedRoomCount(level.key)
expect(countRestoredMinimap).toBe(countUpdatedFull)
})
it('isolates revealed rooms strictly by level key across multi-level area transitions', () => {
const minimap = new Minimap()
const townLevel = makeSyntheticLevel('town-key', 30, 30)
const wildernessLevel = makeSyntheticLevel('wilderness-key', 50, 50)
minimap.reveal(townLevel, 5, 5)
expect(minimap.revealedRoomCount('town-key')).toBeGreaterThan(0)
expect(minimap.revealedRoomCount('wilderness-key')).toBe(0)
minimap.reveal(wildernessLevel, 20, 20)
expect(minimap.revealedRoomCount('town-key')).toBeGreaterThan(0)
expect(minimap.revealedRoomCount('wilderness-key')).toBeGreaterThan(0)
const townCount = minimap.revealedRoomCount('town-key')
const wildCount = minimap.revealedRoomCount('wilderness-key')
expect(townCount).not.toBe(wildCount)
})
it('world-renderer reveals fog-of-war even when automap mode is off', () => {
const text = readFileSync(resolve('tests/fixtures/netproto/04-d2gs-idle-60s.d2cap'), 'utf8')
const settingsStore = createSettingsStore()
const session = new OnlineSession({ settingsStore, mapService: sharedMapService })
session.loadD2CapReplay(text)
session.replayAllD2Cap()
const minimap = new Minimap()
session.automapView.setMode('off')
renderSceneSourceFrame({
source: session.sceneSource,
lightingPreset: 'fullbright',
renderer: {
begin() {},
draw() {},
drawSolidQuad() {},
setLighting() {},
setPalette() {},
flush() {},
getGl() {
return null
},
} as never,
tileGpuCache: {
loadedPageCount: 1,
ensureActPalette() {},
getPageHandle() {
return { index: 1, width: 2048, height: 1932 }
},
} as never,
minimap,
automapView: session.automapView,
})
expect(minimap.revealedRoomsCount).toBeGreaterThan(0)
session.automapView.setMode('level')
renderSceneSourceFrame({
source: session.sceneSource,
lightingPreset: 'fullbright',
renderer: {
begin() {},
draw() {},
drawSolidQuad() {},
setLighting() {},
setPalette() {},
flush() {},
getGl() {
return null
},
} as never,
tileGpuCache: {
loadedPageCount: 1,
ensureActPalette() {},
getPageHandle() {
return { index: 1, width: 2048, height: 1932 }
},
} as never,
minimap,
automapView: session.automapView,
})
expect(minimap.revealedRoomsCount).toBeGreaterThan(0)
})
})
describe('Dimension 2: Viewport Geometry, Dynamic Resizing & Scissor Bounds', () => {
it('maintains valid viewport bounds and scissor clipping across 500 random resolution combinations', () => {
let seed = 4242
const nextRandom = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let i = 0; i < 500; i++) {
const screenW = Math.max(1, Math.floor(nextRandom() * 7680) + 1)
const screenH = Math.max(1, Math.floor(nextRandom() * 4320) + 1)
const leftPanel = nextRandom() > 0.5
const rightPanel = nextRandom() > 0.5
const panX = (nextRandom() - 0.5) * 2000
const panY = (nextRandom() - 0.5) * 2000
const vpMinimap = computeAutomapViewport(
screenW,
screenH,
true,
leftPanel,
rightPanel,
panX,
panY,
)
expect(vpMinimap.bMinimap).toBe(true)
expect(vpMinimap.divisor).toBe(20)
expect(vpMinimap.width).toBeGreaterThan(0)
expect(vpMinimap.height).toBeGreaterThan(0)
expect(vpMinimap.x).toBeGreaterThanOrEqual(0)
expect(vpMinimap.y).toBeGreaterThanOrEqual(0)
if (screenW >= 280) {
expect(vpMinimap.width).toBeLessThanOrEqual(screenW)
}
const vpFull = computeAutomapViewport(
screenW,
screenH,
false,
leftPanel,
rightPanel,
panX,
panY,
)
expect(vpFull.bMinimap).toBe(false)
expect(vpFull.divisor).toBe(10)
expect(vpFull.x).toBe(0)
expect(vpFull.y).toBe(0)
expect(vpFull.width).toBe(screenW)
expect(vpFull.height).toBe(screenH)
const neutralCenterX = Math.trunc(screenW / 2)
const shiftAmount = screenW >> 2
if (leftPanel && !rightPanel) {
expect(vpFull.centerX).toBe(neutralCenterX + shiftAmount)
} else if (rightPanel && !leftPanel) {
expect(vpFull.centerX).toBe(neutralCenterX - shiftAmount)
} else {
expect(vpFull.centerX).toBe(neutralCenterX)
}
}
})
it('enables and disables hardware scissor test around automap sprite rendering', () => {
const minimap = new Minimap()
registerSyntheticAtlases(minimap)
minimap.mode = 'full'
const level = makeSyntheticLevel('test-scissor', 20, 20)
minimap.reveal(level, 4, 4)
const { renderer, scissors } = createMockRenderer()
minimap.draw(
renderer,
level,
4,
4,
{ x: 0, y: 0, zoom: 1 },
{ width: 1920, height: 1080 },
{ playerWorldX: 0, playerWorldY: 0, levelId: 1 },
)
expect(scissors.length).toBe(2)
expect(scissors[0]).toEqual({ x: 0, y: 0, width: 1920, height: 1080 })
expect(scissors[1]).toBeNull()
})
})
describe('Dimension 3: Fade State Cycling & Distance Opacity Mapping', () => {
it('cycles through all 4 discrete fade states and updates alpha values correctly', () => {
const minimap = new Minimap()
expect(minimap.fadeState).toBe(0)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0])
minimap.cycleFade()
expect(minimap.fadeState).toBe(1)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[1])
minimap.cycleFade()
expect(minimap.fadeState).toBe(2)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2])
minimap.cycleFade()
expect(minimap.fadeState).toBe(3)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[3])
minimap.cycleFade()
expect(minimap.fadeState).toBe(0)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0])
})
it('applies distance-based alpha factor in Full Automap mode according to radius bands', () => {
const minimap = new Minimap()
registerSyntheticAtlases(minimap)
minimap.mode = 'full'
const level = makeSyntheticLevel('test-fade-dist', 50, 50)
minimap.reveal(level, 0, 0)
const { renderer, draws } = createMockRenderer()
minimap.draw(
renderer,
level,
0,
0,
{ x: 0, y: 0, zoom: 1 },
{ width: 800, height: 600 },
{ playerWorldX: 0, playerWorldY: 0, levelId: 1 },
)
const spriteDraws = draws.filter((d): d is RecordedSpriteDraw => d.kind === 'sprite')
expect(spriteDraws.length).toBeGreaterThan(0)
for (const draw of spriteDraws) {
expect(draw.tint).toBeDefined()
const alpha = draw.tint![3]
expect(alpha).toBeGreaterThan(0)
expect(alpha).toBeLessThanOrEqual(1.0)
}
})
})
describe('Dimension 4: Dynamic Unit Marker Fuzzing & Palette Verification', () => {
it('strictly excludes hostile monsters, bosses, ancient statues, and dying monsters from the automap', () => {
const minimap = new Minimap()
registerSyntheticAtlases(minimap)
minimap.mode = 'full'
const level = makeSyntheticLevel('test-entities', 50, 50)
minimap.reveal(level, 25, 25)
const hostileEntities: AutomapEntityMarker[] = [
{ x: 100, y: 100, kind: 'boss', alive: true, classId: 1 },
{ x: 110, y: 110, kind: 'boss', alive: true, classId: 156 },
{ x: 120, y: 120, kind: 'boss', alive: true, classId: 211 },
{ x: 130, y: 130, kind: 'boss', alive: true, classId: 242 },
{ x: 140, y: 140, kind: 'boss', alive: true, classId: 243 },
{ x: 150, y: 150, kind: 'boss', alive: true, classId: 544 },
{ x: 160, y: 160, kind: 'npc', alive: true, classId: 537 },
{ x: 170, y: 170, kind: 'npc', alive: true, classId: 538 },
{ x: 180, y: 180, kind: 'npc', alive: true, classId: 539 },
{ x: 190, y: 190, kind: 'boss', alive: false, classId: 1 },
]
const { renderer } = createMockRenderer()
minimap.draw(
renderer,
level,
25,
25,
{ x: 0, y: 0, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 0,
levelId: 1,
entities: hostileEntities,
},
)
expect(minimap.lastRenderStats.crosshairsDrawn.npcs).toBe(0)
})
it('fuzzes 1000 unit entities and asserts friendly palette conformity and corpse rendering', () => {
const minimap = new Minimap()
registerSyntheticAtlases(minimap)
minimap.mode = 'full'
const level = makeSyntheticLevel('test-fuzz-1000', 100, 100)
minimap.reveal(level, 50, 50)
const entities: AutomapEntityMarker[] = []
let seed = 9999
const nextRandom = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let i = 0; i < 1000; i++) {
const r = nextRandom()
const x = Math.floor((nextRandom() - 0.5) * 800)
const y = Math.floor((nextRandom() - 0.5) * 600)
if (r < 0.2) {
entities.push({ x, y, kind: 'mercenary', alive: true, name: `Merc ${i}` })
} else if (r < 0.4) {
entities.push({ x, y, kind: 'corpse', alive: false, name: `Corpse ${i}` })
} else if (r < 0.6) {
entities.push({ x, y, kind: 'party', alive: true, name: `Ally ${i}` })
} else if (r < 0.8) {
entities.push({ x, y, kind: 'summon', alive: true, classId: 357 })
} else {
entities.push({ x, y, kind: 'summon', alive: true, classId: 291 })
}
}
const { renderer, draws } = createMockRenderer()
minimap.draw(
renderer,
level,
50,
50,
{ x: 0, y: 0, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 0,
levelId: 1,
entities,
},
)
expect(minimap.lastRenderStats.crosshairsDrawn.total).toBeGreaterThan(100)
expect(minimap.lastRenderStats.crosshairsDrawn.mercenaries).toBeGreaterThan(0)
const quadDraws = draws.filter((d): d is RecordedQuadDraw => d.kind === 'quad')
for (const quad of quadDraws) {
for (const channel of quad.color) {
expect(channel).toBeGreaterThanOrEqual(0)
expect(channel).toBeLessThanOrEqual(1.0001)
}
}
})
it('renders town NPCs as npc entityDots in AutomapView', () => {
sharedMapService.loadAct({ act: 0, seed: 0x12345, areaId: 1 })
const automapView = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
const townNpcs: (RenderableUnitInput & { isNpc?: boolean })[] = [
{ kind: 'monster', id: 101, classId: 146, x: 10, y: 10, isNpc: true, name: 'Deckard Cain' },
{ kind: 'monster', id: 102, classId: 147, x: 12, y: 12, isNpc: true, name: 'Gheed' },
{ kind: 'monster', id: 103, classId: 148, x: 14, y: 14, isNpc: true, name: 'Akara' },
{ kind: 'monster', id: 104, classId: 150, x: 16, y: 16, isNpc: true, name: 'Kashya' },
{ kind: 'monster', id: 105, classId: 154, x: 18, y: 18, isNpc: true, name: 'Charsi' },
{ kind: 'monster', id: 106, classId: 155, x: 20, y: 20, isNpc: true, name: 'Warriv' },
]
for (const npc of townNpcs) {
const mk = getMonsterKindByClassId(npc.classId)
expect(mk).toBeDefined()
expect(mk!.interact).toBe(false)
expect(NPC_HCIDX_TO_DESCRIPTOR_ID.has(npc.classId) || NPC_HCIDX_TO_DESCRIPTOR_ID[npc.classId]).toBeTruthy()
}
const snapshot = automapView.buildSnapshot(townNpcs)
const npcsInSnapshot = snapshot.entityDots.filter((d: MinimapEntityDot) => d.kind === 'npc')
expect(npcsInSnapshot.length).toBe(townNpcs.length)
})
})
describe('Dimension 5: Grounded Spawn Verification & Topological Robustness', () => {
it('verifies grounded spawn across diverse game areas avoids naive geometric centroid', () => {
const testAreaIds = [1, 2, 3, 4, 8]
for (const areaId of testAreaIds) {
const levelView = sharedMapService.loadAct({ act: 0, seed: 0x12345, areaId })!
expect(levelView.spawnX).toBeGreaterThanOrEqual(0)
expect(levelView.spawnX).toBeLessThan(levelView.cellsX)
expect(levelView.spawnY).toBeGreaterThanOrEqual(0)
expect(levelView.spawnY).toBeLessThan(levelView.cellsY)
const geometricCenterX = Math.floor(levelView.cellsX / 2)
const geometricCenterY = Math.floor(levelView.cellsY / 2)
const isGeometricCentroid =
levelView.spawnX === geometricCenterX && levelView.spawnY === geometricCenterY
expect(isGeometricCentroid).toBe(false)
let walkableSubCount = 0
for (let dy = 0; dy < 5; dy++) {
for (let dx = 0; dx < 5; dx++) {
const idx =
(levelView.spawnY * 5 + dy) * levelView.gridWidth +
(levelView.spawnX * 5 + dx)
if (levelView.walkable[idx] === 1) {
walkableSubCount++
}
}
}
expect(walkableSubCount).toBeGreaterThan(0)
}
})
})
})

View File

@ -0,0 +1,682 @@
/**
* Adversarial Stress Test Suite for Batch 2 Gate (Challenger 2).
*
* Rigorously stress-tests:
* 1. Belt Dynamic Resizing & Potion Slotting:
* - Rapid equip/unequip cycles across belt capacities (1 to 4 rows).
* - Potion overflow retention, gravity shifting, and non-corruption.
* - Slot indexing bounds fuzzing and left-click pick-to-cursor vs right-click auto-drink separation.
* - Scroll (tsc, isc) manual placement, two-pass auto-slotting, and non-beltable rejection.
* 2. Experience Table Parity:
* - All 7 classes (Ama, Sor, Nec, Pal, Bar, Dru, Ass) across levels 1..99 against independent raw oracle.
* - Exact boundary and level cap invariants (lvl 1, 98->99, lvl 99 cap, invalid levels).
* 3. Mini-Panel & Death Screen:
* - 8-button (MP) vs 7-button (SP) mode toggling, boundaries, and button hit dispatching.
* - Death screen DC6 sprite centering across diverse aspect ratios and resolutions.
*/
import { describe, expect, it, vi } from 'vitest'
import {
BeltModel,
BELT_ROWS,
BELT_COLS,
BELT_BASE_X,
BELT_BASE_Y,
BELT_PITCH_X,
BELT_PITCH_Y,
BELT_CELL_SIZE,
BELT_POTION_CATALOG,
getBeltRowsForCode,
getBeltSlotBox,
itemToBeltPotion,
areBeltPotionsCompatible,
type BeltPotion,
} from '../../src/common/items/belt-model.ts'
import {
ControlBarHud,
MULTIPLAYER_MINIPANEL_ITEMS,
SINGLEPLAYER_MINIPANEL_ITEMS,
MINIPANEL_BOUNDS,
MINIPANEL_SINGLE_BOUNDS,
getMiniPanelTooltip,
} from '../../src/client/ui/control-bar.ts'
import {
D2_EXPERIENCE_TABLE_99,
getExperienceTable,
getExperienceForLevel,
getNextLevelExperience,
EXPERIENCE_TXT_RAW,
EXPERIENCE_TABLE_BY_CLASS,
} from '../../src/common/skills/formulas.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { getViewportProfile, type ViewportResolution } from '../../src/client/view/viewport-profile.ts'
import type { ClientCommand, ItemData } from '../../src/netproto/index.ts'
// --- Helpers for Test Harness ---
function setupHudEnvironment() {
const world = new ClientWorld()
const hud = new HudManager()
const commands: ClientCommand[] = []
const model = new HudModel({ world, hudManager: hud, onCommand: (c) => commands.push(c) })
return { world, hud, model, commands }
}
function makeTestPotion(code: string, id: string | number): BeltPotion {
const pot = itemToBeltPotion({ code, id: String(id) })
if (!pot) throw new Error(`Failed to create test potion for code ${code}`)
return pot
}
describe('Challenger 2 Empirical Stress Test Harness — HUD, Belts & Experience', () => {
// =========================================================================
// SUITE 1: Belt Dynamic Resizing & Potion Slotting Stress Harness
// =========================================================================
describe('Suite 1: Belt Dynamic Resizing & Potion Slotting', () => {
it('1.1 Rapid equip/unequip fuzzing across 1,000 cycles preserves state without memory corruption', () => {
const belt = new BeltModel()
const beltCodes = [
null, // 1 row (default unequipped)
'lbl', // 2 rows (Sash)
'vbl', // 2 rows (Light Belt)
'mbl', // 3 rows (Belt)
'tbl', // 3 rows (Heavy Belt)
'hbl', // 4 rows (Plated Belt)
'zlb', // 4 rows (Demonhide Sash)
'zvb', // 4 rows (Sharkskin Belt)
'zmb', // 4 rows (Mesh Belt)
'ztb', // 4 rows (Battle Belt)
'zhb', // 4 rows (War Belt)
'ulc', // 4 rows (Spiderweb Sash)
'umc', // 4 rows (Mithril Coil)
'uhc', // 4 rows (Colossus Girdle)
]
// Populate initial 4x4 belt
belt.setEquippedBelt('hbl')
expect(belt.maxRows).toBe(4)
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
belt.setSlot(c, r, makeTestPotion('hp1', `pot-init-${r}-${c}`))
}
}
expect(belt.countTotalPotions()).toBe(16)
// Stress loop: 1,000 rapid switches
for (let cycle = 0; cycle < 1000; cycle++) {
const code = beltCodes[cycle % beltCodes.length]
belt.setEquippedBelt(code)
const expectedRows = getBeltRowsForCode(code)
expect(belt.maxRows).toBe(expectedRows)
expect(belt.grid.length).toBe(4)
for (let r = 0; r < 4; r++) {
expect(belt.grid[r]!.length).toBe(4)
}
// Active rows must follow expansion state
belt.lockedOpen = false
belt.hovered = false
expect(belt.activeRows).toBe(1)
expect(belt.isExpanded).toBe(false)
belt.lockedOpen = true
if (expectedRows > 1) {
expect(belt.isExpanded).toBe(true)
expect(belt.activeRows).toBe(expectedRows)
} else {
expect(belt.isExpanded).toBe(false)
expect(belt.activeRows).toBe(1)
}
}
// After 1,000 cycles, re-equipping a 4-row belt must reveal all 16 original potions intact
belt.setEquippedBelt('hbl')
expect(belt.maxRows).toBe(4)
expect(belt.countTotalPotions()).toBe(16)
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
const pot = belt.getSlot(c, r)
expect(pot).not.toBeNull()
expect(pot?.id).toBe(`pot-init-${r}-${c}`)
}
}
})
it('1.2 Potion overflow retention and gravity drain when unequipping from 4-row to 1-row', () => {
const belt = new BeltModel()
belt.setEquippedBelt('hbl') // 4 rows
// Put distinct potion codes in column 0: row 0 = hp1, row 1 = hp2, row 2 = hp3, row 3 = hp4
belt.setSlot(0, 0, makeTestPotion('hp1', 'pot-0'))
belt.setSlot(0, 1, makeTestPotion('hp2', 'pot-1'))
belt.setSlot(0, 2, makeTestPotion('hp3', 'pot-2'))
belt.setSlot(0, 3, makeTestPotion('hp4', 'pot-3'))
// Unequip belt -> capacity drops to 1 row
belt.setEquippedBelt(null)
expect(belt.maxRows).toBe(1)
// Only row 0 can be directly consumed via useSlot(0, 0)
// useSlot(0, 1) must be rejected because row 1 >= maxRows
expect(belt.useSlot(0, 1)).toBeNull()
expect(belt.useSlot(0, 2)).toBeNull()
expect(belt.useSlot(0, 3)).toBeNull()
// Consume row 0: consumes 'hp1'
const consumed1 = belt.useSlot(0, 0)
expect(consumed1?.code).toBe('hp1')
// Gravity shift: row 1 ('hp2') has shifted down into row 0!
expect(belt.getSlot(0, 0)?.code).toBe('hp2')
// Consume row 0 again: consumes 'hp2'
const consumed2 = belt.useSlot(0, 0)
expect(consumed2?.code).toBe('hp2')
// Gravity shift: row 2 ('hp3') has shifted down into row 0!
expect(belt.getSlot(0, 0)?.code).toBe('hp3')
// Consume row 0 again: consumes 'hp3'
const consumed3 = belt.useSlot(0, 0)
expect(consumed3?.code).toBe('hp3')
// Gravity shift: row 3 ('hp4') has shifted down into row 0!
expect(belt.getSlot(0, 0)?.code).toBe('hp4')
// Consume row 0 again: consumes 'hp4'
const consumed4 = belt.useSlot(0, 0)
expect(consumed4?.code).toBe('hp4')
// Now column 0 is completely empty
expect(belt.getSlot(0, 0)).toBeNull()
expect(belt.useSlot(0, 0)).toBeNull()
})
it('1.3 Fuzzes slot indexing across 5,000 bounds and boundary inputs with zero unhandled exceptions', () => {
const belt = new BeltModel()
belt.setEquippedBelt('hbl')
const potion = makeTestPotion('hp1', 'fuzz-pot')
// Explicit boundary coordinates
const testCoordinates: [number, number][] = [
[-1000, 0],
[0, -1000],
[-1, 0],
[0, -1],
[-1, -1],
[4, 0],
[0, 4],
[4, 4],
[15, 0],
[0, 15],
[16, 16],
[1000, 1000],
[0, 0],
[3, 0],
[0, 3],
[3, 3],
]
for (const [col, row] of testCoordinates) {
const isColValid = col >= 0 && col < BELT_COLS
const isRowValid = row >= 0 && row < BELT_ROWS
if (isColValid && isRowValid) {
expect(belt.setSlot(col, row, potion)).toBe(true)
expect(belt.getSlot(col, row)).toStrictEqual(potion)
expect(belt.setSlot(col, row, null)).toBe(true)
expect(belt.getSlot(col, row)).toBeNull()
} else {
expect(belt.setSlot(col, row, potion)).toBe(false)
expect(belt.getSlot(col, row)).toBeNull()
expect(belt.useSlot(col, row)).toBeNull()
expect(belt.placePotion(potion, col, row)).toBe(potion)
}
}
// Fuzz 5,000 randomized integers
for (let i = 0; i < 5000; i++) {
const randCol = Math.floor(Math.random() * 200) - 100
const randRow = Math.floor(Math.random() * 200) - 100
const isColValid = randCol >= 0 && randCol < BELT_COLS
const isRowValid = randRow >= 0 && randRow < belt.maxRows
if (isColValid && isRowValid) {
expect(belt.setSlot(randCol, randRow, potion)).toBe(true)
expect(belt.getSlot(randCol, randRow)?.code).toBe(potion.code)
belt.setSlot(randCol, randRow, null)
} else {
expect(belt.setSlot(randCol, randRow, potion)).toBe(false)
expect(belt.placePotion(potion, randCol, randRow)).toBe(potion)
}
}
})
it('1.4 Left-click interaction picks up potion to cursor without auto-drinking, right-click drinks', () => {
const { world, hud, model, commands } = setupHudEnvironment()
hud.commandSink = model
// Put an online item into belt slot 0
const onlinePotion = {
id: 777,
itemId: 777,
opcode: 0x9c,
code: 'hp1',
rawCode: 'hp1 ',
action: 4,
category: 1,
location: 2, // belt
container: 'belt',
gridX: 0,
gridY: 0,
bodyLoc: 0,
equippedSlot: 0,
storePage: 1,
storagePage: 1,
worldX: 0,
worldY: 0,
} as unknown as ItemData
world.apply({ type: 'ItemEvent', action: 4, item: onlinePotion, container: 'belt' })
model.syncFromWorld()
// Verify the belt slot is populated in HUD
const beltPotion = hud.belt.getSlot(0, 0)
expect(beltPotion).not.toBeNull()
expect(beltPotion?.code).toBe('hp1')
// Get slot 0 bounding box coordinates
const box = getBeltSlotBox(0, 0)
const clickX = box.x + 10
const clickY = box.y + 10
// Spy on drinkBeltSlot to guarantee left-click NEVER drinks
const drinkSpy = vi.spyOn(hud, 'drinkBeltSlot')
// --- Interaction 1: Left-click on occupied slot with empty cursor ---
hud.inventory.cursorItem = null
hud.handleHudMouseDown({
button: 0, // Left click
shiftKey: false,
clientX: clickX,
clientY: clickY,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent)
// Must NOT drink
expect(drinkSpy).not.toHaveBeenCalled()
// Must emit BeltRemove (0x24) to pick up the potion to cursor
expect(commands).toHaveLength(1)
expect(commands[0]).toEqual({ type: 'BeltRemove', itemId: 777 })
commands.length = 0
drinkSpy.mockClear()
// --- Interaction 2: Left-click with cursor item onto belt slot ---
const cursorItem = { id: 888, code: 'mp1' } as any
hud.inventory.cursorItem = cursorItem
hud.handleHudMouseDown({
button: 0, // Left click
shiftKey: false,
clientX: clickX,
clientY: clickY,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent)
expect(drinkSpy).not.toHaveBeenCalled()
// Occupied slot + cursor -> BeltSwap (0x25)
expect(commands).toHaveLength(1)
expect(commands[0]).toEqual({ type: 'BeltSwap', cursorItemId: 888, beltItemId: 777 })
commands.length = 0
drinkSpy.mockClear()
// --- Interaction 3: Right-click on belt slot -> must DRINK potion ---
hud.handleHudMouseDown({
button: 2, // Right click
shiftKey: false,
clientX: clickX,
clientY: clickY,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent)
expect(drinkSpy).toHaveBeenCalledWith(0, false)
expect(commands).toHaveLength(1)
expect(commands[0]).toEqual({ type: 'BeltUse', itemId: 777, shift: false })
commands.length = 0
drinkSpy.mockClear()
// --- Interaction 4: Press key '1' -> must DRINK slot 0 ---
hud.handleKeyDown({
key: '1',
shiftKey: false,
preventDefault: () => {},
} as unknown as KeyboardEvent)
expect(drinkSpy).toHaveBeenCalledWith(0, false)
expect(commands).toHaveLength(1)
expect(commands[0]).toEqual({ type: 'BeltUse', itemId: 777, shift: false })
})
it('1.5 Scrolls (tsc, isc) auto-slotting, column segregation and non-beltable rejection', () => {
const belt = new BeltModel()
belt.setEquippedBelt('hbl') // 4 rows
const tsc1 = itemToBeltPotion({ code: 'tsc', id: 'tp-1' })!
const tsc2 = itemToBeltPotion({ code: 'tsc', id: 'tp-2' })!
const isc1 = itemToBeltPotion({ code: 'isc', id: 'id-1' })!
const isc2 = itemToBeltPotion({ code: 'isc', id: 'id-2' })!
const hp1 = itemToBeltPotion({ code: 'hp1', id: 'hp-1' })!
expect(tsc1.kind).toBe('scroll')
expect(tsc1.invFile).toBe('invbsc')
expect(isc1.kind).toBe('scroll')
expect(isc1.invFile).toBe('invrsc')
// Step A: Auto-slot first tsc -> goes to col 0, row 0
const res1 = belt.autoPlacePotionSlot(tsc1)
expect(res1).toEqual({ col: 0, row: 0, potion: tsc1 })
// Step B: Auto-slot second tsc -> matches col 0 family, goes to col 0, row 1
const res2 = belt.autoPlacePotionSlot(tsc2)
expect(res2).toEqual({ col: 0, row: 1, potion: tsc2 })
// Step C: Auto-slot isc -> does NOT match col 0 (tsc != isc), goes to col 1, row 0
const res3 = belt.autoPlacePotionSlot(isc1)
expect(res3).toEqual({ col: 1, row: 0, potion: isc1 })
// Step D: Auto-slot second isc -> matches col 1 family, goes to col 1, row 1
const res4 = belt.autoPlacePotionSlot(isc2)
expect(res4).toEqual({ col: 1, row: 1, potion: isc2 })
// Step E: Auto-slot healing potion -> goes to col 2, row 0
const res5 = belt.autoPlacePotionSlot(hp1)
expect(res5).toEqual({ col: 2, row: 0, potion: hp1 })
// Verify slot placements
expect(belt.getSlot(0, 0)?.code).toBe('tsc')
expect(belt.getSlot(0, 1)?.code).toBe('tsc')
expect(belt.getSlot(1, 0)?.code).toBe('isc')
expect(belt.getSlot(1, 1)?.code).toBe('isc')
expect(belt.getSlot(2, 0)?.code).toBe('hp1')
// Step F: Verify strict rejection of non-beltable items
const invalidItems = [
{ code: 'gpm' }, // Strangling Gas Potion (throwing weapon)
{ code: 'opm' }, // Fulminating Potion (throwing weapon)
{ code: 'gpl' }, // Choking Gas Potion
{ code: 'opl' }, // Exploding Potion
{ code: 'gps' }, // Rancid Gas Potion
{ code: 'ops' }, // Oil Potion
{ code: 'axe', kind: 'weapon' },
{ code: 'swor', kind: 'weapon' },
{ code: 'shld', kind: 'armor' },
{ code: 'key' },
{ code: 'r01' }, // El Rune
{ code: 'gcv' }, // Chipped Amethyst
]
for (const item of invalidItems) {
expect(itemToBeltPotion(item)).toBeNull()
expect(belt.autoPlacePotion(item)).toBe(false)
expect(belt.setSlot(3, 0, item)).toBe(false)
}
})
})
// =========================================================================
// SUITE 2: Experience Table Parity Across All 7 Classes × 99 Levels
// =========================================================================
describe('Suite 2: Experience Table Parity Across All 7 Classes × 99 Levels', () => {
// Independent clean-room oracle parser directly parsing the raw TS text constant
function buildExperienceOracle(): Record<string, number[]> {
const lines = EXPERIENCE_TXT_RAW.split(/\r?\n/).filter((l) => l.trim().length > 0)
const header = lines[0]!.split('\t').map((h) => h.trim().toLowerCase())
const oracle: Record<string, number[]> = {
amazon: new Array(100).fill(0),
sorceress: new Array(100).fill(0),
necromancer: new Array(100).fill(0),
paladin: new Array(100).fill(0),
barbarian: new Array(100).fill(0),
druid: new Array(100).fill(0),
assassin: new Array(100).fill(0),
}
for (let i = 1; i < lines.length; i++) {
const parts = lines[i]!.split('\t')
const lvlStr = parts[0]!.trim()
if (lvlStr === 'MaxLvl') continue
const rowLvl = parseInt(lvlStr, 10)
if (Number.isNaN(rowLvl)) continue
const charLvl = rowLvl + 1
if (charLvl >= 1 && charLvl <= 99) {
for (const cls of Object.keys(oracle)) {
const colIdx = header.indexOf(cls)
if (colIdx !== -1) {
oracle[cls]![charLvl] = parseInt(parts[colIdx] ?? '0', 10)
}
}
}
}
return oracle
}
const oracle = buildExperienceOracle()
const classAliases: [string, string][] = [
['amazon', 'Ama'],
['sorceress', 'Sor'],
['necromancer', 'Nec'],
['paladin', 'Pal'],
['barbarian', 'Bar'],
['druid', 'Dru'],
['assassin', 'Ass'],
]
it('2.1 Verifies all 7 classes across all 99 levels against independent raw oracle (693 assertions)', () => {
for (const [canonicalName, alias] of classAliases) {
const expectedTable = oracle[canonicalName]!
expect(expectedTable).toBeDefined()
expect(expectedTable.length).toBe(100)
const tableFromFn = getExperienceTable(canonicalName)
const tableFromAlias = getExperienceTable(alias)
for (let lvl = 1; lvl <= 99; lvl++) {
const expectedXp = expectedTable[lvl]!
expect(tableFromFn[lvl]).toBe(expectedXp)
expect(tableFromAlias[lvl]).toBe(expectedXp)
expect(getExperienceForLevel(lvl, canonicalName)).toBe(expectedXp)
expect(getExperienceForLevel(lvl, alias)).toBe(expectedXp)
}
}
})
it('2.2 Boundary conditions, exact deltas and level cap enforcement', () => {
// Level 1 baseline
expect(getExperienceForLevel(1)).toBe(0)
expect(getNextLevelExperience(1)).toBe(500)
// Level 2 baseline
expect(getExperienceForLevel(2)).toBe(500)
expect(getNextLevelExperience(2)).toBe(1500)
// Key milestone levels
expect(getExperienceForLevel(17)).toBe(275207)
expect(getExperienceForLevel(30)).toBe(4663553)
expect(getExperienceForLevel(90)).toBe(1618470619)
// Level 98 -> 99 delta
const xp98 = getExperienceForLevel(98)
const xp99 = getExperienceForLevel(99)
expect(xp98).toBe(3229426756)
expect(xp99).toBe(3520485254)
expect(xp99 - xp98).toBe(291058498) // Exact 1.13c delta
// Level 98 next level experience
expect(getNextLevelExperience(98)).toBe(3520485254)
// Level 99 CAP: next level experience remains 3,520,485,254 and does not overflow or become NaN
expect(getNextLevelExperience(99)).toBe(3520485254)
expect(getNextLevelExperience(100)).toBe(3520485254)
// Degenerate / invalid inputs
expect(getExperienceForLevel(0)).toBe(0)
expect(getExperienceForLevel(-10)).toBe(0)
expect(getExperienceForLevel(100)).toBe(3520485254)
expect(getExperienceForLevel(999)).toBe(3520485254)
})
})
// =========================================================================
// SUITE 3: Mini-Panel & Death Screen Layout & Responsiveness
// =========================================================================
describe('Suite 3: Mini-Panel & Death Screen', () => {
it('3.1 Multiplayer (8 buttons) vs Single-Player (7 buttons) rapid toggle & click dispatch', () => {
const hud = new ControlBarHud()
const onMiniPanelAction = vi.fn()
const dummyState = {
level: 80,
xp: 1000000,
currentLevelXp: 0,
nextLevelXp: 2000000,
stamina: 100,
maxStamina: 100,
isRunning: false,
unspentStatPoints: 0,
unspentSkillPoints: 0,
}
// 500 rapid toggles between MP and SP
for (let i = 0; i < 500; i++) {
hud.isMultiplayer = i % 2 === 0
hud.miniPanelOpen = true
if (hud.isMultiplayer) {
expect(hud.currentMiniPanelItems.length).toBe(8)
expect(hud.currentMiniPanelBounds).toEqual(MINIPANEL_BOUNDS)
expect(hud.currentButtonStartX).toBe(320)
expect(hud.currentMiniPanelItems.map((m) => m.action)).toContain('party')
} else {
expect(hud.currentMiniPanelItems.length).toBe(7)
expect(hud.currentMiniPanelBounds).toEqual(MINIPANEL_SINGLE_BOUNDS)
expect(hud.currentButtonStartX).toBe(330)
expect(hud.currentMiniPanelItems.map((m) => m.action)).not.toContain('party')
}
}
// Test all 8 button hit dispatches in Multiplayer mode
hud.isMultiplayer = true
hud.miniPanelOpen = true
const mpActions = MULTIPLAYER_MINIPANEL_ITEMS.map((m) => m.action)
for (let idx = 0; idx < mpActions.length; idx++) {
onMiniPanelAction.mockClear()
const clickX = 320 + idx * 20 + 10
const clickY = 535
hud.handleMouseMove(clickX, clickY)
expect(hud.hoveredElement).toBe(mpActions[idx])
hud.handleClick(clickX, clickY, dummyState, { onToggleRun: vi.fn(), onMiniPanelAction })
expect(onMiniPanelAction).toHaveBeenCalledWith(mpActions[idx])
}
// Test all 7 button hit dispatches in Single-Player mode
hud.isMultiplayer = false
hud.miniPanelOpen = true
const spActions = SINGLEPLAYER_MINIPANEL_ITEMS.map((m) => m.action)
for (let idx = 0; idx < spActions.length; idx++) {
onMiniPanelAction.mockClear()
const clickX = 330 + idx * 20 + 10
const clickY = 535
hud.handleMouseMove(clickX, clickY)
expect(hud.hoveredElement).toBe(spActions[idx])
hud.handleClick(clickX, clickY, dummyState, { onToggleRun: vi.fn(), onMiniPanelAction })
expect(onMiniPanelAction).toHaveBeenCalledWith(spActions[idx])
}
})
it('3.2 Death screen DC6 sprite positioning across diverse resolutions and aspect ratios', () => {
const resolutions: ViewportResolution[] = [
'800x600', // 4:3 standard
'1024x768', // 4:3
'1068x600', // wide
'1280x720', // 16:9 widescreen
]
const { hud } = setupHudEnvironment()
hud.isDead = true
for (const res of resolutions) {
const profile = getViewportProfile(res)
hud.setViewportProfile(profile)
const expectedCenterX = Math.round(profile.width / 2)
const expectedCenterY = Math.round(profile.height / 2)
const expectedYHardcore = expectedCenterY - 94
const expectedYInst = expectedCenterY - 46
// Mock canvas context
const drawImageMock = vi.fn()
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: drawImageMock,
fillRect: vi.fn(),
fillText: vi.fn(),
} as any
hud.ctx = mockCtx
// Mock death DC6 images with distinct widths and heights
const mockHardcoreImg = { width: 340, height: 40 } as HTMLImageElement
const mockInstImg = { width: 420, height: 24 } as HTMLImageElement
;(hud as any).images.set('deathHardcore', mockHardcoreImg)
;(hud as any).images.set('deathInst', mockInstImg)
hud.render()
// Verify deathHardcore image drawn centered at (centerX - width/2, expectedYHardcore)
expect(drawImageMock).toHaveBeenCalledWith(
mockHardcoreImg,
expectedCenterX - Math.round(mockHardcoreImg.width / 2),
expectedYHardcore,
)
// Verify deathInst image drawn centered at (centerX - width/2, expectedYInst)
expect(drawImageMock).toHaveBeenCalledWith(
mockInstImg,
expectedCenterX - Math.round(mockInstImg.width / 2),
expectedYInst,
)
// Test fallback text rendering when images are absent
;(hud as any).images.delete('deathHardcore')
;(hud as any).images.delete('deathInst')
const fontSpy = vi.spyOn(hud.font, 'drawText')
hud.render()
expect(fontSpy).toHaveBeenCalledWith(
mockCtx,
'YOU HAVE DIED',
expectedCenterX,
expectedYHardcore + 18,
expect.objectContaining({ align: 'center', color: 'red' }),
)
expect(fontSpy).toHaveBeenCalledWith(
mockCtx,
'PRESS ESC TO CONTINUE.',
expectedCenterX,
expectedYInst + 18,
expect.objectContaining({ align: 'center', color: 'gold' }),
)
fontSpy.mockRestore()
}
})
})
})

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { beforeAll, describe, expect, it } from 'vitest'
import { AutomapView } from '../../src/client/automap/automap-view.ts' import { AutomapView } from '../../src/client/automap/automap-view.ts'
import { MapService } from '../../src/client/map/map-service.ts'
import { import {
clearMissingArtEntries, clearMissingArtEntries,
computeAnimDataFrameIndex, computeAnimDataFrameIndex,
@ -12,6 +13,11 @@ import { ClientWorldSceneSource, type ClientSceneWorldAdapter } from '../../src/
import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts' import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts'
import { PLR_MODES } from '../../src/common/units/plr-mode.ts' import { PLR_MODES } from '../../src/common/units/plr-mode.ts'
let sharedMapService: MapService
beforeAll(async () => {
sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles')
})
function makeTaggedCharArt(): LoadedCharacter { function makeTaggedCharArt(): LoadedCharacter {
const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [ const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [
{ key: 'dt', group: 0, frames: 4, speed: 256, loop: false }, { key: 'dt', group: 0, frames: 4, speed: 256, loop: false },
@ -106,8 +112,8 @@ describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isola
expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN) expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN)
// Render scene frame and inspect drawn sprite tags for both players // Render scene frame and inspect drawn sprite tags for both players
const automap = new AutomapView({ initialMode: 'level' }) const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
const levelView = automap.setContext(0x11223344, 0, 1) const levelView = automap.setContext(0x11223344, 0, 1)!
const baseX = levelView.subOriginX + 50 const baseX = levelView.subOriginX + 50
const baseY = levelView.subOriginY + 50 const baseY = levelView.subOriginY + 50
@ -362,8 +368,8 @@ describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isola
// Fail-fast diagnostic when clip speed metadata is missing // Fail-fast diagnostic when clip speed metadata is missing
clearMissingArtEntries() clearMissingArtEntries()
const automap = new AutomapView({ initialMode: 'level' }) const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
const levelView = automap.setContext(0x11223344, 0, 1) const levelView = automap.setContext(0x11223344, 0, 1)!
const baseX = levelView.subOriginX + 50 const baseX = levelView.subOriginX + 50
const baseY = levelView.subOriginY + 50 const baseY = levelView.subOriginY + 50
const source = new ClientWorldSceneSource( const source = new ClientWorldSceneSource(

View File

@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest' import { beforeAll, describe, expect, it, vi } from 'vitest'
import { import {
HttpRangeMpqSource, HttpRangeMpqSource,
InMemoryChunkCache, InMemoryChunkCache,
@ -80,6 +80,11 @@ import {
HudManager, HudManager,
} from '../../src/client/ui/hud-manager.ts' } from '../../src/client/ui/hud-manager.ts'
let sharedMapService: MapService
beforeAll(async () => {
sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles')
})
class MemoryStorage implements StorageLike { class MemoryStorage implements StorageLike {
readonly data = new Map<string, string>() readonly data = new Map<string, string>()
@ -421,7 +426,7 @@ describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms (
describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => { 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', () => { it('normalizes D2GS world sub-tiles by LevelView.subOriginX/Y and submits tile + unit quads to SpriteRenderer', () => {
const automap = new AutomapView({ initialMode: 'level' }) const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
const levelView = automap.setContext(0x11223344, 0, 1) const levelView = automap.setContext(0x11223344, 0, 1)
const localSubX = Math.floor((levelView.cellsX * 5) / 2) const localSubX = Math.floor((levelView.cellsX * 5) / 2)
const localSubY = Math.floor((levelView.cellsY * 5) / 2) const localSubY = Math.floor((levelView.cellsY * 5) / 2)
@ -535,6 +540,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F
describe('AutomapView Reveal Modes: off / level / act (F17)', () => { 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)', () => { it('switches cleanly between off (server rooms only), level (all current area rooms), and act (multi-level Act rooms)', () => {
const automap = new AutomapView({ const automap = new AutomapView({
mapService: sharedMapService,
initialMode: 'off', initialMode: 'off',
actRevealLevelIds: { actRevealLevelIds: {
1: [1, 2, 3], 1: [1, 2, 3],
@ -893,7 +899,7 @@ describe('Issue #540 R2 & R3: EntityArtCache, Object/Ground Item/Animated Tile R
}, },
} }
const automap = new AutomapView({ initialMode: 'level' }) const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
const levelView = automap.setContext(0x11223344, 0, 1) const levelView = automap.setContext(0x11223344, 0, 1)
const worldSubX = levelView.subOriginX + 50 const worldSubX = levelView.subOriginX + 50
const worldSubY = levelView.subOriginY + 50 const worldSubY = levelView.subOriginY + 50
@ -1000,7 +1006,7 @@ describe('Issue #540 R2 & R3: EntityArtCache, Object/Ground Item/Animated Tile R
it('cycles animatedPlacements frames in drawTileQuad using sceneTick', () => { it('cycles animatedPlacements frames in drawTileQuad using sceneTick', () => {
const p0 = { act: 1, page: 0, x: 10, y: 20, w: 160, h: 80, dx: -80, dy: -40 } const p0 = { act: 1, page: 0, x: 10, y: 20, w: 160, h: 80, dx: -80, dy: -40 }
const p1 = { act: 1, page: 0, x: 200, y: 20, w: 160, h: 80, dx: -80, dy: -40 } const p1 = { act: 1, page: 0, x: 200, y: 20, w: 160, h: 80, dx: -80, dy: -40 }
const automap = new AutomapView({ initialMode: 'level' }) const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
const levelView = automap.setContext(0x11223344, 0, 1) const levelView = automap.setContext(0x11223344, 0, 1)
const localSubX = Math.floor((levelView.cellsX * 5) / 2) const localSubX = Math.floor((levelView.cellsX * 5) / 2)
const localSubY = Math.floor((levelView.cellsY * 5) / 2) const localSubY = Math.floor((levelView.cellsY * 5) / 2)