1202 lines
49 KiB
TypeScript
1202 lines
49 KiB
TypeScript
/**
|
||
* Tier 2 — Boundary & Corner Cases E2E Tests (`tests/e2e-automap-113c/tier2-boundary-corner.test.ts`).
|
||
*
|
||
* Covers boundary conditions, edge cases, negative/fail-fast inputs, wildcard rows (`Style == -1`,
|
||
* `StartSequence == -1`), divider rows, hidden wall bit 0 (`flags & 1`), multi-tile `seq != 0`
|
||
* suppression, Level 4 portal mode `0` vs non-`0`, octile distance boundary `159` vs `160`,
|
||
* viewport edge clamping, and negative zero normalization (`>= 5` tests per feature, `65` tests total).
|
||
*/
|
||
|
||
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 {
|
||
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 type { Ds1 } from '../../src/formats/ds1.ts'
|
||
import { actOfLevel, cell, hasOutdoorWaypoint, parseTable } from '../../src/game/acts.ts'
|
||
import { 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, type AutomapSpriteKey } from '../../src/ui/baked-ui-meta.ts'
|
||
import type { D2FontRenderer } from '../../src/ui/font.ts'
|
||
import {
|
||
AUTOMAP_REVEAL_DISTANCE_THRESHOLD,
|
||
Minimap,
|
||
type MinimapLevel,
|
||
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 PACK_DIR = path.resolve(ROOT_DIR, 'samples/d2-packs')
|
||
|
||
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
|
||
}[]
|
||
}
|
||
|
||
function createNoopRenderer(): SpriteRenderer {
|
||
return {
|
||
flush() {},
|
||
draw(_frame: AtlasFrame) {},
|
||
drawSolid() {},
|
||
drawSolidQuad() {},
|
||
} as unknown as SpriteRenderer
|
||
}
|
||
|
||
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 () => {
|
||
const expArchive = await MpqArchive.open(memorySource(new Uint8Array(fs.readFileSync(MPQ_EXP_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 2 — Boundary & Corner Cases (F1–F13, >= 5 tests per feature)', () => {
|
||
// ============================================================================
|
||
// F1: AutoMap.txt 36 Level Names & 20 Tile Names — Boundary & Fail-Fast
|
||
// ============================================================================
|
||
describe('F1 Boundary: Level & Tile Name Lookup Edge Cases & Fail-Fast', () => {
|
||
it('F1.B1: maps empty string, "0", "None", and "Expansion" to levelTypeId 0', () => {
|
||
expect(getAutomapLevelTypeId('')).toBe(0)
|
||
expect(getAutomapLevelTypeId(' ')).toBe(0)
|
||
expect(getAutomapLevelTypeId('0')).toBe(0)
|
||
expect(getAutomapLevelTypeId('None')).toBe(0)
|
||
expect(getAutomapLevelTypeId('none')).toBe(0)
|
||
expect(getAutomapLevelTypeId('Expansion')).toBe(0)
|
||
expect(getAutomapLevelTypeId('EXPANSION')).toBe(0)
|
||
})
|
||
|
||
it('F1.B2: maps both "1 Monestary" (D2 binary typo) and "1 Monastery" / "Act 1 - Monastery" to index 5', () => {
|
||
expect(getAutomapLevelTypeId('1 Monestary')).toBe(5)
|
||
expect(getAutomapLevelTypeId('1 Monastery')).toBe(5)
|
||
expect(getAutomapLevelTypeId('Act 1 - Monastery')).toBe(5)
|
||
expect(getAutomapLevelTypeId(' act 1 - monastery ')).toBe(5)
|
||
})
|
||
|
||
it('F1.B3: throws fail-fast errors on out-of-range numeric levelTypeId (< 0, >= 36, NaN, float)', () => {
|
||
expect(() => getAutomapLevelTypeId(-1)).toThrow()
|
||
expect(() => getAutomapLevelTypeId(36)).toThrow()
|
||
expect(() => getAutomapLevelTypeId(NaN)).toThrow()
|
||
expect(() => getAutomapLevelTypeId(1.5)).toThrow()
|
||
expect(() => getAutomapLevelTypeId(Infinity)).toThrow()
|
||
})
|
||
|
||
it('F1.B4: throws fail-fast errors on unrecognized level name strings and non-string/number types', () => {
|
||
expect(() => getAutomapLevelTypeId('Act 6 - Heaven')).toThrow()
|
||
expect(() => getAutomapLevelTypeId('99 Unknown')).toThrow()
|
||
expect(() => getAutomapLevelTypeId(null as unknown as string)).toThrow()
|
||
expect(() => getAutomapLevelTypeId(undefined as unknown as string)).toThrow()
|
||
})
|
||
|
||
it('F1.B5: maps empty tileName to 0 ("fl") for divider rows and throws on unrecognized tile names', () => {
|
||
expect(getAutomapTileType('')).toBe(0)
|
||
expect(getAutomapTileType(' ')).toBe(0)
|
||
expect(() => getAutomapTileType('wall_invalid')).toThrow()
|
||
expect(() => getAutomapTileType('20')).toThrow()
|
||
expect(() => getAutomapTileType(null as unknown as string)).toThrow()
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F2: AutoMap.txt Parser & getAutomapCellId — Boundary & Wildcard Cases
|
||
// ============================================================================
|
||
describe('F2 Boundary: Parser Validation, Divider Rows, Wildcards & Cel Termination', () => {
|
||
it('F2.B1: fails fast when parseAutomapTable receives empty, null, or malformed headers', () => {
|
||
expect(() => parseAutomapTable('')).toThrow()
|
||
expect(() => parseAutomapTable(' \n ')).toThrow()
|
||
expect(() => parseAutomapTable(null as unknown as string)).toThrow()
|
||
expect(() => parseAutomapTable('BadHeader\tCol2')).toThrow()
|
||
})
|
||
|
||
it('F2.B2: stops counting celCount at the first -1 in Cel1..Cel4 per DATATBLS_LoadAutomapTxt (0x6fdbcca0)', () => {
|
||
const tsv = [
|
||
'LevelName\tTileName\tStyle\tStartSequence\tEndSequence\tType1\tCel1\tType2\tCel2\tType2\tCel3\tType4\tCel4',
|
||
'1 Town\tfl\t1\t0\t0\tfl\t10\t\t-1\t\t20\t\t30',
|
||
'1 Town\twl\t2\t0\t0\twl\t-1\t\t15\t\t25\t\t35',
|
||
].join('\n')
|
||
const table = parseAutomapTable(tsv)
|
||
expect(table.rows[0]!.celCount).toBe(1)
|
||
expect(table.rows[0]!.cels).toEqual([10])
|
||
expect(table.rows[1]!.celCount).toBe(0)
|
||
expect(table.rows[1]!.cels).toEqual([])
|
||
expect(getAutomapCellId(table, 1, 1, 2, 0)).toBe(-1)
|
||
})
|
||
|
||
it('F2.B3: matches StartSequence == -1 and Style == -1 as wildcards and respects inclusive [StartSequence..EndSequence] bounds', () => {
|
||
const tsv = [
|
||
'LevelName\tTileName\tStyle\tStartSequence\tEndSequence\tType1\tCel1\tType2\tCel2\tType2\tCel3\tType4\tCel4',
|
||
'1 Cave\tfl\t3\t5\t8\tfl\t111\t\t-1\t\t-1\t\t-1',
|
||
'1 Cave\tfl\t3\t-1\t-1\tfl\t222\t\t-1\t\t-1\t\t-1',
|
||
'1 Cave\tfl\t-1\t-1\t-1\tfl\t333\t\t-1\t\t-1\t\t-1',
|
||
].join('\n')
|
||
const table = parseAutomapTable(tsv)
|
||
// Exact boundary: sequence 5 and 8 match row 0 (111)
|
||
expect(getAutomapCellId(table, 3, 0, 3, 5)).toBe(111)
|
||
expect(getAutomapCellId(table, 3, 0, 3, 8)).toBe(111)
|
||
// Sequence 4 and 9 miss row 0, hit row 1 with StartSequence == -1 (222)
|
||
expect(getAutomapCellId(table, 3, 0, 3, 4)).toBe(222)
|
||
expect(getAutomapCellId(table, 3, 0, 3, 9)).toBe(222)
|
||
// Style 99 misses row 0 & 1, hits row 2 with Style == -1 (333)
|
||
expect(getAutomapCellId(table, 3, 0, 99, 99)).toBe(333)
|
||
})
|
||
|
||
it('F2.B4: returns -1 for levelTypeId === 0 (divider rows) even if bucket[0] contains rows', () => {
|
||
expect(getAutomapCellId(automapTable, 0, 0, 0, 0)).toBe(-1)
|
||
expect(getAutomapCellId(automapTable, 0, 1, -1, -1)).toBe(-1)
|
||
})
|
||
|
||
it('F2.B5: throws fail-fast errors in getAutomapCellId on invalid table, levelTypeId, tileType, style, or sequence', () => {
|
||
expect(() => getAutomapCellId(null as unknown as AutomapTable, 1, 0, 0, 0)).toThrow()
|
||
expect(() => getAutomapCellId(automapTable, -1, 0, 0, 0)).toThrow()
|
||
expect(() => getAutomapCellId(automapTable, 36, 0, 0, 0)).toThrow()
|
||
expect(() => getAutomapCellId(automapTable, 1, -1, 0, 0)).toThrow()
|
||
expect(() => getAutomapCellId(automapTable, 1, 20, 0, 0)).toThrow()
|
||
expect(() => getAutomapCellId(automapTable, 1, 0, -2, 0)).toThrow()
|
||
expect(() => getAutomapCellId(automapTable, 1, 0, 0, -2)).toThrow()
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F3: 0x6fba1528 Priority Weight Table & Deduplication — Boundary Cases
|
||
// ============================================================================
|
||
describe('F3 Boundary: Weight Table Bounds & Same-Group / Cross-Slot Deduplication', () => {
|
||
it('F3.B1: returns -1 for negative, out-of-bounds (>= 2048), non-integer, and unmapped cellNos', () => {
|
||
expect(getAutomapCellWeight(-1)).toBe(-1)
|
||
expect(getAutomapCellWeight(-999)).toBe(-1)
|
||
expect(getAutomapCellWeight(2048)).toBe(-1)
|
||
expect(getAutomapCellWeight(99999)).toBe(-1)
|
||
expect(getAutomapCellWeight(1.5)).toBe(-1)
|
||
expect(getAutomapCellWeight(NaN)).toBe(-1)
|
||
expect(getAutomapCellWeight(4)).toBe(-1)
|
||
expect(getAutomapCellWeight(5)).toBe(-1)
|
||
})
|
||
|
||
it('F3.B2: retains the first cell when two cells at identical (xPixel, yPixel) belong to the exact same weight group', () => {
|
||
// Cells 46 and 49 both belong to weight group 6
|
||
const deduped = deduplicateAutomapCells([
|
||
{ cellNo: 49, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 },
|
||
{ cellNo: 46, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 },
|
||
])
|
||
expect(deduped).toHaveLength(1)
|
||
expect(deduped[0]!.cellNo).toBe(49)
|
||
expect(deduped[0]!.weight).toBe(6)
|
||
})
|
||
|
||
it('F3.B3: does not deduplicate cells across different fSaved atlas slots (0..3) at identical (xPixel, yPixel)', () => {
|
||
const deduped = deduplicateAutomapCells([
|
||
{ cellNo: 0, xPixel: 160, yPixel: 80, cellX: 20, cellY: 0, fSaved: 0 },
|
||
{ cellNo: 0, xPixel: 160, yPixel: 80, cellX: 20, cellY: 0, fSaved: 1 },
|
||
{ cellNo: 0, xPixel: 160, yPixel: 80, cellX: 20, cellY: 0, fSaved: 2 },
|
||
{ cellNo: 0, xPixel: 160, yPixel: 80, cellX: 20, cellY: 0, fSaved: 3 },
|
||
])
|
||
expect(deduped).toHaveLength(4)
|
||
})
|
||
|
||
it('F3.B4: normalizes -0 coordinates to +0 during deduplication and coordinate computation', () => {
|
||
const deduped = deduplicateAutomapCells([
|
||
{ cellNo: 6, xPixel: -0, yPixel: -0, cellX: 0, cellY: 0 },
|
||
{ cellNo: 534, xPixel: 0, yPixel: 0, cellX: 0, cellY: 0 },
|
||
])
|
||
expect(deduped).toHaveLength(1)
|
||
expect(Object.is(deduped[0]!.xPixel, 0)).toBe(true)
|
||
expect(Object.is(deduped[0]!.yPixel, 0)).toBe(true)
|
||
expect(deduped[0]!.cellNo).toBe(534)
|
||
})
|
||
|
||
it('F3.B5: handles empty arrays and throws fail-fast error on non-array input to deduplicateAutomapCells', () => {
|
||
expect(deduplicateAutomapCells([])).toEqual([])
|
||
expect(() => deduplicateAutomapCells(null as unknown as AutomapCell[])).toThrow()
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F4: 1.13c Special Tile, Object & Monster Overrides — Boundary Cases
|
||
// ============================================================================
|
||
describe('F4 Boundary: Hidden Wall Bit 0, Multi-Tile seq != 0 Suppression & Conditional Gates', () => {
|
||
it('F4.B1: skips wall tiles with bit 0 (flags & 1) set (InvisWal), but does NOT skip floor tiles (tileType === 0) with bit 0', () => {
|
||
// Wall tile (tileType=1) with flags=1 (bit 0) -> -1
|
||
expect(
|
||
resolveTileAutomapCellId(automapTable, {
|
||
levelId: 1,
|
||
levelTypeId: 1,
|
||
tileType: 1,
|
||
style: 0,
|
||
sequence: 0,
|
||
flags: 1,
|
||
}),
|
||
).toBe(-1)
|
||
// Wall tile with flags=2 (bit 1 only, bit 0 clear) -> not suppressed by bit 0
|
||
expect(
|
||
resolveTileAutomapCellId(automapTable, {
|
||
levelId: 1,
|
||
levelTypeId: 1,
|
||
tileType: 1,
|
||
style: 0,
|
||
sequence: 0,
|
||
flags: 2,
|
||
}),
|
||
).toBe(61)
|
||
// Floor tile (tileType=0) with flags=1 -> not suppressed by wall bit 0 check
|
||
expect(
|
||
resolveTileAutomapCellId(automapTable, {
|
||
levelId: 2,
|
||
levelTypeId: 2,
|
||
tileType: 0,
|
||
style: 8,
|
||
sequence: 0,
|
||
flags: 1,
|
||
}),
|
||
).toBe(405)
|
||
})
|
||
|
||
it('F4.B2: suppresses multi-tile duplicate icons when sequence != 0 for Blood Moor Campfire and Palace Cellar stairs', () => {
|
||
for (const seq of [1, 2, 3, 7, 15]) {
|
||
expect(
|
||
resolveTileAutomapCellId(automapTable, {
|
||
levelId: 2,
|
||
levelTypeId: 2,
|
||
tileType: 0,
|
||
style: 8,
|
||
sequence: seq,
|
||
}),
|
||
).toBe(-1)
|
||
|
||
expect(
|
||
resolveTileAutomapCellId(automapTable, {
|
||
levelId: 40,
|
||
levelTypeId: 12,
|
||
tileType: 8,
|
||
style: 25,
|
||
sequence: seq,
|
||
}),
|
||
).toBe(-1)
|
||
}
|
||
})
|
||
|
||
it('F4.B3: suppresses closed Red Portal (objectClassId=60, mode=0 -> -1) in Level 4 Stony Field while allowing mode != 0 (-> 300)', () => {
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 60, autoMapCol: 306, levelId: 4, mode: 0 })).toBe(-1)
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 60, autoMapCol: 306, levelId: 4, mode: 1 })).toBe(300)
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 60, autoMapCol: 306, levelId: 4, mode: 2 })).toBe(300)
|
||
// Outside Level 4, Red Portal returns 306 even when mode === 0
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 60, autoMapCol: 0, levelId: 109, mode: 0 })).toBe(306)
|
||
})
|
||
|
||
it('F4.B4: suppresses Compelling Orb (366) when mode !== 2 and Arcane Waypoint (402) when levelId !== 74', () => {
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 366, autoMapCol: 308, mode: 0 })).toBe(-1)
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 366, autoMapCol: 308, mode: 1 })).toBe(-1)
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 366, autoMapCol: 308, mode: 2 })).toBe(308)
|
||
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 402, autoMapCol: 318, levelId: 1 })).toBe(-1)
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 402, autoMapCol: 318, levelId: 52 })).toBe(-1)
|
||
expect(resolveObjectAutomapCellId({ objectClassId: 402, autoMapCol: 318, levelId: 74 })).toBe(318)
|
||
})
|
||
|
||
it('F4.B5: suppresses dead monsters (isAlive=false) including Cain, and throws fail-fast on invalid override params', () => {
|
||
expect(
|
||
resolveMonsterAutomapCellId({
|
||
monsterClassId: 146, // Deckard Cain
|
||
automapCel: 267,
|
||
isAlive: false,
|
||
}),
|
||
).toBe(-1)
|
||
expect(
|
||
resolveMonsterAutomapCellId({
|
||
monsterClassId: 300,
|
||
automapCel: 0,
|
||
noMap: false,
|
||
isAlive: true,
|
||
}),
|
||
).toBe(-1)
|
||
|
||
expect(() => resolveObjectAutomapCellId({ objectClassId: -1, autoMapCol: 0 })).toThrow()
|
||
expect(() => resolveMonsterAutomapCellId({ monsterClassId: -1, automapCel: 0 })).toThrow()
|
||
expect(() =>
|
||
resolveTileAutomapCellId(automapTable, {
|
||
levelId: -1,
|
||
levelTypeId: 1,
|
||
tileType: 0,
|
||
style: 0,
|
||
sequence: 0,
|
||
}),
|
||
).toThrow()
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F5: Pre-Baked Town Base Map Chunk Generation — Boundary Cases
|
||
// ============================================================================
|
||
describe('F5 Boundary: Non-Chunk Towns, Custom Room Origins & Invalid Level IDs', () => {
|
||
it('F5.B1: returns empty array for Act 1 (levelId=1), Act 3 (levelId=75), and non-town levels', () => {
|
||
for (const id of [0, 1, 2, 39, 41, 74, 75, 102, 104, 108, 110, 136]) {
|
||
expect(generateTownSavedExtras(id)).toEqual([])
|
||
}
|
||
})
|
||
|
||
it('F5.B2: throws fail-fast error when generateTownSavedExtras receives negative or non-integer levelId', () => {
|
||
expect(() => generateTownSavedExtras(-1)).toThrow()
|
||
expect(() => generateTownSavedExtras(40.5)).toThrow()
|
||
expect(() => generateTownSavedExtras(NaN)).toThrow()
|
||
})
|
||
|
||
it('F5.B3: computes exact negative/zero-crossing coordinates for Act 2 (levelId=40) when roomOriginSubX == roomSubWidth', () => {
|
||
const cells = generateTownSavedExtras(40, 160, 0, 160)
|
||
expect(cells[0]).toMatchObject({ cellNo: 21, xPixel: 123, yPixel: 313, cellX: 46, cellY: 31 })
|
||
expect(cells[13]).toMatchObject({ cellNo: 38, xPixel: 443, yPixel: 613 })
|
||
})
|
||
|
||
it('F5.B4: computes exact coordinates for Act 4 (levelId=103) at origin (roomOriginSubX=45, roomOriginSubY=-20)', () => {
|
||
const cells = generateTownSavedExtras(103, 45, -20)
|
||
expect(cells[0]).toMatchObject({ cellNo: 0, xPixel: -53, yPixel: 96, fSaved: 2 })
|
||
expect(cells[3]).toMatchObject({ cellNo: 3, xPixel: 83, yPixel: 186, fSaved: 2 })
|
||
})
|
||
|
||
it('F5.B5: verifies all generated cellNo values for levels 40, 103, 109 are strictly within their sheet frameCount', () => {
|
||
for (const c of generateTownSavedExtras(40)) {
|
||
expect(c.cellNo).toBeGreaterThanOrEqual(0)
|
||
expect(c.cellNo).toBeLessThan(AUTOMAP_UI_MANIFEST.atlases.Act2Map.frameCount)
|
||
}
|
||
for (const c of generateTownSavedExtras(103)) {
|
||
expect(c.cellNo).toBeGreaterThanOrEqual(0)
|
||
expect(c.cellNo).toBeLessThan(AUTOMAP_UI_MANIFEST.atlases.Act4Map.frameCount)
|
||
}
|
||
for (const c of generateTownSavedExtras(109)) {
|
||
expect(c.cellNo).toBeGreaterThanOrEqual(0)
|
||
expect(c.cellNo).toBeLessThan(AUTOMAP_UI_MANIFEST.atlases.ExTnMap.frameCount)
|
||
}
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F6: Offline Packing of All 8 Automap DC6 Sprite Sheets — Boundary Cases
|
||
// ============================================================================
|
||
describe('F6 Boundary: First/Last Frame Bounds, Empty Frame Slots & Manifest Parity', () => {
|
||
it('F6.B1: verifies first (0) and last (frameCount - 1) frames exist in all 8 packed atlases', () => {
|
||
for (const key of Object.keys(AUTOMAP_UI_MANIFEST.atlases) as AutomapSpriteKey[]) {
|
||
const meta = AUTOMAP_UI_MANIFEST.atlases[key]
|
||
expect(meta.frames[0]).toBeDefined()
|
||
expect(meta.frames[meta.frameCount - 1]).toBeDefined()
|
||
expect(meta.frames[meta.frameCount]).toBeUndefined()
|
||
}
|
||
})
|
||
|
||
it('F6.B2: ensures public/assets/ui/automap/manifest.json and AUTOMAP_UI_MANIFEST are byte-for-byte consistent', () => {
|
||
const diskManifest = JSON.parse(
|
||
fs.readFileSync(path.join(ROOT_DIR, 'public/assets/ui/automap/manifest.json'), 'utf8'),
|
||
)
|
||
expect(diskManifest).toEqual(AUTOMAP_UI_MANIFEST)
|
||
})
|
||
|
||
it('F6.B3: verifies special override frames (267 Cain, 300 Red Portal, 305 Palace, 306/307 Portal, 318 WP, 319 Stash, 405 Campfire) have non-zero dimensions in MaxiMap & MaxiMapS', () => {
|
||
for (const cellNo of [267, 300, 305, 306, 307, 308, 309, 318, 319, 405]) {
|
||
const fullFrame = AUTOMAP_UI_MANIFEST.atlases.MaxiMap.frames[cellNo]!
|
||
const halfFrame = AUTOMAP_UI_MANIFEST.atlases.MaxiMapS.frames[cellNo]!
|
||
expect(fullFrame.width).toBeGreaterThan(0)
|
||
expect(fullFrame.height).toBeGreaterThan(0)
|
||
expect(halfFrame.width).toBeGreaterThan(0)
|
||
expect(halfFrame.height).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F6.B4: verifies Act2Map(S) frames 0..19, Act4Map(S) frames 0..3, and ExTnMap(S) frames 0..7 all have positive width & height', () => {
|
||
for (let i = 0; i < 20; i++) {
|
||
expect(AUTOMAP_UI_MANIFEST.atlases.Act2Map.frames[i]!.width).toBeGreaterThan(0)
|
||
expect(AUTOMAP_UI_MANIFEST.atlases.Act2MapS.frames[i]!.width).toBeGreaterThan(0)
|
||
}
|
||
for (let i = 0; i < 4; i++) {
|
||
expect(AUTOMAP_UI_MANIFEST.atlases.Act4Map.frames[i]!.width).toBeGreaterThan(0)
|
||
expect(AUTOMAP_UI_MANIFEST.atlases.Act4MapS.frames[i]!.width).toBeGreaterThan(0)
|
||
}
|
||
for (let i = 0; i < 8; i++) {
|
||
expect(AUTOMAP_UI_MANIFEST.atlases.ExTnMap.frames[i]!.width).toBeGreaterThan(0)
|
||
expect(AUTOMAP_UI_MANIFEST.atlases.ExTnMapS.frames[i]!.width).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F6.B5: verifies PNG binary headers (\x89PNG\r\n\x1a\n) for all 8 sheets in public/assets/ui/automap/ and public/ui/', () => {
|
||
const pngMagic = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||
for (const key of Object.keys(AUTOMAP_UI_MANIFEST.atlases) as AutomapSpriteKey[]) {
|
||
const buf = fs.readFileSync(path.join(ROOT_DIR, 'public/assets/ui/automap', `${key}.png`))
|
||
expect(new Uint8Array(buf.buffer, buf.byteOffset, 8)).toEqual(pngMagic)
|
||
}
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F7: Live & Offline 4-Layer Automap Generation — Boundary Cases
|
||
// ============================================================================
|
||
describe('F7 Boundary: Lower Wall tileType=15 vs 16 Boundary, Truncation & Act Resolution', () => {
|
||
it('F7.B1: applies +24 yPixel offset strictly at tileType >= 16 (tileType 15 -> +0, tileType 16..19 -> +24)', () => {
|
||
const t15 = computeAutomapTilePixelCoords(5, 5, 15)
|
||
const t16 = computeAutomapTilePixelCoords(5, 5, 16)
|
||
const t17 = computeAutomapTilePixelCoords(5, 5, 17)
|
||
const t18 = computeAutomapTilePixelCoords(5, 5, 18)
|
||
const t19 = computeAutomapTilePixelCoords(5, 5, 19)
|
||
expect(t15.yPixel).toBe(40)
|
||
expect(t16.yPixel).toBe(64)
|
||
expect(t17.yPixel).toBe(64)
|
||
expect(t18.yPixel).toBe(64)
|
||
expect(t19.yPixel).toBe(64)
|
||
})
|
||
|
||
it('F7.B2: truncates negative xClient/yClient toward zero (Math.trunc) in computeAutomapObjectPixelCoords', () => {
|
||
// subX = 1, subY = 4 -> xClient = -3 * 16 = -48 -> Math.trunc(-48 / 10) + 1 = -4 + 1 = -3
|
||
// yClient = 5 * 8 = 40 -> Math.trunc(40 / 10) - 3 = 4 - 3 = 1
|
||
expect(computeAutomapObjectPixelCoords(1, 4)).toEqual({ xPixel: -3, yPixel: 1 })
|
||
// subX = 0, subY = 0 -> xPixel = 1, yPixel = -3
|
||
expect(computeAutomapObjectPixelCoords(0, 0)).toEqual({ xPixel: 1, yPixel: -3 })
|
||
})
|
||
|
||
it('F7.B3: resolves act boundaries (1..39 -> Act 1, 40..74 -> Act 2, 75..102 -> Act 3, 103..108 -> Act 4, 109..136 -> Act 5)', () => {
|
||
expect(actOfLevel(1)).toBe(1)
|
||
expect(actOfLevel(39)).toBe(1)
|
||
expect(actOfLevel(40)).toBe(2)
|
||
expect(actOfLevel(74)).toBe(2)
|
||
expect(actOfLevel(75)).toBe(3)
|
||
expect(actOfLevel(102)).toBe(3)
|
||
expect(actOfLevel(103)).toBe(4)
|
||
expect(actOfLevel(108)).toBe(4)
|
||
expect(actOfLevel(109)).toBe(5)
|
||
expect(actOfLevel(136)).toBe(5)
|
||
})
|
||
|
||
it('F7.B4: excludes town levels (1, 40, 75, 103, 109) from hasOutdoorWaypoint', () => {
|
||
for (const townId of [1, 40, 75, 103, 109]) {
|
||
expect(hasOutdoorWaypoint(townId)).toBe(false)
|
||
}
|
||
expect(hasOutdoorWaypoint(3)).toBe(true)
|
||
expect(hasOutdoorWaypoint(42)).toBe(true)
|
||
expect(hasOutdoorWaypoint(79)).toBe(true)
|
||
expect(hasOutdoorWaypoint(106)).toBe(true)
|
||
expect(hasOutdoorWaypoint(111)).toBe(true)
|
||
})
|
||
|
||
it('F7.B5: throws fail-fast error when parseTable is called with empty Uint8Array', () => {
|
||
expect(() => parseTable(new Uint8Array(0))).toThrow()
|
||
expect(cell({ header: ['Id', 'Name'], rows: [['1', 'Town']] }, ['1', 'Town'], 'MissingCol')).toBe('')
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F8: 4-Layer Isometric Sprite Renderer — Boundary Cases
|
||
// ============================================================================
|
||
describe('F8 Boundary: Invalid Cell Skipping, Out-of-Frustum Culling & Fallback Synthesis', () => {
|
||
it('F8.B1: skips cells with cellNo < 0 or cellNo >= sheetMeta.frames.length without crashing', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const coords = computeAutomapTilePixelCoords(4, 4, 0)
|
||
const level: MinimapLevel = {
|
||
key: 'f8-b1-invalid-cellno',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
automap: {
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
floors: [
|
||
{ cellNo: -1, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 0 },
|
||
{ cellNo: 99999, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 0 },
|
||
{ cellNo: 5, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 0 },
|
||
],
|
||
walls: [],
|
||
objects: [],
|
||
extras: [],
|
||
},
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
minimap.draw(createNoopRenderer(), level, 4, 4, { x: 0, y: 320, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(minimap.spritesDrawnLastFrame.floors).toBe(1)
|
||
expect(minimap.spritesDrawnLastFrame.total).toBe(1)
|
||
})
|
||
|
||
it('F8.B2: frustum-culls cells projected outside the active viewport bounds', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'minimap' // 279x225 viewport
|
||
const near = computeAutomapTilePixelCoords(4, 4, 0)
|
||
const far = computeAutomapTilePixelCoords(200, 200, 0)
|
||
const level: MinimapLevel = {
|
||
key: 'f8-b2-frustum',
|
||
cellsX: 256,
|
||
cellsY: 256,
|
||
blocked: new Uint8Array(256 * 256 * 25),
|
||
gridWidth: 1280,
|
||
markers: [],
|
||
automap: {
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
floors: [],
|
||
walls: [],
|
||
objects: [],
|
||
extras: [
|
||
{ cellNo: 309, xPixel: near.xPixel, yPixel: near.yPixel, cellX: 4, cellY: 4, fSaved: 0 },
|
||
{ cellNo: 309, xPixel: far.xPixel, yPixel: far.yPixel, cellX: 200, cellY: 200, fSaved: 0 },
|
||
],
|
||
},
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
minimap.draw(createNoopRenderer(), level, 4, 4, { x: 0, y: 320, zoom: 1 }, { width: 800, height: 600 })
|
||
// Only the near extra is inside the 279x225 minimap box
|
||
expect(minimap.spritesDrawnLastFrame.extras).toBe(1)
|
||
})
|
||
|
||
it('F8.B3: synthesizes a fallback 4-layer AutomapLayerData when level.automap is omitted', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const blocked = new Uint8Array(10 * 10 * 25)
|
||
blocked[0] = 1
|
||
const level: MinimapLevel = {
|
||
key: 'f8-b3-synth',
|
||
cellsX: 10,
|
||
cellsY: 10,
|
||
blocked,
|
||
gridWidth: 50,
|
||
markers: [
|
||
{ cellX: 2, cellY: 2, kind: 'waypoint' },
|
||
{ cellX: 3, cellY: 3, kind: 'exit', targetName: 'Next Area' },
|
||
],
|
||
}
|
||
minimap.reveal(level, 2, 2, 0, 160, true)
|
||
minimap.draw(createNoopRenderer(), level, 2, 2, { x: 0, y: 160, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(minimap.lastRenderStats.cellCounts.total).toBe(100 + 2)
|
||
expect(minimap.spritesDrawnLastFrame.total).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('F8.B4: skips all rendering when minimap.visible === false while resetting frame draw stats to 0', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.visible = false
|
||
const level: MinimapLevel = {
|
||
key: 'f8-b4-hidden',
|
||
cellsX: 8,
|
||
cellsY: 8,
|
||
blocked: new Uint8Array(8 * 8 * 25),
|
||
gridWidth: 40,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 2, 2, 0, 160, true)
|
||
minimap.draw(createNoopRenderer(), level, 2, 2, { x: 0, y: 160, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(minimap.spritesDrawnLastFrame.total).toBe(0)
|
||
expect(minimap.crosshairsDrawnLastFrame.total).toBe(0)
|
||
expect(minimap.hudTextLastFrame).toBeNull()
|
||
})
|
||
|
||
it('F8.B5: handles negative cell coordinates in automapPixelToCellCoords and projectAutomapCellScreenRect', () => {
|
||
const negPx = computeAutomapTilePixelCoords(2, 10, 0) // xPixel = -64, yPixel = 48
|
||
expect(negPx.xPixel).toBe(-64)
|
||
expect(negPx.yPixel).toBe(48)
|
||
expect(automapPixelToCellCoords(negPx.xPixel, negPx.yPixel)).toEqual({ cellX: 2, cellY: 10 })
|
||
|
||
const rect = projectAutomapCellScreenRect(negPx, { width: 16, height: 16, offsetX: 0, offsetY: 0 }, 0, 0, true)
|
||
expect(rect.screenX).toBe(-32)
|
||
expect(rect.screenY).toBe(24)
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F9: Viewport, Panel Shift & Hotkeys — Boundary Cases
|
||
// ============================================================================
|
||
describe('F9 Boundary: Both Panels Open Cancellation, Small Screen Clamping & Mode Reset', () => {
|
||
it('F9.B1: cancels horizontal center shift (centerShiftX === 0) when BOTH left and right HUD panels are open', () => {
|
||
const vpBoth = computeAutomapViewport(800, 600, false, true, true, 0, 0)
|
||
expect(vpBoth.centerShiftX).toBe(0)
|
||
expect(vpBoth.centerX).toBe(400)
|
||
})
|
||
|
||
it('F9.B2: clamps viewport dimensions safely when screenWidth or screenHeight is very small (<= 48px)', () => {
|
||
const vpTinyFull = computeAutomapViewport(0, 20, false, false, false, 0, 0)
|
||
expect(vpTinyFull.width).toBeGreaterThanOrEqual(1)
|
||
expect(vpTinyFull.height).toBeGreaterThanOrEqual(1)
|
||
|
||
const vpTinyMini = computeAutomapViewport(100, 100, true, false, false, 0, 0)
|
||
expect(vpTinyMini.x).toBeGreaterThanOrEqual(0)
|
||
expect(vpTinyMini.width).toBe(279)
|
||
expect(vpTinyMini.height).toBe(225)
|
||
})
|
||
|
||
it('F9.B3: ignores arrow-key panning when in Corner Minimap mode (bMinimap=true) or when hidden (visible=false)', () => {
|
||
const minimap = new Minimap()
|
||
expect(minimap.bMinimap).toBe(true)
|
||
expect(minimap.pan(16, -16)).toBe(false)
|
||
expect(minimap.panX).toBe(0)
|
||
expect(minimap.panY).toBe(0)
|
||
|
||
minimap.mode = 'full'
|
||
minimap.visible = false
|
||
expect(minimap.pan(16, -16)).toBe(false)
|
||
expect(minimap.panX).toBe(0)
|
||
expect(minimap.panY).toBe(0)
|
||
})
|
||
|
||
it('F9.B4: automatically resets panX and panY to (0, 0) when switching from Full Automap back to Corner Minimap or hiding with Tab', () => {
|
||
const minimap = new Minimap()
|
||
minimap.mode = 'full'
|
||
minimap.panBySteps(5, -4)
|
||
expect(minimap.panX).toBe(40)
|
||
expect(minimap.panY).toBe(-32)
|
||
|
||
minimap.toggleMode() // back to minimap
|
||
expect(minimap.bMinimap).toBe(true)
|
||
expect(minimap.panX).toBe(0)
|
||
expect(minimap.panY).toBe(0)
|
||
|
||
minimap.mode = 'full'
|
||
minimap.panBySteps(3, 3)
|
||
minimap.toggleVisibility() // hide
|
||
expect(minimap.panX).toBe(0)
|
||
expect(minimap.panY).toBe(0)
|
||
})
|
||
|
||
it('F9.B5: wraps fadeState cleanly across multiple full cycles (0 -> 1 -> 2 -> 0 -> 1 -> 2 -> 0)', () => {
|
||
const minimap = new Minimap()
|
||
const sequence: number[] = []
|
||
for (let i = 0; i < 6; i++) {
|
||
sequence.push(minimap.cycleFade())
|
||
}
|
||
expect(sequence).toEqual([1, 2, 0, 1, 2, 0])
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F10: Room/Chunk Fog-of-War Exploration Reveal — Boundary Cases
|
||
// ============================================================================
|
||
describe('F10 Boundary: Octile Distance 159 vs 160 Threshold & Grid Edge Clamping', () => {
|
||
it('F10.B1: enforces exact octile distance boundary: 159 (< 160) does NOT reveal, 160 (>= 160) DOES reveal', () => {
|
||
// dx = 79, dy = 1 -> max(79,1)*2 + min(79,1) = 158 + 1 = 159
|
||
expect(computeAutomapRevealDistance(79, 1)).toBe(159)
|
||
expect(computeAutomapRevealDistance(79, 1) < AUTOMAP_REVEAL_DISTANCE_THRESHOLD).toBe(true)
|
||
// dx = 80, dy = 0 -> 80*2 + 0 = 160
|
||
expect(computeAutomapRevealDistance(80, 0)).toBe(160)
|
||
expect(computeAutomapRevealDistance(80, 0) >= AUTOMAP_REVEAL_DISTANCE_THRESHOLD).toBe(true)
|
||
|
||
const minimap = new Minimap()
|
||
const level: MinimapLevel = {
|
||
key: 'f10-b1-boundary',
|
||
cellsX: 32,
|
||
cellsY: 32,
|
||
blocked: new Uint8Array(32 * 32 * 25),
|
||
gridWidth: 160,
|
||
markers: [],
|
||
}
|
||
expect(minimap.reveal(level, 4, 4, 100, 100)).toBe(true)
|
||
// Move by (79, 1) -> octile = 159 -> skipped!
|
||
expect(minimap.reveal(level, 5, 4, 179, 101)).toBe(false)
|
||
// Move by (80, 0) from (100, 100) -> octile = 160 -> reveals!
|
||
expect(minimap.reveal(level, 5, 4, 180, 100)).toBe(true)
|
||
})
|
||
|
||
it('F10.B2: handles negative movement deltas symmetrically in computeAutomapRevealDistance', () => {
|
||
expect(computeAutomapRevealDistance(-80, 0)).toBe(160)
|
||
expect(computeAutomapRevealDistance(0, -80)).toBe(160)
|
||
expect(computeAutomapRevealDistance(-60, -40)).toBe(160)
|
||
expect(computeAutomapRevealDistance(-79, -1)).toBe(159)
|
||
})
|
||
|
||
it('F10.B3: clamps 3x3 room neighborhood at top-left corner room (0, 0) to 2x2 valid rooms (0..1, 0..1)', () => {
|
||
const minimap = new Minimap()
|
||
const level: MinimapLevel = {
|
||
key: 'f10-b3-corner',
|
||
cellsX: 32,
|
||
cellsY: 32,
|
||
blocked: new Uint8Array(32 * 32 * 25),
|
||
gridWidth: 160,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 0, 0, 0, 0, true)
|
||
expect(minimap.revealedRoomCount(level.key)).toBe(4)
|
||
expect(minimap.isRoomRevealed(level.key, 0, 0)).toBe(true)
|
||
expect(minimap.isRoomRevealed(level.key, 1, 0)).toBe(true)
|
||
expect(minimap.isRoomRevealed(level.key, 0, 1)).toBe(true)
|
||
expect(minimap.isRoomRevealed(level.key, 1, 1)).toBe(true)
|
||
expect(minimap.isRoomRevealed(level.key, -1, 0)).toBe(false)
|
||
})
|
||
|
||
it('F10.B4: returns false for out-of-bounds cell coordinates in isCellExplored', () => {
|
||
const minimap = new Minimap()
|
||
const level: MinimapLevel = {
|
||
key: 'f10-b4-oob',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 8, 8, 640, 640, true)
|
||
expect(minimap.isCellExplored(level, -1, 8)).toBe(false)
|
||
expect(minimap.isCellExplored(level, 8, -1)).toBe(false)
|
||
expect(minimap.isCellExplored(level, 16, 8)).toBe(false)
|
||
expect(minimap.isCellExplored(level, 8, 16)).toBe(false)
|
||
})
|
||
|
||
it('F10.B5: returns 0 exploredFraction on an unrevealed or 0x0 empty level without division by zero', () => {
|
||
const minimap = new Minimap()
|
||
const emptyLevel: MinimapLevel = {
|
||
key: 'f10-b5-empty',
|
||
cellsX: 0,
|
||
cellsY: 0,
|
||
blocked: new Uint8Array(0),
|
||
gridWidth: 0,
|
||
markers: [],
|
||
}
|
||
expect(minimap.exploredFraction(emptyLevel)).toBe(0)
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F11: 12-Segment Crosshair & Dynamic Portals — Boundary Cases
|
||
// ============================================================================
|
||
describe('F11 Boundary: Dead Entities, Inactive Portals, Iron Golem Color & Offscreen Culling', () => {
|
||
it('F11.B1: omits dead entities (alive: false) from crosshair and label rendering', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'f11-b1-dead',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
minimap.draw(
|
||
createNoopRenderer(),
|
||
level,
|
||
4,
|
||
4,
|
||
{ x: 0, y: 320, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 320,
|
||
entities: [
|
||
{ x: 0, y: 320, kind: 'boss', name: 'DeadBoss', alive: false },
|
||
{ x: 0, y: 320, kind: 'npc', name: 'DeadNpc', alive: false },
|
||
],
|
||
},
|
||
)
|
||
expect(minimap.crosshairsDrawnLastFrame.bosses).toBe(0)
|
||
expect(minimap.crosshairsDrawnLastFrame.npcs).toBe(0)
|
||
expect(minimap.crosshairsDrawnLastFrame.player).toBe(1)
|
||
expect(minimap.lastRenderStats.entityLabels).toHaveLength(0)
|
||
})
|
||
|
||
it('F11.B2: omits inactive portals (active: false) from portal sprite and label rendering', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'f11-b2-inactive-portal',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
automap: { layerId: 1, levelTypeId: 1, floors: [], walls: [], objects: [], extras: [] },
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
minimap.draw(
|
||
createNoopRenderer(),
|
||
level,
|
||
4,
|
||
4,
|
||
{ x: 0, y: 320, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 320,
|
||
portals: [{ x: 0, y: 320, active: false, label: 'Closed Portal' }],
|
||
},
|
||
)
|
||
expect(minimap.spritesDrawnLastFrame.portals).toBe(0)
|
||
expect(minimap.warpLabelsLastFrame).toHaveLength(0)
|
||
})
|
||
|
||
it('F11.B3: renders Iron Golem (classId=291) and Valkyrie (classId=357) with white 0x1F (244/255) crosshair color', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const colors: (readonly [number, number, number, number])[] = []
|
||
const renderer = {
|
||
flush() {},
|
||
draw() {},
|
||
drawSolid() {},
|
||
drawSolidQuad(
|
||
_x0: number,
|
||
_y0: number,
|
||
_x1: number,
|
||
_y1: number,
|
||
_x2: number,
|
||
_y2: number,
|
||
_x3: number,
|
||
_y3: number,
|
||
color: readonly [number, number, number, number],
|
||
) {
|
||
colors.push(color)
|
||
},
|
||
} as unknown as SpriteRenderer
|
||
|
||
const level: MinimapLevel = {
|
||
key: 'f11-b3-golem',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
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: 'summon', classId: 291 }],
|
||
},
|
||
)
|
||
expect(colors[0]?.[0]).toBeCloseTo(244 / 255, 4)
|
||
expect(colors[0]?.[1]).toBeCloseTo(244 / 255, 4)
|
||
expect(colors[0]?.[2]).toBeCloseTo(244 / 255, 4)
|
||
})
|
||
|
||
it('F11.B4: falls back to drawSolid when renderer does not implement drawSolidQuad', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
let solidCalls = 0
|
||
const fallbackRenderer = {
|
||
flush() {},
|
||
draw() {},
|
||
drawSolid() {
|
||
solidCalls++
|
||
},
|
||
} as unknown as SpriteRenderer
|
||
const level: MinimapLevel = {
|
||
key: 'f11-b4-solid-fallback',
|
||
cellsX: 8,
|
||
cellsY: 8,
|
||
blocked: new Uint8Array(8 * 8 * 25),
|
||
gridWidth: 40,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 2, 2, 0, 160, true)
|
||
minimap.draw(fallbackRenderer, level, 2, 2, { x: 0, y: 160, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(solidCalls).toBe(12)
|
||
})
|
||
|
||
it('F11.B5: culls entity and player crosshairs when panned completely outside the Full Automap viewport', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
minimap.pan(5000, 5000)
|
||
const level: MinimapLevel = {
|
||
key: 'f11-b5-offscreen-crosshair',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
minimap.draw(
|
||
createNoopRenderer(),
|
||
level,
|
||
4,
|
||
4,
|
||
{ x: 0, y: 320, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 320,
|
||
entities: [{ x: 0, y: 320, kind: 'boss', name: 'Boss' }],
|
||
},
|
||
)
|
||
expect(minimap.crosshairsDrawnLastFrame.player).toBe(0)
|
||
expect(minimap.crosshairsDrawnLastFrame.bosses).toBe(0)
|
||
expect(minimap.crosshairsDrawnLastFrame.total).toBe(0)
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F12: Entrance/Warp Labels & Top-Right Gold Font16 HUD Text — Boundary Cases
|
||
// ============================================================================
|
||
describe('F12 Boundary: Unrevealed Warp Suppression, Boss Name Suppression & Empty Level Name', () => {
|
||
it('F12.B1: suppresses entrance/warp labels for markers located in unrevealed rooms', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'f12-b1-unrevealed-warp',
|
||
cellsX: 32,
|
||
cellsY: 32,
|
||
blocked: new Uint8Array(32 * 32 * 25),
|
||
gridWidth: 160,
|
||
markers: [{ cellX: 28, cellY: 28, kind: 'exit', targetName: 'Secret Exit' }],
|
||
}
|
||
// Reveal around (2, 2); (28, 28) remains unrevealed
|
||
minimap.reveal(level, 2, 2, 0, 160, true)
|
||
minimap.draw(createNoopRenderer(), level, 2, 2, { x: 0, y: 160, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(minimap.warpLabelsLastFrame).toHaveLength(0)
|
||
})
|
||
|
||
it('F12.B2: does NOT emit Font6 entity name labels for hostile bosses or mercenaries (only party & npc)', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'f12-b2-no-boss-label',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
minimap.draw(
|
||
createNoopRenderer(),
|
||
level,
|
||
4,
|
||
4,
|
||
{ x: 0, y: 320, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 320,
|
||
entities: [
|
||
{ x: 0, y: 320, kind: 'boss', name: 'Andariel' },
|
||
{ x: 0, y: 320, kind: 'mercenary', name: 'Emilio' },
|
||
],
|
||
},
|
||
)
|
||
expect(minimap.lastRenderStats.entityLabels).toHaveLength(0)
|
||
})
|
||
|
||
it('F12.B3: sets hudTextLastFrame to null when levelName is empty string', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'f12-b3-empty-title',
|
||
cellsX: 8,
|
||
cellsY: 8,
|
||
blocked: new Uint8Array(8 * 8 * 25),
|
||
gridWidth: 40,
|
||
levelName: '',
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 2, 2, 0, 160, true)
|
||
minimap.draw(createNoopRenderer(), level, 2, 2, { x: 0, y: 160, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(minimap.hudTextLastFrame).toBeNull()
|
||
})
|
||
|
||
it('F12.B4: defaults stash label to "Stash" when stash.label and stash.name are omitted', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'f12-b4-default-stash',
|
||
cellsX: 8,
|
||
cellsY: 8,
|
||
blocked: new Uint8Array(8 * 8 * 25),
|
||
gridWidth: 40,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 2, 2, 0, 160, true)
|
||
minimap.draw(
|
||
createNoopRenderer(),
|
||
level,
|
||
2,
|
||
2,
|
||
{ x: 0, y: 160, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 160,
|
||
stashes: [{ x: 0, y: 160 }],
|
||
},
|
||
)
|
||
expect(minimap.warpLabelsLastFrame[0]?.text).toBe('Stash')
|
||
})
|
||
|
||
it('F12.B5: does not invoke font.drawText in drawHudOverlay when minimap.visible === false', () => {
|
||
const minimap = new Minimap()
|
||
minimap.visible = false
|
||
let textCount = 0
|
||
const mockFont = {
|
||
drawText() {
|
||
textCount++
|
||
},
|
||
} as unknown as D2FontRenderer
|
||
minimap.drawHudOverlay({} as CanvasRenderingContext2D, mockFont, 800, 600)
|
||
expect(textCount).toBe(0)
|
||
})
|
||
})
|
||
|
||
// ============================================================================
|
||
// F13: Full-Spectrum 136-Level Verification — Boundary & Special Levels
|
||
// ============================================================================
|
||
describe('F13 Boundary: Special Level IDs (Level 1, 38 Tristram, 39 Cow, 74 Arcane, 133..136 Uber/Baal)', () => {
|
||
it('F13.B1: verifies boundary LevelId=1 (Rogue Encampment) and LevelId=136 (Uber Tristram) have valid baked automaps', () => {
|
||
for (const levelId of [1, 136]) {
|
||
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 }
|
||
expect(
|
||
scene.automap.floors.length +
|
||
scene.automap.walls.length +
|
||
scene.automap.objects.length +
|
||
scene.automap.extras.length,
|
||
).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F13.B2: verifies special Act 1 levels Tristram (LevelId=38) and Moo Moo Farm (LevelId=39)', () => {
|
||
for (const levelId of [38, 39]) {
|
||
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 }
|
||
expect(scene.automap.floors.length + scene.automap.walls.length).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F13.B3: verifies Arcane Sanctuary (LevelId=74) variants and Arcane Waypoint icon (cellNo=318)', () => {
|
||
const entries74 = packIndex.levels.filter(l => l.levelId === 74)
|
||
expect(entries74.length).toBeGreaterThan(0)
|
||
let totalCells74 = 0
|
||
for (const entry of entries74) {
|
||
const scene = JSON.parse(
|
||
fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8'),
|
||
) as { automap: AutomapLayerData }
|
||
totalCells74 +=
|
||
scene.automap.floors.length +
|
||
scene.automap.walls.length +
|
||
scene.automap.objects.length +
|
||
scene.automap.extras.length
|
||
}
|
||
expect(totalCells74).toBeGreaterThan(0)
|
||
|
||
const emptyDs1 = {
|
||
version: 18,
|
||
width: 16,
|
||
height: 16,
|
||
act: 2,
|
||
substitutionType: 0,
|
||
files: [],
|
||
objects: [],
|
||
cells: [],
|
||
wallLayers: 1,
|
||
floorLayers: 1,
|
||
shadowLayers: 1,
|
||
substitutionLayers: 0,
|
||
npcPaths: [],
|
||
} as unknown as Ds1
|
||
const arcaneAutomap = buildLevelAutomap(emptyDs1, {
|
||
automapTable,
|
||
levelId: 74,
|
||
layerId: 74,
|
||
levelTypeId: 23,
|
||
act: 2,
|
||
waypoints: [{ x: 10, y: 10 }],
|
||
})
|
||
expect(arcaneAutomap.objects.some(c => c.cellNo === 318)).toBe(true)
|
||
})
|
||
|
||
it('F13.B4: verifies Act 5 Pandemonium Event / Uber levels (Matron Den 133, Forgotten Sands 134, Furnace of Pain 135, Tristram 136)', () => {
|
||
for (const levelId of [133, 134, 135, 136]) {
|
||
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 }
|
||
expect(
|
||
scene.automap.floors.length +
|
||
scene.automap.walls.length +
|
||
scene.automap.objects.length +
|
||
scene.automap.extras.length,
|
||
).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F13.B5: verifies all 365 scene variants across all 136 levels have zero negative cellNo or NaN pixel coordinates', () => {
|
||
for (const entry of packIndex.levels) {
|
||
const scene = JSON.parse(
|
||
fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8'),
|
||
) as { automap: AutomapLayerData }
|
||
for (const c of [
|
||
...scene.automap.floors,
|
||
...scene.automap.walls,
|
||
...scene.automap.objects,
|
||
...scene.automap.extras,
|
||
]) {
|
||
expect(c.cellNo).toBeGreaterThanOrEqual(0)
|
||
expect(Number.isFinite(c.xPixel)).toBe(true)
|
||
expect(Number.isFinite(c.yPixel)).toBe(true)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
})
|