1644 lines
64 KiB
TypeScript
1644 lines
64 KiB
TypeScript
/**
|
||
* 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, 14],
|
||
[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 136 scene.json variants in samples/d2-packs', () => {
|
||
expect(packIndex.levels.length).toBe(136)
|
||
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]')
|
||
})
|
||
})
|
||
})
|