diablo2-web/tests/e2e-automap-113c/tier1-feature-coverage.test.ts

1644 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tier 1 — Requirement-Driven Feature Coverage E2E Tests (`tests/e2e-automap-113c/tier1-feature-coverage.test.ts`).
*
* Covers primary happy-path behavior for all 13 features (`F1`–`F13`) in `PROJECT.md § Feature Inventory`
* (`>= 5` tests per feature, `65` tests total), grounded strictly in Diablo II v1.13c (`D2Common.dll` & `D2Client.dll`)
* and `samples/d2/d2exp.mpq` / `samples/d2-packs`.
*/
import fs from 'node:fs'
import path from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { MpqArchive, memorySource } from '../../src/mpq/index.ts'
import { decodeDs1 } from '../../src/formats/ds1.ts'
import { decodeDt1 } from '../../src/formats/dt1.ts'
import {
AUTOMAP_CELL_WEIGHT_PAIRS,
AUTOMAP_CROSSHAIR_SCALED_VERTICES,
AUTOMAP_CROSSHAIR_VERTICES,
AUTOMAP_LEVEL_NAMES,
AUTOMAP_TILE_NAMES,
type AutomapCell,
type AutomapLayerData,
type AutomapTable,
computeAutomapObjectPixelCoords,
computeAutomapTilePixelCoords,
deduplicateAutomapCells,
generateTownSavedExtras,
getAutomapCellId,
getAutomapCellWeight,
getAutomapLevelTypeId,
getAutomapTileType,
getCrosshairSegments,
parseAutomapTable,
resolveMonsterAutomapCellId,
resolveObjectAutomapCellId,
resolveTileAutomapCellId,
} from '../../src/formats/automap-table.ts'
import * as gameAutomapReexport from '../../src/game/automap-table.ts'
import { actOfLevel, cell, hasOutdoorWaypoint, parseTable } from '../../src/game/acts.ts'
import { buildIsoMapScene, buildLevelAutomap } from '../../src/game/d2map.ts'
import type { AtlasFrame } from '../../src/render/atlas.ts'
import type { AtlasHandle, SpriteRenderer } from '../../src/render/renderer.ts'
import {
AUTOMAP_UI_MANIFEST,
BAKED_UI_MANIFEST,
type AutomapSpriteKey,
} from '../../src/ui/baked-ui-meta.ts'
import type { D2ColorCode, D2FontRenderer } from '../../src/ui/font.ts'
import {
AUTOMAP_CROSSHAIR_COLORS,
AUTOMAP_FADE_ALPHAS,
AUTOMAP_PAN_STEP_PX,
AUTOMAP_REVEAL_DISTANCE_THRESHOLD,
AUTOMAP_SHEET_NAMES,
Minimap,
type MinimapLevel,
ROOM_SIZE_CELLS,
automapPixelToCellCoords,
computeAutomapRevealDistance,
computeAutomapViewport,
projectAutomapCellScreenRect,
resolveAutomapSheetName,
} from '../../src/ui/minimap.ts'
const ROOT_DIR = process.cwd()
const MPQ_EXP_PATH = path.resolve(ROOT_DIR, 'samples/d2/d2exp.mpq')
const MPQ_DATA_PATH = path.resolve(ROOT_DIR, 'samples/d2/d2data.mpq')
const PACK_DIR = path.resolve(ROOT_DIR, 'samples/d2-packs')
let expArchive: MpqArchive
let dataArchive: MpqArchive
let rawAutomapBytes: Uint8Array
let automapTable: AutomapTable
let packIndex: {
readonly levels: readonly {
readonly act: number
readonly levelId: number
readonly slug: string
readonly label: string
readonly levelName: string
readonly path: string
}[]
}
interface RecordedSpriteDraw {
readonly kind: 'sprite'
readonly frame: AtlasFrame
readonly x: number
readonly y: number
readonly atlas?: AtlasHandle
readonly width?: number
readonly height?: number
readonly tint?: 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]
}
function createRecordingRenderer(): {
renderer: SpriteRenderer
sprites: RecordedSpriteDraw[]
quads: RecordedQuadDraw[]
scissors: ({ x: number; y: number; width: number; height: number } | null)[]
} {
const sprites: RecordedSpriteDraw[] = []
const quads: RecordedQuadDraw[] = []
const scissors: ({ x: number; y: number; width: number; height: number } | null)[] = []
const mockGl = {
SCISSOR_TEST: 0x0c11,
enable(cap: number) {
if (cap === 0x0c11) {
// scissor enabled
}
},
scissor(x: number, y: number, width: number, height: number) {
scissors.push({ x, y, width, height })
},
disable(cap: number) {
if (cap === 0x0c11) {
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]
},
) {
sprites.push({
kind: 'sprite',
frame,
x,
y,
...(options?.atlas ? { atlas: options.atlas } : {}),
...(options?.width !== undefined ? { width: options.width } : {}),
...(options?.height !== undefined ? { height: options.height } : {}),
...(options?.tint ? { tint: options.tint } : {}),
})
},
drawSolid() {},
drawSolidQuad(
x0: number,
y0: number,
x1: number,
y1: number,
x2: number,
y2: number,
x3: number,
y3: number,
color: readonly [number, number, number, number],
) {
quads.push({ kind: 'quad', x0, y0, x1, y1, x2, y2, x3, y3, color })
},
} as unknown as SpriteRenderer
return { renderer, sprites, quads, scissors }
}
function registerAtlases(minimap: Minimap): void {
const keys: readonly AutomapSpriteKey[] = [
'MaxiMap',
'MaxiMapS',
'Act2Map',
'Act2MapS',
'Act4Map',
'Act4MapS',
'ExTnMap',
'ExTnMapS',
]
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,
} as AtlasHandle)
}
}
beforeAll(async () => {
expArchive = await MpqArchive.open(memorySource(new Uint8Array(fs.readFileSync(MPQ_EXP_PATH))))
dataArchive = await MpqArchive.open(memorySource(new Uint8Array(fs.readFileSync(MPQ_DATA_PATH))))
const autoMapEntry = expArchive.find('data\\global\\excel\\AutoMap.txt')
if (!autoMapEntry) throw new Error('AutoMap.txt missing in d2exp.mpq')
rawAutomapBytes = await expArchive.read(autoMapEntry)
automapTable = parseAutomapTable(rawAutomapBytes)
packIndex = JSON.parse(fs.readFileSync(path.join(PACK_DIR, 'index.json'), 'utf8'))
})
describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () => {
// ============================================================================
// F1: AutoMap.txt 36 Level Names & 20 Tile Names
// ============================================================================
describe('F1: AutoMap.txt 36 Level Names & 20 Tile Names (D2Common.dll 0x6fdef720 & 0x6fdef7b0)', () => {
it('F1.1: defines all 36 AUTOMAP_LEVEL_NAMES (0..35) matching D2Common.dll 0x6fdef720 including "1 Monestary"', () => {
expect(AUTOMAP_LEVEL_NAMES).toHaveLength(36)
expect(AUTOMAP_LEVEL_NAMES[0]).toBe('None')
expect(AUTOMAP_LEVEL_NAMES[1]).toBe('1 Town')
expect(AUTOMAP_LEVEL_NAMES[2]).toBe('1 Wilderness')
expect(AUTOMAP_LEVEL_NAMES[5]).toBe('1 Monestary')
expect(AUTOMAP_LEVEL_NAMES[11]).toBe('1 Tristram')
expect(AUTOMAP_LEVEL_NAMES[12]).toBe('2 Town')
expect(AUTOMAP_LEVEL_NAMES[19]).toBe('2 Arcane')
expect(AUTOMAP_LEVEL_NAMES[20]).toBe('3 Town')
expect(AUTOMAP_LEVEL_NAMES[26]).toBe('4 Town')
expect(AUTOMAP_LEVEL_NAMES[29]).toBe('5 Town')
expect(AUTOMAP_LEVEL_NAMES[34]).toBe('5 Baal')
expect(AUTOMAP_LEVEL_NAMES[35]).toBe('5 Lava')
})
it('F1.2: defines all 20 AUTOMAP_TILE_NAMES (0..19) matching D2Common.dll 0x6fdef7b0 ("fl".."fi")', () => {
expect(AUTOMAP_TILE_NAMES).toEqual([
'fl',
'wl',
'wr',
'wtlr',
'wtll',
'wtr',
'wbl',
'wbr',
'wld',
'wrd',
'wle',
'wre',
'co',
'sh',
'tr',
'rf',
'ld',
'rd',
'fd',
'fi',
])
})
it('F1.3: resolves all 36 level names case-insensitively and by numeric ID via getAutomapLevelTypeId', () => {
for (let id = 0; id < 36; id++) {
const name = AUTOMAP_LEVEL_NAMES[id]!
expect(getAutomapLevelTypeId(name)).toBe(id)
expect(getAutomapLevelTypeId(name.toUpperCase())).toBe(id)
expect(getAutomapLevelTypeId(name.toLowerCase())).toBe(id)
expect(getAutomapLevelTypeId(id)).toBe(id)
}
})
it('F1.4: resolves LvlTypes.txt "Act N - <Category>" strings to their 1-to-1 dwAutomapLevelType index', () => {
expect(getAutomapLevelTypeId('Act 1 - Town')).toBe(1)
expect(getAutomapLevelTypeId('Act 1 - Wilderness')).toBe(2)
expect(getAutomapLevelTypeId('Act 1 - Monastery')).toBe(5)
expect(getAutomapLevelTypeId('Act 2 - Town')).toBe(12)
expect(getAutomapLevelTypeId('Act 2 - Arcane')).toBe(19)
expect(getAutomapLevelTypeId('Act 3 - Kurast')).toBe(22)
expect(getAutomapLevelTypeId('Act 4 - Town')).toBe(26)
expect(getAutomapLevelTypeId('Act 5 - Town')).toBe(29)
expect(getAutomapLevelTypeId('Act 5 - Ice Caves')).toBe(33)
expect(getAutomapLevelTypeId('Act 5 - Baal')).toBe(34)
})
it('F1.5: resolves all 20 tile orientation names via getAutomapTileType and re-exports F1 symbols from src/game/automap-table.ts', () => {
for (let t = 0; t < 20; t++) {
const tileName = AUTOMAP_TILE_NAMES[t]!
expect(getAutomapTileType(tileName)).toBe(t)
expect(getAutomapTileType(tileName.toUpperCase())).toBe(t)
}
expect(gameAutomapReexport.AUTOMAP_LEVEL_NAMES).toBe(AUTOMAP_LEVEL_NAMES)
expect(gameAutomapReexport.AUTOMAP_TILE_NAMES).toBe(AUTOMAP_TILE_NAMES)
expect(gameAutomapReexport.getAutomapLevelTypeId).toBe(getAutomapLevelTypeId)
expect(gameAutomapReexport.getAutomapTileType).toBe(getAutomapTileType)
})
})
// ============================================================================
// F2: AutoMap.txt 3,288-Row Parser & getAutomapCellId
// ============================================================================
describe('F2: AutoMap.txt 3,288-Row Parser & getAutomapCellId (D2Common.dll 0x6fd5f8f0 & 0x6fd5fdd0)', () => {
it('F2.1: parses all 3,288 data rows of AutoMap.txt from d2exp.mpq into a 36x20 bucketed AutomapTable', () => {
expect(automapTable.rows).toHaveLength(3288)
expect(automapTable.buckets).toHaveLength(36)
for (let lvl = 0; lvl < 36; lvl++) {
expect(automapTable.buckets[lvl]).toHaveLength(20)
}
expect(automapTable.levelRanges).toHaveLength(36)
})
it('F2.2: parses Cel1..Cel4 strictly by column index (6, 8, 10, 12) despite duplicate Type2 headers at 7 and 9', () => {
const multiCelRows = automapTable.rows.filter(r => r.celCount >= 3)
expect(multiCelRows.length).toBeGreaterThan(50)
for (const row of multiCelRows) {
expect(row.cels).toHaveLength(row.celCount)
for (const cel of row.cels) {
expect(cel).toBeGreaterThanOrEqual(0)
expect(cel).toBeLessThan(1499)
}
}
})
it('F2.3: populates contiguous pAutoMapRand levelRanges for levelTypeIds 1..35 in AutoMap.txt', () => {
// Every populated levelType in AutoMap.txt has start >= 0 and end > start
let populatedCount = 0
for (let lvl = 1; lvl <= 35; lvl++) {
const range = automapTable.levelRanges[lvl]!
if (range.start !== -1) {
populatedCount++
expect(range.end).toBeGreaterThan(range.start)
expect(automapTable.rows[range.start]!.levelTypeId).toBe(lvl)
}
}
expect(populatedCount).toBeGreaterThanOrEqual(30)
})
it('F2.4: matches exact (levelTypeId, tileType, style, sequence) rows in getAutomapCellId', () => {
// Act 1 Town (1), wl (1), style 0, seq 0 -> 61
expect(getAutomapCellId(automapTable, 1, 1, 0, 0)).toBe(61)
// Act 1 Town (1), wr (2), style 0, seq 0 -> 60
expect(getAutomapCellId(automapTable, 1, 2, 0, 0)).toBe(60)
// Act 1 Cave (3): pick a populated bucket row and verify getAutomapCellId resolves a valid non-negative cellNo
const caveRow = automapTable.buckets[3]!.find(b => b.length > 0)![0]!
const caveCell = getAutomapCellId(
automapTable,
3,
caveRow.tileType,
caveRow.style === -1 ? 0 : caveRow.style,
caveRow.startSequence === -1 ? 0 : caveRow.startSequence,
)
expect(caveCell).toBeGreaterThanOrEqual(0)
})
it('F2.5: deterministically distributes multi-variant Cel1..Cel4 picks across spatial coordinates using SplitMix64', () => {
const multiRow = automapTable.rows.find(r => r.levelTypeId > 0 && r.celCount === 4)!
expect(multiRow).toBeDefined()
const seq = multiRow.startSequence === -1 ? 0 : multiRow.startSequence
const style = multiRow.style === -1 ? 0 : multiRow.style
const pickedSet = new Set<number>()
for (let x = 0; x < 12; x++) {
for (let y = 0; y < 12; y++) {
const cellId = getAutomapCellId(
automapTable,
multiRow.levelTypeId,
multiRow.tileType,
style,
seq,
x,
y,
12345,
)
expect(multiRow.cels).toContain(cellId)
// Same coordinates & seed must return identical result
expect(
getAutomapCellId(
automapTable,
multiRow.levelTypeId,
multiRow.tileType,
style,
seq,
x,
y,
12345,
),
).toBe(cellId)
pickedSet.add(cellId)
}
}
expect(pickedSet.size).toBe(4)
})
})
// ============================================================================
// F3: 0x6fba1528 Cell Priority Weight Table & Deduplication
// ============================================================================
describe('F3: 0x6fba1528 Cell Priority Weight Table & Deduplication (D2Client.dll 0x6fba1528 & 0x6fb11320)', () => {
it('F3.1: exports all 49 (cellId, groupId) priority weight pairs across 18 groups (0..17)', () => {
expect(AUTOMAP_CELL_WEIGHT_PAIRS).toHaveLength(49)
const groups = new Set(AUTOMAP_CELL_WEIGHT_PAIRS.map(([, g]) => g))
expect(groups.size).toBe(18)
for (let g = 0; g <= 17; g++) {
expect(groups.has(g)).toBe(true)
}
})
it('F3.2: returns exact group weights via getAutomapCellWeight for all 49 table entries', () => {
for (const [cellId, groupId] of AUTOMAP_CELL_WEIGHT_PAIRS) {
expect(getAutomapCellWeight(cellId)).toBe(groupId)
}
expect(getAutomapCellWeight(533)).toBe(17)
expect(getAutomapCellWeight(534)).toBe(17)
expect(getAutomapCellWeight(520)).toBe(16)
expect(getAutomapCellWeight(46)).toBe(6)
expect(getAutomapCellWeight(51)).toBe(7)
})
it('F3.3: preserves higher-weight cells (doors, stairs, warps) when deduplicating identical (xPixel, yPixel) coordinates', () => {
const cells: AutomapCell[] = [
{ cellNo: 0, xPixel: 80, yPixel: 40, cellX: 7, cellY: 3 }, // weight 0
{ cellNo: 46, xPixel: 80, yPixel: 40, cellX: 7, cellY: 3 }, // door weight 6
{ cellNo: 520, xPixel: 80, yPixel: 40, cellX: 7, cellY: 3 }, // stairs weight 16
{ cellNo: 11, xPixel: 80, yPixel: 40, cellX: 7, cellY: 3 }, // wall weight 2 (must not overwrite 520)
]
const deduped = deduplicateAutomapCells(cells)
expect(deduped).toHaveLength(1)
expect(deduped[0]!.cellNo).toBe(520)
expect(deduped[0]!.weight).toBe(16)
})
it('F3.4: preserves special override icons (300..319, 405) over unweighted (-1) ordinary tiles at identical coordinates', () => {
const cells: AutomapCell[] = [
{ cellNo: 95, xPixel: 16, yPixel: 24, cellX: 4, cellY: 2 }, // unweighted -1
{ cellNo: 318, xPixel: 16, yPixel: 24, cellX: 4, cellY: 2 }, // Waypoint 318
{ cellNo: 96, xPixel: 16, yPixel: 24, cellX: 4, cellY: 2 }, // unweighted -1 (must not overwrite 318)
]
const deduped = deduplicateAutomapCells(cells)
expect(deduped).toHaveLength(1)
expect(deduped[0]!.cellNo).toBe(318)
})
it('F3.5: deterministically sorts deduplicated cells by (yPixel, xPixel, cellNo)', () => {
const cells: AutomapCell[] = [
{ cellNo: 10, xPixel: 32, yPixel: 20, cellX: 4, cellY: 1 },
{ cellNo: 5, xPixel: 8, yPixel: 12, cellX: 2, cellY: 1 },
{ cellNo: 8, xPixel: -8, yPixel: 12, cellX: 1, cellY: 2 },
{ cellNo: 2, xPixel: 0, yPixel: 4, cellX: 0, cellY: 1 },
]
const sorted = deduplicateAutomapCells(cells)
expect(sorted.map(c => [c.yPixel, c.xPixel, c.cellNo])).toEqual([
[4, 0, 2],
[12, -8, 8],
[12, 8, 5],
[20, 32, 10],
])
})
})
// ============================================================================
// F4: 1.13c Special Tile, Object & Monster Overrides
// ============================================================================
describe('F4: 1.13c Special Tile, Object & Monster Overrides (0x6fd89360, 0x6fb11d00, 0x6fb10c40)', () => {
it('F4.1: applies Rogue Campfire (Level 2, style=8, seq=0 -> 405) and Act 2 Palace Cellar (Level 40, type=8, style=25, seq=0 -> 305)', () => {
expect(
resolveTileAutomapCellId(automapTable, {
levelId: 2,
levelTypeId: 2,
tileType: 0,
style: 8,
sequence: 0,
}),
).toBe(405)
expect(
resolveTileAutomapCellId(automapTable, {
levelId: 40,
levelTypeId: 12,
tileType: 8,
style: 25,
sequence: 0,
}),
).toBe(305)
})
it('F4.2: maps Stash (267 -> 319), Cain Gibbet (26 -> 267), Wirt (268 -> 309), and Khalim Chests (405..407 -> 319)', () => {
expect(resolveObjectAutomapCellId({ objectClassId: 267, autoMapCol: 0 })).toBe(319)
expect(resolveObjectAutomapCellId({ objectClassId: 26, autoMapCol: 0 })).toBe(267)
expect(resolveObjectAutomapCellId({ objectClassId: 268, autoMapCol: 0 })).toBe(309)
expect(resolveObjectAutomapCellId({ objectClassId: 405, autoMapCol: 0 })).toBe(319)
expect(resolveObjectAutomapCellId({ objectClassId: 406, autoMapCol: 0 })).toBe(319)
expect(resolveObjectAutomapCellId({ objectClassId: 407, autoMapCol: 0 })).toBe(319)
})
it('F4.3: maps Blue Town Portal (59 -> 306) and Red Portal (60 -> 300 when open in Level 4 Stony Field, 306 elsewhere)', () => {
expect(resolveObjectAutomapCellId({ objectClassId: 59, autoMapCol: 0 })).toBe(306)
expect(resolveObjectAutomapCellId({ objectClassId: 60, autoMapCol: 0, levelId: 4, mode: 1 })).toBe(300)
expect(resolveObjectAutomapCellId({ objectClassId: 60, autoMapCol: 0, levelId: 4, mode: 2 })).toBe(300)
expect(resolveObjectAutomapCellId({ objectClassId: 60, autoMapCol: 0, levelId: 39, mode: 2 })).toBe(306)
})
it('F4.4: gates Compelling Orb (366 -> 308 on mode 2) and Arcane Waypoint (402 -> 318 in levelId=74)', () => {
expect(resolveObjectAutomapCellId({ objectClassId: 366, autoMapCol: 308, mode: 2 })).toBe(308)
expect(resolveObjectAutomapCellId({ objectClassId: 402, autoMapCol: 318, levelId: 74 })).toBe(318)
expect(resolveObjectAutomapCellId({ objectClassId: 119, autoMapCol: 307 })).toBe(307)
})
it('F4.5: resolves all 7 Deckard Cain monster classIds (146,254,265,266,473,532,540 -> 267) and MonStats2.txt automapCel', () => {
for (const cainId of [146, 254, 265, 266, 473, 532, 540]) {
expect(
resolveMonsterAutomapCellId({
monsterClassId: cainId,
automapCel: 0,
noMap: true,
isAlive: true,
}),
).toBe(267)
}
// Standard NPC / boss with !noMap && automapCel > 0 (e.g. Izual 960, town NPC 272)
expect(
resolveMonsterAutomapCellId({
monsterClassId: 406,
automapCel: 960,
noMap: false,
isAlive: true,
}),
).toBe(960)
})
})
// ============================================================================
// F5: Pre-Baked Town Base Map Chunk Generation (0x6fb12760)
// ============================================================================
describe('F5: Pre-Baked Town Base Map Chunk Generation (D2Client.dll 0x6fb12760)', () => {
it('F5.1: generates 14 fSaved=1 (Act2Map) 4x5 grid cells (skipping 0x6fb82db8 frames) for Act 2 Lut Gholein (levelId=40)', () => {
const cells = generateTownSavedExtras(40, 360, 0, 160)
expect(cells).toHaveLength(14)
expect(cells.every(c => c.fSaved === 1)).toBe(true)
expect(cells.map(c => c.cellNo)).toEqual([21, 22, 23, 24, 26, 27, 28, 29, 31, 32, 33, 34, 37, 38])
})
it('F5.2: computes exact 0x6fb12877 subtile and pixel steps for Act 2 Lut Gholein (dx=-40 per row j, dy=+20 per col i)', () => {
const cells = generateTownSavedExtras(40, 360, 0, 160)
expect(cells[0]).toMatchObject({ cellNo: 21, fSaved: 1, xPixel: 923, yPixel: 713 })
expect(cells[1]).toMatchObject({ cellNo: 22, fSaved: 1, xPixel: 1083, yPixel: 713 })
expect(cells[4]).toMatchObject({ cellNo: 26, fSaved: 1, xPixel: 923, yPixel: 813 })
})
it('F5.3: generates 4 fSaved=2 (Act4Map) 2x2 grid cells for Act 4 Pandemonium Fortress (levelId=103)', () => {
const cells = generateTownSavedExtras(103, 120, 0)
expect(cells).toHaveLength(4)
expect(cells.every(c => c.fSaved === 2)).toBe(true)
expect(cells.map(c => c.cellNo)).toEqual([0, 1, 2, 3])
expect(cells[0]).toMatchObject({ cellNo: 0, fSaved: 2, xPixel: 251, yPixel: 248 })
})
it('F5.4: generates 6 fSaved=3 (ExTnMap) 2x4 grid cells (frames 0..5) for Act 5 Harrogath (levelId=109)', () => {
const cells = generateTownSavedExtras(109, 0, 0)
expect(cells).toHaveLength(6)
expect(cells.every(c => c.fSaved === 3)).toBe(true)
expect(cells.map(c => c.cellNo)).toEqual([0, 1, 2, 3, 4, 5])
expect(cells[0]).toMatchObject({ cellNo: 0, fSaved: 3, xPixel: -250, yPixel: 145 })
})
it('F5.5: embeds fSaved=1, 2, 3 town extras into baked scene.json files for levels 40, 103, and 109', () => {
for (const [levelId, expectedSlot, expectedLen] of [
[40, 1, 15],
[103, 2, 4],
[109, 3, 6],
] as const) {
const entry = packIndex.levels.find(l => l.levelId === levelId)!
expect(entry).toBeDefined()
const scene = JSON.parse(
fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8'),
) as { automap: AutomapLayerData }
const savedExtras = scene.automap.extras.filter(c => c.fSaved === expectedSlot)
expect(savedExtras).toHaveLength(expectedLen)
}
})
})
// ============================================================================
// F6: Offline Packing of All 8 Automap DC6 Sprite Sheets (scripts/pack-ui.ts)
// ============================================================================
describe('F6: Offline Packing of All 8 Automap DC6 Sprite Sheets (scripts/pack-ui.ts)', () => {
it('F6.1: packs all 8 automap sprite sheets into public/assets/ui/automap/*.png and manifest.json', () => {
const manifestPath = path.join(ROOT_DIR, 'public/assets/ui/automap/manifest.json')
expect(fs.existsSync(manifestPath)).toBe(true)
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as typeof AUTOMAP_UI_MANIFEST
const keys: readonly AutomapSpriteKey[] = [
'MaxiMap',
'MaxiMapS',
'Act2Map',
'Act2MapS',
'Act4Map',
'Act4MapS',
'ExTnMap',
'ExTnMapS',
]
for (const key of keys) {
const pngPath = path.join(ROOT_DIR, 'public/assets/ui/automap', `${key}.png`)
expect(fs.existsSync(pngPath)).toBe(true)
expect(fs.statSync(pngPath).size).toBeGreaterThan(100)
expect(manifest.atlases[key]).toBeDefined()
}
})
it('F6.2: packs all 8 automap sprite sheets into public/ui/automap-*.png and public/ui/manifest.json', () => {
const uiFiles = [
'automap-maxi.png',
'automap-maxis.png',
'automap-act2.png',
'automap-act2s.png',
'automap-act4.png',
'automap-act4s.png',
'automap-extn.png',
'automap-extns.png',
]
for (const file of uiFiles) {
const p = path.join(ROOT_DIR, 'public/ui', file)
expect(fs.existsSync(p)).toBe(true)
expect(fs.statSync(p).size).toBeGreaterThan(100)
}
const uiManifest = JSON.parse(
fs.readFileSync(path.join(ROOT_DIR, 'public/ui/manifest.json'), 'utf8'),
) as { automapAtlases?: Record<string, unknown> }
expect(uiManifest.automapAtlases).toBeDefined()
expect(Object.keys(uiManifest.automapAtlases!)).toHaveLength(8)
})
it('F6.3: exports exact frame counts in AUTOMAP_UI_MANIFEST and BAKED_UI_MANIFEST (1499, 40, 4, 8)', () => {
expect(AUTOMAP_UI_MANIFEST.atlases.MaxiMap.frameCount).toBe(1499)
expect(AUTOMAP_UI_MANIFEST.atlases.MaxiMapS.frameCount).toBe(1499)
expect(AUTOMAP_UI_MANIFEST.atlases.Act2Map.frameCount).toBe(40)
expect(AUTOMAP_UI_MANIFEST.atlases.Act2MapS.frameCount).toBe(40)
expect(AUTOMAP_UI_MANIFEST.atlases.Act4Map.frameCount).toBe(4)
expect(AUTOMAP_UI_MANIFEST.atlases.Act4MapS.frameCount).toBe(4)
expect(AUTOMAP_UI_MANIFEST.atlases.ExTnMap.frameCount).toBe(8)
expect(AUTOMAP_UI_MANIFEST.atlases.ExTnMapS.frameCount).toBe(8)
expect(BAKED_UI_MANIFEST.automapAtlases?.MaxiMap.frameCount).toBe(1499)
})
it('F6.4: preserves per-frame width, height, offsetX, offsetY, x, y metadata across all 8 sheets', () => {
for (const key of Object.keys(AUTOMAP_UI_MANIFEST.atlases) as AutomapSpriteKey[]) {
const atlas = AUTOMAP_UI_MANIFEST.atlases[key]
expect(atlas.frames).toHaveLength(atlas.frameCount)
expect(atlas.atlasWidth).toBeGreaterThan(0)
expect(atlas.atlasHeight).toBeGreaterThan(0)
const nonEmptyFrames = atlas.frames.filter(f => f.width > 0 && f.height > 0)
expect(nonEmptyFrames.length).toBeGreaterThan(0)
for (const frame of nonEmptyFrames) {
expect(Number.isInteger(frame.offsetX)).toBe(true)
expect(Number.isInteger(frame.offsetY)).toBe(true)
expect(frame.x).toBeGreaterThanOrEqual(0)
expect(frame.y).toBeGreaterThanOrEqual(0)
expect(frame.x + frame.width).toBeLessThanOrEqual(atlas.atlasWidth)
expect(frame.y + frame.height).toBeLessThanOrEqual(atlas.atlasHeight)
}
}
})
it('F6.5: scales MaxiMapS, Act2MapS, Act4MapS, ExTnMapS cell dimensions to half of full-scale sheets', () => {
expect(AUTOMAP_UI_MANIFEST.atlases.MaxiMapS.cellWidth).toBe(
AUTOMAP_UI_MANIFEST.atlases.MaxiMap.cellWidth / 2,
)
expect(AUTOMAP_UI_MANIFEST.atlases.Act2MapS.cellWidth).toBe(
AUTOMAP_UI_MANIFEST.atlases.Act2Map.cellWidth / 2,
)
expect(AUTOMAP_UI_MANIFEST.atlases.Act4MapS.cellWidth).toBe(
AUTOMAP_UI_MANIFEST.atlases.Act4Map.cellWidth / 2,
)
expect(AUTOMAP_UI_MANIFEST.atlases.ExTnMapS.cellWidth).toBe(
AUTOMAP_UI_MANIFEST.atlases.ExTnMap.cellWidth / 2,
)
})
})
// ============================================================================
// F7: Live & Offline 4-Layer Automap Generation Across All 136 Levels
// ============================================================================
describe('F7: Live & Offline 4-Layer Automap Generation Across All 136 Levels (src/game/d2map.ts & acts.ts)', () => {
it('F7.1: computes exact tile (0x6fb11880) and object (0x6fb11780) automap pixel coordinates', () => {
// Floor / upper wall (tileType < 16): xPixel = (cellX - cellY) * 8, yPixel = (cellX + cellY) * 4
expect(computeAutomapTilePixelCoords(12, 5, 0)).toEqual({
xPixel: (12 - 5) * 8,
yPixel: (12 + 5) * 4,
})
// Lower wall (tileType >= 16): yPixel += 24
expect(computeAutomapTilePixelCoords(12, 5, 16)).toEqual({
xPixel: (12 - 5) * 8,
yPixel: (12 + 5) * 4 + 24,
})
// Object at subtile (subX, subY): xPixel = trunc((subX - subY)*16/10) + 1, yPixel = trunc((subX + subY)*8/10) - 3
expect(computeAutomapObjectPixelCoords(25, 15)).toEqual({
xPixel: Math.trunc(((25 - 15) * 16) / 10) + 1,
yPixel: Math.trunc(((25 + 15) * 8) / 10) - 3,
})
})
it('F7.2: exposes Layer and LevelType on Levels.txt rows and resolves actOfLevel & hasOutdoorWaypoint', async () => {
const levelsEntry = expArchive.find('data\\global\\excel\\Levels.txt')
expect(levelsEntry).toBeDefined()
const levelsTable = parseTable(await expArchive.read(levelsEntry!))
const row1 = levelsTable.rows.find(r => Number(cell(levelsTable, r, 'Id')) === 1)!
expect(levelsTable.header).toContain('Layer')
expect(levelsTable.header).toContain('LevelType')
expect(Number(cell(levelsTable, row1, 'Layer'))).toBeGreaterThanOrEqual(0)
expect(levelsTable.rows.some(r => Number(cell(levelsTable, r, 'Layer')) > 0)).toBe(true)
expect(Number(cell(levelsTable, row1, 'LevelType'))).toBe(1)
expect(actOfLevel(1, levelsTable)).toBe(1)
expect(actOfLevel(40, levelsTable)).toBe(2)
expect(actOfLevel(75, levelsTable)).toBe(3)
expect(actOfLevel(103, levelsTable)).toBe(4)
expect(actOfLevel(109, levelsTable)).toBe(5)
expect(hasOutdoorWaypoint(3, levelsTable)).toBe(true)
})
it('F7.3: builds live 4-layer AutomapLayerData via buildLevelAutomap and buildIsoMapScene on a real DS1', async () => {
const ds1Entry = dataArchive.find('data\\global\\tiles\\ACT1\\TOWN\\townE1.ds1')
const dt1Entry = dataArchive.find('data\\global\\tiles\\ACT1\\TOWN\\floor.dt1')
expect(ds1Entry).toBeDefined()
expect(dt1Entry).toBeDefined()
const ds1 = decodeDs1(await dataArchive.read(ds1Entry!))
const dt1 = decodeDt1(await dataArchive.read(dt1Entry!))
const automap = buildLevelAutomap(ds1, {
automapTable,
levelId: 1,
layerId: 1,
levelTypeId: 1,
act: 1,
seed: 42,
})
expect(automap.layerId).toBe(1)
expect(automap.levelTypeId).toBe(1)
expect(automap.floors.length + automap.walls.length).toBeGreaterThan(100)
const scene = buildIsoMapScene(ds1, [dt1], 42, {
automapTable,
levelId: 1,
layerId: 1,
levelTypeId: 1,
act: 1,
seed: 42,
})
expect(scene.automap).toBeDefined()
expect(scene.automap?.floors.length).toBe(automap.floors.length)
expect(scene.automap?.walls.length).toBe(automap.walls.length)
})
it('F7.4: synthesizes waypoint automap icons (307 normal, 318 Arcane Sanctuary) when waypoints are passed to buildLevelAutomap', async () => {
const ds1Entry = dataArchive.find('data\\global\\tiles\\ACT1\\TOWN\\townE1.ds1')
const ds1 = decodeDs1(await dataArchive.read(ds1Entry!))
const normalWpMap = buildLevelAutomap(ds1, {
automapTable,
levelId: 3,
layerId: 3,
levelTypeId: 2,
act: 1,
waypoints: [{ x: 50, y: 50 }],
})
expect(normalWpMap.objects.some(c => c.cellNo === 307)).toBe(true)
const arcaneWpMap = buildLevelAutomap(ds1, {
automapTable,
levelId: 74,
layerId: 74,
levelTypeId: 19,
act: 2,
waypoints: [{ x: 50, y: 50 }],
})
expect(arcaneWpMap.objects.some(c => c.cellNo === 318)).toBe(true)
})
it('F7.5: bakes valid 4-layer automap data across all 365 scene.json variants in samples/d2-packs', () => {
expect(packIndex.levels.length).toBeGreaterThanOrEqual(365)
const levelIds = new Set<number>()
for (const entry of packIndex.levels) {
levelIds.add(entry.levelId)
}
expect(levelIds.size).toBe(136)
})
})
// ============================================================================
// F8: 4-Layer Isometric Sprite Automap & Minimap Renderer (src/ui/minimap.ts)
// ============================================================================
describe('F8: 4-Layer Isometric Sprite Automap & Minimap Renderer (src/ui/minimap.ts)', () => {
it('F8.1: maps AutomapAtlasSlot 0..3 and special levelIds to full-scale and half-scale sprite sheets', () => {
expect(AUTOMAP_SHEET_NAMES[0]).toEqual({ full: 'MaxiMap', half: 'MaxiMapS' })
expect(AUTOMAP_SHEET_NAMES[1]).toEqual({ full: 'Act2Map', half: 'Act2MapS' })
expect(AUTOMAP_SHEET_NAMES[2]).toEqual({ full: 'Act4Map', half: 'Act4MapS' })
expect(AUTOMAP_SHEET_NAMES[3]).toEqual({ full: 'ExTnMap', half: 'ExTnMapS' })
expect(resolveAutomapSheetName(0, false)).toBe('MaxiMap')
expect(resolveAutomapSheetName(0, true)).toBe('MaxiMapS')
expect(resolveAutomapSheetName(1, false)).toBe('Act2Map')
expect(resolveAutomapSheetName(1, true)).toBe('Act2MapS')
expect(resolveAutomapSheetName(2, false)).toBe('Act4Map')
expect(resolveAutomapSheetName(2, true)).toBe('Act4MapS')
expect(resolveAutomapSheetName(3, false)).toBe('ExTnMap')
expect(resolveAutomapSheetName(3, true)).toBe('ExTnMapS')
})
it('F8.2: projects cell coordinates to screen space using nDivisor=10 (Full Automap) and nDivisor=20 (Corner Minimap)', () => {
const cellCoord = { xPixel: 40, yPixel: 20 }
const frameFull = { width: 16, height: 12, offsetX: 2, offsetY: -1 }
const projFull = projectAutomapCellScreenRect(cellCoord, frameFull, -360, -256, false)
expect(projFull.screenX).toBe(400)
expect(projFull.screenY).toBe(276)
expect(projFull.drawLeft).toBe(400 + 2 - 8)
expect(projFull.drawTop).toBe(276 - 12 - 1)
const frameMini = { width: 8, height: 6, offsetX: 1, offsetY: -1 }
const projMini = projectAutomapCellScreenRect(cellCoord, frameMini, -624, -118, true)
expect(projMini.screenX).toBe(20 + 624)
expect(projMini.screenY).toBe(10 + 118)
expect(projMini.drawLeft).toBe(projMini.screenX + 1 - 4)
expect(projMini.drawTop).toBe(projMini.screenY - 6 - 1)
})
it('F8.3: recovers original tile (cellX, cellY) from pre-divided (xPixel, yPixel) via automapPixelToCellCoords', () => {
for (const [cx, cy] of [
[0, 0],
[4, 2],
[10, 7],
[23, 15],
] as const) {
const px = computeAutomapTilePixelCoords(cx, cy, 0)
expect(automapPixelToCellCoords(px.xPixel, px.yPixel)).toEqual({ cellX: cx, cellY: cy })
}
})
it('F8.4: renders the 4 automap sprite layers in strict order: floors -> walls -> objects -> extras', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const coords = computeAutomapTilePixelCoords(4, 4, 0)
const level: MinimapLevel = {
key: 'f8-order-test',
cellsX: 16,
cellsY: 16,
blocked: new Uint8Array(16 * 16 * 25),
gridWidth: 80,
markers: [],
automap: {
layerId: 1,
levelTypeId: 1,
floors: [{ cellNo: 3, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 0 }],
walls: [{ cellNo: 46, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 0 }],
objects: [{ cellNo: 318, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 0 }],
extras: [{ cellNo: 309, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 0 }],
},
}
minimap.reveal(level, 4, 4, 0, 320, true)
const { renderer, sprites } = createRecordingRenderer()
minimap.draw(renderer, level, 4, 4, { x: 0, y: 320, zoom: 1 }, { width: 800, height: 600 })
expect(sprites).toHaveLength(4)
const meta = minimap.getSheetMeta('MaxiMap')
expect(sprites[0]!.frame.x).toBe(meta.frames[3]!.x)
expect(sprites[0]!.frame.y).toBe(meta.frames[3]!.y)
expect(sprites[1]!.frame.x).toBe(meta.frames[46]!.x)
expect(sprites[1]!.frame.y).toBe(meta.frames[46]!.y)
expect(sprites[2]!.frame.x).toBe(meta.frames[318]!.x)
expect(sprites[2]!.frame.y).toBe(meta.frames[318]!.y)
expect(sprites[3]!.frame.x).toBe(meta.frames[309]!.x)
expect(sprites[3]!.frame.y).toBe(meta.frames[309]!.y)
})
it('F8.5: selects fSaved=1..3 town sprite atlases (Act2Map, Act4Map, ExTnMap) when rendering town extras', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const coords = computeAutomapTilePixelCoords(4, 4, 0)
const level: MinimapLevel = {
key: 'f8-town-sheets',
cellsX: 16,
cellsY: 16,
blocked: new Uint8Array(16 * 16 * 25),
gridWidth: 80,
markers: [],
automap: {
layerId: 40,
levelTypeId: 12,
floors: [],
walls: [],
objects: [],
extras: [
{ cellNo: 0, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 1 },
{ cellNo: 0, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 2 },
{ cellNo: 0, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 3 },
],
},
}
minimap.reveal(level, 4, 4, 0, 320, true)
const { renderer, sprites } = createRecordingRenderer()
minimap.draw(renderer, level, 4, 4, { x: 0, y: 320, zoom: 1 }, { width: 800, height: 600 })
expect(sprites).toHaveLength(3)
expect(sprites[0]!.atlas?.texture).toBe('Act2Map')
expect(sprites[1]!.atlas?.texture).toBe('Act4Map')
expect(sprites[2]!.atlas?.texture).toBe('ExTnMap')
})
})
// ============================================================================
// F9: Corner Minimap (bMinimap=1) vs Full Automap (bMinimap=0), Viewport, Panel Shift & Hotkeys
// ============================================================================
describe('F9: Corner Minimap (bMinimap=1) vs Full Automap (bMinimap=0), Viewport, Panel Shift & Hotkeys', () => {
it('F9.1: computes 279x225 Corner Minimap viewport at (W-295, 16) and shifts to (16, 16) when right panel opens', () => {
const vpTopRight = computeAutomapViewport(800, 600, true, false, false, 0, 0)
expect(vpTopRight).toMatchObject({
bMinimap: true,
divisor: 20,
x: 505,
y: 16,
width: 279,
height: 225,
centerX: 644,
centerY: 128,
})
const vpTopLeft = computeAutomapViewport(800, 600, true, false, true, 0, 0)
expect(vpTopLeft).toMatchObject({
bMinimap: true,
divisor: 20,
x: 16,
y: 16,
width: 279,
height: 225,
centerX: 155,
centerY: 128,
})
})
it('F9.2: computes Full Automap viewport [0, 0, W, H-48] and shifts centerX by +-160px when side panels open', () => {
const vpCenter = computeAutomapViewport(800, 600, false, false, false, 0, 0)
expect(vpCenter).toMatchObject({
bMinimap: false,
divisor: 10,
x: 0,
y: 0,
width: 800,
height: 552,
centerX: 400,
centerY: 276,
centerShiftX: 0,
})
const vpLeft = computeAutomapViewport(800, 600, false, true, false, 0, 0)
expect(vpLeft.centerX).toBe(560)
expect(vpLeft.centerShiftX).toBe(160)
const vpRight = computeAutomapViewport(800, 600, false, false, true, 0, 0)
expect(vpRight.centerX).toBe(240)
expect(vpRight.centerShiftX).toBe(-160)
})
it('F9.3: toggles visibility via Tab (toggle/toggleVisibility) and switches mode via V (toggleMode/toggleMinimapMode)', () => {
const minimap = new Minimap()
expect(minimap.visible).toBe(true)
expect(minimap.bMinimap).toBe(true)
expect(minimap.mode).toBe('minimap')
expect(minimap.toggleMode()).toBe('full')
expect(minimap.bMinimap).toBe(false)
expect(minimap.divisor).toBe(10)
expect(minimap.toggle()).toBe(false)
expect(minimap.visible).toBe(false)
// Pressing V when hidden makes it visible again
expect(minimap.toggleMode()).toBe('full')
expect(minimap.visible).toBe(true)
})
it('F9.4: pans Full Automap by +-8px per arrow step (AUTOMAP_PAN_STEP_PX) and resets pan on Home', () => {
const minimap = new Minimap()
minimap.mode = 'full'
expect(AUTOMAP_PAN_STEP_PX).toBe(8)
expect(minimap.panBySteps(2, -3)).toBe(true)
expect(minimap.panX).toBe(16)
expect(minimap.panY).toBe(-24)
minimap.resetPan()
expect(minimap.panX).toBe(0)
expect(minimap.panY).toBe(0)
})
it('F9.5: cycles 3 fade states via F9 (1.0 -> 0.75 -> 0.5 -> 1.0) and toggles names via F10', () => {
const minimap = new Minimap()
expect(AUTOMAP_FADE_ALPHAS).toEqual([1.0, 0.75, 0.5])
expect(minimap.fadeState).toBe(0)
expect(minimap.fadeAlpha).toBe(1.0)
expect(minimap.cycleFade()).toBe(1)
expect(minimap.fadeAlpha).toBe(0.75)
expect(minimap.cycleFade()).toBe(2)
expect(minimap.fadeAlpha).toBe(0.5)
expect(minimap.cycleFade()).toBe(0)
expect(minimap.fadeAlpha).toBe(1.0)
expect(minimap.showNames).toBe(true)
expect(minimap.toggleNames()).toBe(false)
expect(minimap.toggleNames()).toBe(true)
})
})
// ============================================================================
// F10: Room/Chunk-Based Fog-of-War Exploration Reveal (0x6fb12af0 & 0x6fb12580)
// ============================================================================
describe('F10: Room/Chunk-Based Fog-of-War Exploration Reveal (0x6fb12af0 & 0x6fb12580)', () => {
it('F10.1: computes octile distance max(|dx|,|dy|)*2 + min(|dx|,|dy|) per 0x6fb12af0', () => {
expect(AUTOMAP_REVEAL_DISTANCE_THRESHOLD).toBe(160)
expect(ROOM_SIZE_CELLS).toBe(8)
expect(computeAutomapRevealDistance(80, 0)).toBe(160)
expect(computeAutomapRevealDistance(0, 80)).toBe(160)
expect(computeAutomapRevealDistance(60, 40)).toBe(160)
expect(computeAutomapRevealDistance(40, 60)).toBe(160)
})
it('F10.2: reveals the 3x3 room neighborhood around the player on initial entry into a level', () => {
const minimap = new Minimap()
const level: MinimapLevel = {
key: 'f10-initial-reveal',
cellsX: 32,
cellsY: 32,
blocked: new Uint8Array(32 * 32 * 25),
gridWidth: 160,
markers: [],
}
// Cell (10, 10) is in room (1, 1) -> reveals rooms (0..2, 0..2) = 9 rooms
expect(minimap.reveal(level, 10, 10, 800, 800)).toBe(true)
expect(minimap.revealedRoomCount(level.key)).toBe(9)
for (let ry = 0; ry <= 2; ry++) {
for (let rx = 0; rx <= 2; rx++) {
expect(minimap.isRoomRevealed(level.key, rx, ry)).toBe(true)
}
}
expect(minimap.isRoomRevealed(level.key, 3, 3)).toBe(false)
})
it('F10.3: triggers subsequent room reveal when player movement reaches >= 160 octile sub-units', () => {
const minimap = new Minimap()
const level: MinimapLevel = {
key: 'f10-octile-step',
cellsX: 32,
cellsY: 32,
blocked: new Uint8Array(32 * 32 * 25),
gridWidth: 160,
markers: [],
}
expect(minimap.reveal(level, 4, 4, 320, 320)).toBe(true)
expect(minimap.isRoomRevealed(level.key, 3, 3)).toBe(false)
// Move to cell (24, 24) (world 1920, 1920 -> octile distance >= 160)
expect(minimap.reveal(level, 24, 24, 1920, 1920)).toBe(true)
expect(minimap.isRoomRevealed(level.key, 3, 3)).toBe(true)
})
it('F10.4: culls unrevealed floor, wall, and object cells while rendering revealed room cells', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const near = computeAutomapTilePixelCoords(2, 2, 0)
const far = computeAutomapTilePixelCoords(28, 28, 0)
const level: MinimapLevel = {
key: 'f10-cull-test',
cellsX: 32,
cellsY: 32,
blocked: new Uint8Array(32 * 32 * 25),
gridWidth: 160,
markers: [],
automap: {
layerId: 1,
levelTypeId: 1,
floors: [
{ cellNo: 1, xPixel: near.xPixel, yPixel: near.yPixel, cellX: 2, cellY: 2, fSaved: 0 },
{ cellNo: 2, xPixel: far.xPixel, yPixel: far.yPixel, cellX: 28, cellY: 28, fSaved: 0 },
],
walls: [],
objects: [],
extras: [],
},
}
minimap.reveal(level, 2, 2, 0, 160, true)
const { renderer } = createRecordingRenderer()
minimap.draw(renderer, level, 2, 2, { x: 0, y: 160, zoom: 1 }, { width: 800, height: 600 })
expect(minimap.spritesDrawnLastFrame.floors).toBe(1)
})
it('F10.5: tracks per-level exploration independently across level transitions', () => {
const minimap = new Minimap()
const levelA: MinimapLevel = {
key: 'act1-town',
cellsX: 24,
cellsY: 24,
blocked: new Uint8Array(24 * 24 * 25),
gridWidth: 120,
markers: [],
}
const levelB: MinimapLevel = {
key: 'act1-blood-moor',
cellsX: 24,
cellsY: 24,
blocked: new Uint8Array(24 * 24 * 25),
gridWidth: 120,
markers: [],
}
minimap.reveal(levelA, 4, 4, 320, 320, true)
expect(minimap.exploredFraction(levelA)).toBeGreaterThan(0)
expect(minimap.exploredFraction(levelB)).toBe(0)
expect(minimap.isCellExplored(levelA, 4, 4)).toBe(true)
expect(minimap.isCellExplored(levelB, 4, 4)).toBe(false)
})
})
// ============================================================================
// F11: 12-Segment Isometric X Crosshair (0x6fb0f1c0, 0x6fb82de8) & Dynamic Portals
// ============================================================================
describe('F11: 12-Segment Isometric X Crosshair (0x6fb0f1c0, 0x6fb82de8) & Dynamic Portals', () => {
it('F11.1: defines the exact 13 vertex pairs from 0x6fb82de8 and their 2x scaled coordinates', () => {
expect(AUTOMAP_CROSSHAIR_VERTICES).toEqual([
[0, -1],
[2, -2],
[4, -1],
[2, 0],
[4, 1],
[2, 2],
[0, 1],
[-2, 2],
[-4, 1],
[-2, 0],
[-4, -1],
[-2, -2],
[0, -1],
])
expect(AUTOMAP_CROSSHAIR_SCALED_VERTICES).toHaveLength(13)
expect(AUTOMAP_CROSSHAIR_SCALED_VERTICES[0]).toEqual([0, -2])
expect(AUTOMAP_CROSSHAIR_SCALED_VERTICES[2]).toEqual([8, -2])
expect(AUTOMAP_CROSSHAIR_SCALED_VERTICES[12]).toEqual([0, -2])
})
it('F11.2: generates 12 closed polygon line segments and applies the (-1, +5) shift when bMinimap=true', () => {
const fullSegs = getCrosshairSegments(400, 276, false)
const miniSegs = getCrosshairSegments(400, 276, true)
expect(fullSegs).toHaveLength(12)
expect(miniSegs).toHaveLength(12)
for (let i = 0; i < 12; i++) {
const next = (i + 1) % 12
expect(fullSegs[i]!.x1).toBe(fullSegs[next]!.x0)
expect(fullSegs[i]!.y1).toBe(fullSegs[next]!.y0)
expect(miniSegs[i]!.x0).toBe(fullSegs[i]!.x0 - 1)
expect(miniSegs[i]!.y0).toBe(fullSegs[i]!.y0 + 5)
}
})
it('F11.3: renders player, party, NPC, mercenary, Valkyrie/IronGolem, and boss crosshairs with authentic palette colors', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const level: MinimapLevel = {
key: 'f11-entities',
cellsX: 16,
cellsY: 16,
blocked: new Uint8Array(16 * 16 * 25),
gridWidth: 80,
markers: [],
}
minimap.reveal(level, 4, 4, 0, 320, true)
const { renderer, quads } = createRecordingRenderer()
minimap.draw(
renderer,
level,
4,
4,
{ x: 0, y: 320, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 320,
entities: [
{ x: 10, y: 320, kind: 'party', name: 'PartyAlly' },
{ x: 20, y: 320, kind: 'npc', name: 'Akara' },
{ x: 30, y: 320, kind: 'mercenary', name: 'KashyaMerc' },
{ x: 40, y: 320, kind: 'summon', classId: 357, name: 'Valkyrie' },
{ x: 50, y: 320, kind: 'boss', name: 'Corpsefire' },
],
},
)
expect(minimap.crosshairsDrawnLastFrame).toEqual({
player: 1,
party: 1,
npcs: 1,
mercenaries: 2,
bosses: 1,
total: 6,
})
expect(quads).toHaveLength(6 * 12)
expect(quads.some(q => q.color === AUTOMAP_CROSSHAIR_COLORS.player)).toBe(true)
expect(quads.some(q => q.color === AUTOMAP_CROSSHAIR_COLORS.party)).toBe(true)
expect(quads.some(q => q.color === AUTOMAP_CROSSHAIR_COLORS.mercenary)).toBe(true)
expect(quads.some(q => q.color === AUTOMAP_CROSSHAIR_COLORS.valkyrieOrIronGolem)).toBe(true)
expect(quads.some(q => q.color === AUTOMAP_CROSSHAIR_COLORS.boss)).toBe(true)
})
it('F11.4: renders dynamic town portals with frame 306 (local), 307 (other party owner), and 300 (Level 4 Stony Field)', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const maxiMeta = minimap.getSheetMeta('MaxiMap')
const level1: MinimapLevel = {
key: 'f11-portals-l1',
cellsX: 8,
cellsY: 8,
blocked: new Uint8Array(8 * 8 * 25),
gridWidth: 40,
markers: [],
levelId: 1,
automap: { layerId: 1, levelTypeId: 1, floors: [], walls: [], objects: [], extras: [] },
}
const { renderer: r1, sprites: s1 } = createRecordingRenderer()
minimap.draw(
r1,
level1,
2,
2,
{ x: 0, y: 160, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 160,
levelId: 1,
portals: [
{ x: 0, y: 160, isLocalPlayerOwner: true },
{ x: 20, y: 160, isLocalPlayerOwner: false },
],
},
)
expect(s1).toHaveLength(2)
expect(s1[0]!.frame.x).toBe(maxiMeta.frames[306]!.x)
expect(s1[1]!.frame.x).toBe(maxiMeta.frames[307]!.x)
// Level 4 (Stony Field Cairn Stones portal -> 300)
const { renderer: r4, sprites: s4 } = createRecordingRenderer()
minimap.draw(
r4,
level1,
2,
2,
{ x: 0, y: 160, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 160,
levelId: 4,
portals: [{ x: 0, y: 160, isLocalPlayerOwner: true }],
},
)
expect(s4).toHaveLength(1)
expect(s4[0]!.frame.x).toBe(maxiMeta.frames[300]!.x)
})
it('F11.5: uses MaxiMapS half-scale portal frames when rendering portals in Corner Minimap mode (bMinimap=true)', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'minimap'
const maxiSMeta = minimap.getSheetMeta('MaxiMapS')
const level: MinimapLevel = {
key: 'f11-portal-mini',
cellsX: 8,
cellsY: 8,
blocked: new Uint8Array(8 * 8 * 25),
gridWidth: 40,
markers: [],
automap: { layerId: 1, levelTypeId: 1, floors: [], walls: [], objects: [], extras: [] },
}
const { renderer, sprites } = createRecordingRenderer()
minimap.draw(
renderer,
level,
2,
2,
{ x: 0, y: 160, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 160,
levelId: 1,
portals: [{ x: 0, y: 160, isLocalPlayerOwner: true }],
},
)
expect(sprites).toHaveLength(1)
expect(sprites[0]!.atlas?.texture).toBe('MaxiMapS')
expect(sprites[0]!.frame.x).toBe(maxiSMeta.frames[306]!.x)
expect(sprites[0]!.frame.y).toBe(maxiSMeta.frames[306]!.y)
})
})
// ============================================================================
// F12: Entrance/Warp Area Name Labels & Top-Right Gold Font16 HUD Text (0x6fb0f9f0)
// ============================================================================
describe('F12: Entrance/Warp Area Name Labels & Top-Right Gold Font16 HUD Text (0x6fb0f9f0)', () => {
it('F12.1: collects revealed entrance/warp destination labels in warpLabelsLastFrame', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const level: MinimapLevel = {
key: 'f12-warps',
cellsX: 16,
cellsY: 16,
blocked: new Uint8Array(16 * 16 * 25),
gridWidth: 80,
levelName: 'Rogue Encampment',
difficulty: 'Normal',
markers: [{ cellX: 4, cellY: 4, kind: 'exit', targetName: 'Blood Moor' }],
}
minimap.reveal(level, 4, 4, 0, 320, true)
const { renderer } = createRecordingRenderer()
minimap.draw(renderer, level, 4, 4, { x: 0, y: 320, zoom: 1 }, { width: 800, height: 600 })
expect(minimap.warpLabelsLastFrame).toContainEqual(
expect.objectContaining({ text: 'Blood Moor', kind: 'warp', color: 'white' }),
)
})
it('F12.2: collects gold "Stash" label on revealed Stash descriptors in warpLabelsLastFrame', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const level: MinimapLevel = {
key: 'f12-stash',
cellsX: 16,
cellsY: 16,
blocked: new Uint8Array(16 * 16 * 25),
gridWidth: 80,
markers: [],
}
minimap.reveal(level, 4, 4, 0, 320, true)
const { renderer } = createRecordingRenderer()
minimap.draw(
renderer,
level,
4,
4,
{ x: 0, y: 320, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 320,
stashes: [{ x: 0, y: 320 }],
},
)
expect(minimap.warpLabelsLastFrame).toContainEqual(
expect.objectContaining({ text: 'Stash', kind: 'stash', color: 'gold' }),
)
})
it('F12.3: populates hudTextLastFrame with Level Name and Difficulty at x = 784 (800 - 16), y1 = 16, y2 = 34', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const level: MinimapLevel = {
key: 'f12-hud-text',
cellsX: 16,
cellsY: 16,
blocked: new Uint8Array(16 * 16 * 25),
gridWidth: 80,
levelName: 'Lut Gholein',
difficulty: 'Nightmare',
markers: [],
}
minimap.reveal(level, 4, 4, 0, 320, true)
const { renderer } = createRecordingRenderer()
minimap.draw(renderer, level, 4, 4, { x: 0, y: 320, zoom: 1 }, { width: 800, height: 600 })
expect(minimap.hudTextLastFrame).toEqual({
levelName: 'Lut Gholein',
difficulty: 'Nightmare',
x: 784,
y1: 16,
y2: 34,
})
})
it('F12.4: renders centered Font6 warp/stash labels and right-aligned gold Font16 HUD text via drawHudOverlay', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const level: MinimapLevel = {
key: 'f12-overlay-draw',
cellsX: 16,
cellsY: 16,
blocked: new Uint8Array(16 * 16 * 25),
gridWidth: 80,
levelName: 'Chaos Sanctuary',
difficulty: 'Hell',
markers: [{ cellX: 4, cellY: 4, kind: 'exit', targetName: 'River of Flame' }],
}
minimap.reveal(level, 4, 4, 0, 320, true)
const { renderer } = createRecordingRenderer()
minimap.draw(
renderer,
level,
4,
4,
{ x: 0, y: 320, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 320,
stashes: [{ x: 0, y: 320, label: 'Stash' }],
},
)
const calls: { text: string; x: number; y: number; font?: string; color?: D2ColorCode; align?: string }[] = []
const mockFont = {
drawText(
_ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
opts?: Parameters<D2FontRenderer['drawText']>[4],
) {
calls.push({
text,
x,
y,
...(opts?.font ? { font: opts.font } : {}),
...(opts?.color ? { color: opts.color } : {}),
...(opts?.align ? { align: opts.align } : {}),
})
},
} as unknown as D2FontRenderer
minimap.drawHudOverlay({} as CanvasRenderingContext2D, mockFont, 800, 600)
expect(calls).toContainEqual(
expect.objectContaining({
text: 'Chaos Sanctuary',
x: 784,
y: 16,
font: 'font16',
color: 'gold',
align: 'right',
}),
)
expect(calls).toContainEqual(
expect.objectContaining({
text: 'Hell',
x: 784,
y: 34,
font: 'font16',
color: 'gold',
align: 'right',
}),
)
expect(calls).toContainEqual(
expect.objectContaining({
text: 'River of Flame',
font: 'font6',
align: 'center',
}),
)
expect(calls).toContainEqual(
expect.objectContaining({
text: 'Stash',
font: 'font6',
color: 'gold',
align: 'center',
}),
)
})
it('F12.5: renders green Font6 NPC/party name labels when showNames is true and suppresses them when F10 toggles showNames off', () => {
const minimap = new Minimap()
registerAtlases(minimap)
minimap.mode = 'full'
const level: MinimapLevel = {
key: 'f12-entity-names',
cellsX: 16,
cellsY: 16,
blocked: new Uint8Array(16 * 16 * 25),
gridWidth: 80,
markers: [],
}
minimap.reveal(level, 4, 4, 0, 320, true)
const { renderer } = createRecordingRenderer()
minimap.draw(
renderer,
level,
4,
4,
{ x: 0, y: 320, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 320,
entities: [{ x: 0, y: 320, kind: 'npc', name: 'Deckard Cain' }],
},
)
const recordFontCalls = () => {
const out: string[] = []
const mockFont = {
drawText(_ctx: CanvasRenderingContext2D, text: string) {
out.push(text)
},
} as unknown as D2FontRenderer
minimap.drawHudOverlay({} as CanvasRenderingContext2D, mockFont, 800, 600)
return out
}
expect(recordFontCalls()).toContain('Deckard Cain')
minimap.toggleNames()
expect(recordFontCalls()).not.toContain('Deckard Cain')
})
})
// ============================================================================
// F13: Full-Spectrum 136-Level Verification & Browser Audit
// ============================================================================
describe('F13: Full-Spectrum 136-Level Verification & Browser Audit (scripts/verify-automap.ts)', () => {
it('F13.1: verifies all 5 Act Towns (LevelIds 1, 40, 75, 103, 109) have baked automap layers and render in both modes', () => {
const minimap = new Minimap()
registerAtlases(minimap)
const { renderer } = createRecordingRenderer()
for (const townLevelId of [1, 40, 75, 103, 109]) {
const entry = packIndex.levels.find(l => l.levelId === townLevelId)!
expect(entry).toBeDefined()
const scene = JSON.parse(
fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8'),
) as {
cellsX: number
cellsY: number
originX: number
originY: number
levelName: string
collision: { width: number; height: number }
automap: AutomapLayerData
}
const ref = scene.automap.floors[0] ?? scene.automap.walls[0] ?? scene.automap.extras[0]!
const cx = Math.max(0, Math.min(scene.cellsX - 1, ref.cellX))
const cy = Math.max(0, Math.min(scene.cellsY - 1, ref.cellY))
const wx = scene.originX + (cx - cy) * 80
const wy = scene.originY + (cx + cy) * 40
const levelModel: MinimapLevel = {
key: `town-${String(townLevelId)}`,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
blocked: new Uint8Array(scene.collision.width * scene.collision.height),
gridWidth: scene.collision.width,
originX: scene.originX,
originY: scene.originY,
automap: scene.automap,
levelId: townLevelId,
levelName: scene.levelName,
difficulty: 'Normal',
markers: [],
}
minimap.reveal(levelModel, cx, cy, wx, wy, true)
minimap.mode = 'minimap'
minimap.draw(renderer, levelModel, cx, cy, { x: wx, y: wy, zoom: 1 }, { width: 800, height: 600 }, { playerWorldX: wx, playerWorldY: wy, levelId: townLevelId })
expect(minimap.spritesDrawnLastFrame.total).toBeGreaterThan(0)
expect(minimap.crosshairsDrawnLastFrame.player).toBe(1)
minimap.mode = 'full'
minimap.draw(renderer, levelModel, cx, cy, { x: wx, y: wy, zoom: 1 }, { width: 800, height: 600 }, { playerWorldX: wx, playerWorldY: wy, levelId: townLevelId })
expect(minimap.spritesDrawnLastFrame.total).toBeGreaterThan(0)
expect(minimap.crosshairsDrawnLastFrame.player).toBe(1)
}
})
it('F13.2: verifies Act 1–3 outdoor wilderness levels (LevelIds 2..7, 41..46, 76..83) produce valid automap cells', () => {
const wildernessIds = [2, 3, 4, 5, 6, 7, 41, 42, 43, 44, 45, 46, 76, 77, 78, 79, 80, 81, 82, 83]
for (const levelId of wildernessIds) {
const entry = packIndex.levels.find(l => l.levelId === levelId)!
expect(entry).toBeDefined()
const scene = JSON.parse(
fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8'),
) as { automap: AutomapLayerData }
const total =
scene.automap.floors.length +
scene.automap.walls.length +
scene.automap.objects.length +
scene.automap.extras.length
expect(total).toBeGreaterThan(0)
}
})
it('F13.3: verifies Act 1–5 dungeons and boss lairs (Catacombs 37, Tal Rasha 73, Durance 102, Chaos Sanctuary 108, Throne 131, Worldstone 132)', () => {
const bossLevelIds = [37, 73, 102, 108, 131, 132]
for (const levelId of bossLevelIds) {
const entry = packIndex.levels.find(l => l.levelId === levelId)!
expect(entry).toBeDefined()
const scene = JSON.parse(
fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8'),
) as { automap: AutomapLayerData }
const total =
scene.automap.floors.length +
scene.automap.walls.length +
scene.automap.objects.length +
scene.automap.extras.length
expect(total).toBeGreaterThan(0)
}
})
it('F13.4: verifies all 136 canonical Level IDs (1..136) have non-empty automap cells with valid frame indices', () => {
for (let levelId = 1; levelId <= 136; levelId++) {
const entry = packIndex.levels.find(l => l.levelId === levelId)!
expect(entry).toBeDefined()
const scene = JSON.parse(
fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8'),
) as { automap: AutomapLayerData }
const allCells = [
...scene.automap.floors,
...scene.automap.walls,
...scene.automap.objects,
...scene.automap.extras,
]
expect(allCells.length).toBeGreaterThan(0)
for (const c of allCells) {
const sheet = resolveAutomapSheetName(c.fSaved ?? 0, false)
const meta = AUTOMAP_UI_MANIFEST.atlases[sheet]
expect(c.cellNo).toBeGreaterThanOrEqual(0)
expect(c.cellNo).toBeLessThan(meta.frameCount)
}
}
})
it('F13.5: confirms scripts/verify-automap.ts exists and implements offline 136-level + headless Chrome CDP audit', () => {
const scriptPath = path.join(ROOT_DIR, 'scripts/verify-automap.ts')
expect(fs.existsSync(scriptPath)).toBe(true)
const content = fs.readFileSync(scriptPath, 'utf8')
expect(content).toContain('verifyAll136LevelsOffline')
expect(content).toContain('verifyHeadlessBrowserCdp')
expect(content).toContain('[1, 2, 8, 40, 47, 75, 103, 109]')
})
})
})