1345 lines
48 KiB
TypeScript
1345 lines
48 KiB
TypeScript
/**
|
||
* @fileoverview Tier 5 — White-Box Adversarial & Differential Stress Tests (1.13c Automap & Minimap).
|
||
*
|
||
* Authored by `teamwork_preview_challenger_r1_1` following `test_coverage_audit` (Whitebox mode)
|
||
* and `solution_stress_testing` (Differential Oracle & Boundary/Permutation Fuzzing).
|
||
*
|
||
* Every test is prefixed with `adv_` to distinguish Tier 5 white-box adversarial tests from Tiers 1–4.
|
||
*
|
||
* White-box targets audited & stress-tested:
|
||
* - `src/formats/automap-table.ts` (lines 1–896)
|
||
* - `src/game/d2map.ts` (`buildLevelAutomap`, lines 587–833)
|
||
* - `src/ui/minimap.ts` (`Minimap`, lines 1–1452)
|
||
* - `src/ui/hud-manager.ts` (`HUDManager`, lines 1–1449)
|
||
*/
|
||
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||
import { MpqArchive, memorySource } from '../../src/mpq/index.ts'
|
||
import type { Ds1 } from '../../src/formats/ds1.ts'
|
||
import {
|
||
AUTOMAP_CELL_WEIGHT_PAIRS,
|
||
AUTOMAP_LEVEL_NAMES,
|
||
AUTOMAP_TILE_NAMES,
|
||
type AutomapAtlasSlot,
|
||
type AutomapCell,
|
||
type AutomapTable,
|
||
computeAutomapObjectPixelCoords,
|
||
computeAutomapTilePixelCoords,
|
||
deduplicateAutomapCells,
|
||
generateTownSavedExtras,
|
||
getAutomapCellId,
|
||
getAutomapCellWeight,
|
||
getAutomapLevelTypeId,
|
||
getAutomapTileType,
|
||
getCrosshairSegments,
|
||
parseAutomapTable,
|
||
resolveMonsterAutomapCellId,
|
||
resolveObjectAutomapCellId,
|
||
resolveTileAutomapCellId,
|
||
} from '../../src/formats/automap-table.ts'
|
||
import { cell, loadActTables, parseTable } from '../../src/game/acts.ts'
|
||
import { buildLevelAutomap } from '../../src/game/d2map.ts'
|
||
import {
|
||
loadObjectsTable,
|
||
type MonstersTable,
|
||
type ObjectsTable,
|
||
} from '../../src/game/objects.ts'
|
||
import { MountedArchives } from '../../src/mpq/mount.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 { D2ColorCode, D2FontRenderer } from '../../src/ui/font.ts'
|
||
import {
|
||
AUTOMAP_CROSSHAIR_COLORS,
|
||
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 MPQ_DATA_PATH = path.resolve(ROOT_DIR, 'samples/d2/d2data.mpq')
|
||
const MPQ_PATCH_PATH = path.resolve(ROOT_DIR, 'samples/d2/Patch_D2.mpq')
|
||
|
||
let expArchive: MpqArchive
|
||
let dataArchive: MpqArchive
|
||
let rawAutomapBytes: Uint8Array
|
||
let rawAutomapText: string
|
||
let automapTable: AutomapTable
|
||
let objectsTable: ObjectsTable
|
||
let monstersTable: MonstersTable
|
||
let monstats2ById: Map<string, { automapCel: number; noMap: boolean }>
|
||
|
||
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 patchArchive = await MpqArchive.open(memorySource(new Uint8Array(fs.readFileSync(MPQ_PATCH_PATH))))
|
||
|
||
const archives = new MountedArchives()
|
||
archives.add('d2data.mpq', dataArchive)
|
||
archives.add('d2exp.mpq', expArchive)
|
||
archives.add('Patch_D2.mpq', patchArchive)
|
||
|
||
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)
|
||
rawAutomapText = Buffer.from(rawAutomapBytes).toString('latin1')
|
||
automapTable = parseAutomapTable(rawAutomapBytes)
|
||
|
||
objectsTable = await loadObjectsTable(archives)
|
||
const actTables = await loadActTables(archives)
|
||
|
||
const presetPlaceByAct = new Map<string, string[]>()
|
||
for (const row of actTables.monpreset.rows) {
|
||
const actStr = cell(actTables.monpreset, row, 'Act')
|
||
const placeStr = cell(actTables.monpreset, row, 'Place')
|
||
if (!placeStr) continue
|
||
let list = presetPlaceByAct.get(actStr)
|
||
if (!list) {
|
||
list = []
|
||
presetPlaceByAct.set(actStr, list)
|
||
}
|
||
list.push(placeStr)
|
||
}
|
||
const statsById = new Map<string, readonly string[]>()
|
||
for (const row of actTables.monstats.rows) {
|
||
const id = cell(actTables.monstats, row, 'Id')
|
||
if (id) statsById.set(id, row)
|
||
}
|
||
monstersTable = {
|
||
preset: actTables.monpreset,
|
||
stats: actTables.monstats,
|
||
presetPlaceByAct,
|
||
statsById,
|
||
}
|
||
|
||
const monstats2Table = parseTable(await archives.read('data\\global\\excel\\monstats2.txt'))
|
||
monstats2ById = new Map()
|
||
for (const row of monstats2Table.rows) {
|
||
const id = cell(monstats2Table, row, 'Id')
|
||
if (id) {
|
||
monstats2ById.set(id.toLowerCase(), {
|
||
noMap: Number(cell(monstats2Table, row, 'noMap')) !== 0,
|
||
automapCel: Number(cell(monstats2Table, row, 'automapCel')) || 0,
|
||
})
|
||
}
|
||
}
|
||
})
|
||
|
||
/**
|
||
* Brute-force reference oracle for `deduplicateAutomapCells` (`0x6fba1528` & `0x6fb11320`).
|
||
*/
|
||
function oracleDeduplicateAutomapCells(cells: readonly AutomapCell[]): AutomapCell[] {
|
||
const referenceWeights = new Map<number, number>(AUTOMAP_CELL_WEIGHT_PAIRS)
|
||
const isSpecialIcon = (cellNo: number): boolean =>
|
||
(cellNo >= 300 && cellNo <= 319) || cellNo === 405
|
||
const normZero = (n: number): number => (n === 0 ? 0 : n)
|
||
|
||
const byCoord = new Map<string, AutomapCell>()
|
||
for (const raw of cells) {
|
||
const weight =
|
||
raw.weight !== undefined
|
||
? raw.weight
|
||
: (referenceWeights.get(raw.cellNo) ?? -1)
|
||
const normalized: AutomapCell = {
|
||
...raw,
|
||
xPixel: normZero(raw.xPixel),
|
||
yPixel: normZero(raw.yPixel),
|
||
weight,
|
||
}
|
||
const key = `${String(normalized.fSaved ?? 0)}:${String(normalized.xPixel)}:${String(normalized.yPixel)}`
|
||
const existing = byCoord.get(key)
|
||
if (!existing) {
|
||
byCoord.set(key, normalized)
|
||
continue
|
||
}
|
||
const existWeight =
|
||
existing.weight !== undefined
|
||
? existing.weight
|
||
: (referenceWeights.get(existing.cellNo) ?? -1)
|
||
if (weight > existWeight) {
|
||
byCoord.set(key, normalized)
|
||
} else if (weight === existWeight && weight === -1) {
|
||
if (isSpecialIcon(normalized.cellNo) && !isSpecialIcon(existing.cellNo)) {
|
||
byCoord.set(key, normalized)
|
||
}
|
||
}
|
||
}
|
||
|
||
return [...byCoord.values()].sort(
|
||
(a, b) => a.yPixel - b.yPixel || a.xPixel - b.xPixel || a.cellNo - b.cellNo,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Deterministic PRNG (Mulberry32) for reproducible adversarial fuzzing.
|
||
*/
|
||
function createSeededRng(seed: number): () => number {
|
||
let state = seed >>> 0
|
||
return () => {
|
||
state = (state + 0x6d2b79f5) >>> 0
|
||
let t = Math.imul(state ^ (state >>> 15), 1 | state)
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||
}
|
||
}
|
||
|
||
function shuffleInPlace<T>(arr: T[], rng: () => number): T[] {
|
||
for (let i = arr.length - 1; i > 0; i--) {
|
||
const j = Math.floor(rng() * (i + 1))
|
||
const tmp = arr[i]!
|
||
arr[i] = arr[j]!
|
||
arr[j] = tmp
|
||
}
|
||
return arr
|
||
}
|
||
|
||
function createRecordingRenderer(): {
|
||
renderer: SpriteRenderer
|
||
sprites: Array<{
|
||
frame: AtlasFrame
|
||
x: number
|
||
y: number
|
||
atlas?: AtlasHandle
|
||
width?: number
|
||
height?: number
|
||
tint?: readonly [number, number, number, number]
|
||
}>
|
||
quads: Array<{
|
||
x0: number
|
||
y0: number
|
||
x1: number
|
||
y1: number
|
||
x2: number
|
||
y2: number
|
||
x3: number
|
||
y3: number
|
||
color: readonly [number, number, number, number]
|
||
}>
|
||
scissors: Array<{ x: number; y: number; width: number; height: number } | null>
|
||
glCalls: { enableCount: number; disableCount: number }
|
||
} {
|
||
const sprites: Array<{
|
||
frame: AtlasFrame
|
||
x: number
|
||
y: number
|
||
atlas?: AtlasHandle
|
||
width?: number
|
||
height?: number
|
||
tint?: readonly [number, number, number, number]
|
||
}> = []
|
||
const quads: Array<{
|
||
x0: number
|
||
y0: number
|
||
x1: number
|
||
y1: number
|
||
x2: number
|
||
y2: number
|
||
x3: number
|
||
y3: number
|
||
color: readonly [number, number, number, number]
|
||
}> = []
|
||
const scissors: Array<{ x: number; y: number; width: number; height: number } | null> = []
|
||
const glCalls = { enableCount: 0, disableCount: 0 }
|
||
|
||
const mockGl = {
|
||
SCISSOR_TEST: 0x0c11,
|
||
enable(cap: number) {
|
||
if (cap === 0x0c11) glCalls.enableCount++
|
||
},
|
||
scissor(x: number, y: number, width: number, height: number) {
|
||
scissors.push({ x, y, width, height })
|
||
},
|
||
disable(cap: number) {
|
||
if (cap === 0x0c11) {
|
||
glCalls.disableCount++
|
||
scissors.push(null)
|
||
}
|
||
},
|
||
}
|
||
|
||
const renderer = {
|
||
gl: mockGl,
|
||
flush: vi.fn(),
|
||
addAtlas: vi.fn((_img: unknown, width: number, height: number) => ({
|
||
texture: 'mock-atlas-tex' as unknown as WebGLTexture,
|
||
width,
|
||
height,
|
||
})),
|
||
draw(
|
||
frame: AtlasFrame,
|
||
x: number,
|
||
y: number,
|
||
options?: {
|
||
atlas?: AtlasHandle
|
||
width?: number
|
||
height?: number
|
||
tint?: readonly [number, number, number, number]
|
||
},
|
||
) {
|
||
sprites.push({
|
||
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: vi.fn(),
|
||
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({ x0, y0, x1, y1, x2, y2, x3, y3, color })
|
||
},
|
||
} as unknown as SpriteRenderer
|
||
|
||
return { renderer, sprites, quads, scissors, glCalls }
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
describe('Tier 5 — White-Box Adversarial & Differential Stress Coverage (1.13c Automap)', () => {
|
||
// =========================================================================
|
||
// Suite 1: F3 — Exhaustive 49-Entry 0x6fba1528 Weight Table & Differential Oracle
|
||
// =========================================================================
|
||
describe('Suite 1: F3 — 0x6fba1528 Weight Table & Differential Permutation Oracle', () => {
|
||
it('adv_f3_all_49_weight_pairs_exact_group_mapping_and_2048_bounds', () => {
|
||
expect(AUTOMAP_CELL_WEIGHT_PAIRS).toHaveLength(49)
|
||
|
||
const expectedMap = new Map<number, number>()
|
||
const seenGroups = new Set<number>()
|
||
for (const [cellId, groupId] of AUTOMAP_CELL_WEIGHT_PAIRS) {
|
||
expect(expectedMap.has(cellId)).toBe(false)
|
||
expectedMap.set(cellId, groupId)
|
||
seenGroups.add(groupId)
|
||
expect(getAutomapCellWeight(cellId)).toBe(groupId)
|
||
}
|
||
|
||
// Exactly 18 groups 0..17
|
||
expect(seenGroups.size).toBe(18)
|
||
for (let g = 0; g <= 17; g++) {
|
||
expect(seenGroups.has(g)).toBe(true)
|
||
}
|
||
|
||
// Every non-listed cellId across [-50, 2100] and non-integers must return -1
|
||
for (let id = -50; id <= 2100; id++) {
|
||
if (!expectedMap.has(id)) {
|
||
expect(getAutomapCellWeight(id)).toBe(-1)
|
||
}
|
||
}
|
||
expect(getAutomapCellWeight(1.5)).toBe(-1)
|
||
expect(getAutomapCellWeight(Number.NaN)).toBe(-1)
|
||
expect(getAutomapCellWeight(Number.POSITIVE_INFINITY)).toBe(-1)
|
||
})
|
||
|
||
it('adv_f3_differential_oracle_18_groups_adversarial_permutations', () => {
|
||
const rng = createSeededRng(0x113c_cafe)
|
||
|
||
// Pool containing all 49 weighted cells, 10 unweighted cells, and 6 special override icons
|
||
const pool: number[] = [
|
||
...AUTOMAP_CELL_WEIGHT_PAIRS.map(([cellId]) => cellId),
|
||
4, 5, 50, 77, 88, 99, 150, 200, 250, 299, // unweighted (-1)
|
||
300, 304, 306, 307, 318, 405, // special override icons
|
||
]
|
||
|
||
// Run 50 adversarial trials with random subsets, coordinates, fSaved slots, and permutations
|
||
for (let trial = 0; trial < 50; trial++) {
|
||
const input: AutomapCell[] = []
|
||
const coords = [
|
||
{ xPixel: 80, yPixel: 40, cellX: 10, cellY: 5 },
|
||
{ xPixel: -16, yPixel: 32, cellX: 1, cellY: 3 },
|
||
{ xPixel: -0, yPixel: -0, cellX: 0, cellY: 0 }, // tests -0 normalization
|
||
]
|
||
|
||
const shuffledPool = shuffleInPlace([...pool, ...pool.slice(0, 15)], rng)
|
||
for (let i = 0; i < shuffledPool.length; i++) {
|
||
const cellNo = shuffledPool[i]!
|
||
const c = coords[i % coords.length]!
|
||
const fSaved = (i % 4) as AutomapAtlasSlot
|
||
input.push({
|
||
cellNo,
|
||
xPixel: c.xPixel,
|
||
yPixel: c.yPixel,
|
||
cellX: c.cellX,
|
||
cellY: c.cellY,
|
||
fSaved,
|
||
})
|
||
}
|
||
|
||
const actual = deduplicateAutomapCells(input)
|
||
const expected = oracleDeduplicateAutomapCells(input)
|
||
|
||
expect(actual).toEqual(expected)
|
||
}
|
||
})
|
||
|
||
it('adv_f3_special_override_icons_vs_unweighted_and_weighted_matrix', () => {
|
||
const specialIcons = [
|
||
...Array.from({ length: 20 }, (_, i) => 300 + i),
|
||
405,
|
||
]
|
||
|
||
for (const specialCel of specialIcons) {
|
||
// Case A: unweighted tile (cellNo=50, weight=-1) first, then special icon -> special icon wins!
|
||
const caseA = deduplicateAutomapCells([
|
||
{ cellNo: 50, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 },
|
||
{ cellNo: specialCel, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 },
|
||
])
|
||
expect(caseA).toHaveLength(1)
|
||
expect(caseA[0]!.cellNo).toBe(specialCel)
|
||
|
||
// Case B: special icon first, then unweighted tile (cellNo=50) -> special icon is NOT overwritten!
|
||
const caseB = deduplicateAutomapCells([
|
||
{ cellNo: specialCel, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 },
|
||
{ cellNo: 50, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 },
|
||
])
|
||
expect(caseB).toHaveLength(1)
|
||
expect(caseB[0]!.cellNo).toBe(specialCel)
|
||
|
||
// Case C: two special icons at the same coordinate -> first special icon is preserved
|
||
const otherSpecial = specialCel === 300 ? 405 : 300
|
||
const caseC = deduplicateAutomapCells([
|
||
{ cellNo: specialCel, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 },
|
||
{ cellNo: otherSpecial, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 },
|
||
])
|
||
expect(caseC).toHaveLength(1)
|
||
expect(caseC[0]!.cellNo).toBe(specialCel)
|
||
}
|
||
})
|
||
|
||
it('adv_f3_explicit_cell_weight_override_precedence_and_fail_fast', () => {
|
||
// cellNo 50 (explicit weight 2) vs cellNo 60 (explicit weight 9) -> weight 9 wins
|
||
const result = deduplicateAutomapCells([
|
||
{ cellNo: 50, xPixel: 0, yPixel: 0, cellX: 0, cellY: 0, weight: 2 },
|
||
{ cellNo: 60, xPixel: 0, yPixel: 0, cellX: 0, cellY: 0, weight: 9 },
|
||
{ cellNo: 70, xPixel: 0, yPixel: 0, cellX: 0, cellY: 0, weight: 5 },
|
||
])
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0]!.cellNo).toBe(60)
|
||
expect(result[0]!.weight).toBe(9)
|
||
|
||
// Fail-fast on non-array input
|
||
expect(() => deduplicateAutomapCells(null as unknown as AutomapCell[])).toThrow(
|
||
/expected array of AutomapCell/,
|
||
)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// Suite 2: F2 — parseAutomapTable, decodeLatin1, Frame Bounds & SplitMix64
|
||
// =========================================================================
|
||
describe('Suite 2: F2 — parseAutomapTable White-Box Edge Branches & SplitMix64', () => {
|
||
it('adv_f2_3287_line_trimmed_automap_txt_recovery_branch', () => {
|
||
// White-box test of src/formats/automap-table.ts:348-350:
|
||
// Passing rawAutomapText.trim() strips the trailing tab-only row 3288,
|
||
// leaving 3287 dataLines with 'Expansion' at index 2603.
|
||
const trimmedInput = rawAutomapText.trim()
|
||
const tableFromTrimmed = parseAutomapTable(trimmedInput)
|
||
|
||
expect(tableFromTrimmed.rows).toHaveLength(3288)
|
||
expect(tableFromTrimmed.rows[2603]!.levelName).toBe('Expansion')
|
||
expect(tableFromTrimmed.rows[3287]!.levelTypeId).toBe(0)
|
||
expect(tableFromTrimmed.rows.length).toBe(automapTable.rows.length)
|
||
})
|
||
|
||
it('adv_f2_decode_latin1_multi_chunk_uint8array_over_8192_bytes', () => {
|
||
// rawAutomapBytes is ~80KB (> 9 chunks of 8192 bytes in decodeLatin1)
|
||
expect(rawAutomapBytes.byteLength).toBeGreaterThan(8192 * 5)
|
||
const fromBytes = parseAutomapTable(rawAutomapBytes)
|
||
const fromString = parseAutomapTable(rawAutomapText)
|
||
|
||
expect(fromBytes.rows).toHaveLength(3288)
|
||
expect(fromBytes.rows[0]).toEqual(fromString.rows[0])
|
||
expect(fromBytes.rows[2602]).toEqual(fromString.rows[2602])
|
||
expect(fromBytes.rows[2603]).toEqual(fromString.rows[2603])
|
||
expect(fromBytes.rows[3286]).toEqual(fromString.rows[3286])
|
||
})
|
||
|
||
it('adv_f2_fail_fast_malformed_rows_and_non_integer_fields', () => {
|
||
const lines = rawAutomapText.split(/\r?\n/)
|
||
|
||
// 1. Empty TileName on non-divider row (white-box branch at line 377)
|
||
const emptyTileLines = [...lines]
|
||
const cols1 = emptyTileLines[1]!.split('\t')
|
||
cols1[1] = ' '
|
||
emptyTileLines[1] = cols1.join('\t')
|
||
expect(() => parseAutomapTable(emptyTileLines.join('\n'))).toThrow(/has empty TileName/)
|
||
|
||
// 2. Unknown LevelName on row 0
|
||
const badLevelLines = [...lines]
|
||
const cols2 = badLevelLines[1]!.split('\t')
|
||
cols2[0] = '99 Cow Level'
|
||
badLevelLines[1] = cols2.join('\t')
|
||
expect(() => parseAutomapTable(badLevelLines.join('\n'))).toThrow(
|
||
/unrecognized level name "99 Cow Level"/,
|
||
)
|
||
|
||
// 3. Unknown TileName on row 0
|
||
const badTileLines = [...lines]
|
||
const cols3 = badTileLines[1]!.split('\t')
|
||
cols3[1] = 'zz'
|
||
badTileLines[1] = cols3.join('\t')
|
||
expect(() => parseAutomapTable(badTileLines.join('\n'))).toThrow(
|
||
/unrecognized tile name "zz"/,
|
||
)
|
||
|
||
// 4. Non-integer Style field (white-box branch at line 298)
|
||
const nanStyleLines = [...lines]
|
||
const cols4 = nanStyleLines[1]!.split('\t')
|
||
cols4[2] = '12.5'
|
||
nanStyleLines[1] = cols4.join('\t')
|
||
expect(() => parseAutomapTable(nanStyleLines.join('\n'))).toThrow(
|
||
/invalid integer "12\.5" in Style at row 0/,
|
||
)
|
||
|
||
// 5. Header with only 1 header row and no data rows (white-box branch at line 353)
|
||
expect(() => parseAutomapTable(lines[0]!)).toThrow(/no data rows found/)
|
||
})
|
||
|
||
it('adv_f2_negative_cel_termination_and_maximap_frame_bounds', () => {
|
||
const maxiFramesCount = AUTOMAP_UI_MANIFEST.atlases.MaxiMap.frames.length
|
||
expect(maxiFramesCount).toBe(1499)
|
||
expect(AUTOMAP_UI_MANIFEST.atlases.MaxiMapS.frames.length).toBe(1499)
|
||
|
||
// Verify every single valid Cel entry in all 3,288 rows is within [0, 1498] (and within 2048 weight table)
|
||
for (const row of automapTable.rows) {
|
||
for (let i = 0; i < row.celCount; i++) {
|
||
const cel = row.cels[i]!
|
||
expect(cel).toBeGreaterThanOrEqual(0)
|
||
expect(cel).toBeLessThan(maxiFramesCount)
|
||
expect(cel & 0x7ff).toBe(cel)
|
||
}
|
||
}
|
||
|
||
// Also test custom Cel stopping at first negative value (e.g. -2) even if followed by positive Cel3
|
||
const lines = rawAutomapText.split(/\r?\n/)
|
||
const customLines = [...lines]
|
||
const cols = customLines[1]!.split('\t')
|
||
cols[6] = '42' // Cel1
|
||
cols[8] = '-2' // Cel2 (< 0 terminator)
|
||
cols[10] = '99' // Cel3 (must be ignored because Cel2 < 0)
|
||
cols[12] = '100' // Cel4
|
||
customLines[1] = cols.join('\t')
|
||
|
||
const customTable = parseAutomapTable(customLines.join('\n'))
|
||
expect(customTable.rows[0]!.celCount).toBe(1)
|
||
expect(customTable.rows[0]!.cels).toEqual([42])
|
||
})
|
||
|
||
it('adv_f2_splitmix64_coordinate_extremes_and_parameter_validation', () => {
|
||
const multiRow = automapTable.rows.find((r) => r.celCount >= 3 && r.levelTypeId > 0)
|
||
expect(multiRow).toBeDefined()
|
||
|
||
const extremeCoords: Array<[number, number, number]> = [
|
||
[0, 0, 0],
|
||
[-1, -1, -1],
|
||
[-2147483648, 2147483647, 0xffffffff],
|
||
[0x7fffffff, -0x80000000, 0xdeadbeef],
|
||
[123456, 654321, 42],
|
||
]
|
||
|
||
for (const [cx, cy, seed] of extremeCoords) {
|
||
const style = multiRow!.style === -1 ? 0 : multiRow!.style
|
||
const seq = multiRow!.startSequence === -1 ? 0 : multiRow!.startSequence
|
||
const id1 = getAutomapCellId(
|
||
automapTable,
|
||
multiRow!.levelTypeId,
|
||
multiRow!.tileType,
|
||
style,
|
||
seq,
|
||
cx,
|
||
cy,
|
||
seed,
|
||
)
|
||
const id2 = getAutomapCellId(
|
||
automapTable,
|
||
multiRow!.levelTypeId,
|
||
multiRow!.tileType,
|
||
style,
|
||
seq,
|
||
cx,
|
||
cy,
|
||
seed,
|
||
)
|
||
expect(id1).toBe(id2)
|
||
expect(multiRow!.cels).toContain(id1)
|
||
}
|
||
|
||
// LevelType 0 ("None") returns -1 without throwing
|
||
expect(getAutomapCellId(automapTable, 0, 0, 0, 0, 0, 0)).toBe(-1)
|
||
|
||
// Out-of-range parameters fail fast
|
||
expect(() => getAutomapCellId(automapTable, -1, 0, 0, 0)).toThrow(/invalid levelTypeId/)
|
||
expect(() => getAutomapCellId(automapTable, 36, 0, 0, 0)).toThrow(/invalid levelTypeId/)
|
||
expect(() => getAutomapCellId(automapTable, 1, -1, 0, 0)).toThrow(/invalid tileType/)
|
||
expect(() => getAutomapCellId(automapTable, 1, 20, 0, 0)).toThrow(/invalid tileType/)
|
||
expect(() => getAutomapCellId(automapTable, 1, 0, -2, 0)).toThrow(/invalid style/)
|
||
expect(() => getAutomapCellId(automapTable, 1, 0, 0, -2)).toThrow(/invalid sequence/)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// Suite 3: F4/F5/F6/F7 — buildLevelAutomap White-Box Branches in d2map.ts
|
||
// =========================================================================
|
||
describe('Suite 3: F7 — buildLevelAutomap White-Box Branches (d2map.ts)', () => {
|
||
it('adv_f7_top_corner_right_type3_synthesizes_companion_type4_tile', () => {
|
||
// Find a (levelTypeId, style, sequence) that has valid entries for BOTH tileType 3 ("wtlr") and tileType 4 ("wtll")
|
||
let chosenLevelType = 3
|
||
let chosenStyle = 0
|
||
let chosenSeq = 0
|
||
let expectedType3Cel = -1
|
||
let expectedType4Cel = -1
|
||
|
||
outer: for (let lt = 1; lt < 36; lt++) {
|
||
const b3 = automapTable.buckets[lt]![3]!
|
||
for (const r3 of b3) {
|
||
const st = r3.style === -1 ? 0 : r3.style
|
||
const sq = r3.startSequence === -1 ? 0 : r3.startSequence
|
||
const c3 = resolveTileAutomapCellId(automapTable, {
|
||
levelId: 5,
|
||
levelTypeId: lt,
|
||
tileType: 3,
|
||
style: st,
|
||
sequence: sq,
|
||
cellX: 0,
|
||
cellY: 0,
|
||
})
|
||
const c4 = resolveTileAutomapCellId(automapTable, {
|
||
levelId: 5,
|
||
levelTypeId: lt,
|
||
tileType: 4,
|
||
style: st,
|
||
sequence: sq,
|
||
cellX: 0,
|
||
cellY: 0,
|
||
})
|
||
if (c3 >= 0 && c4 >= 0 && c3 !== c4 && getAutomapCellWeight(c3) !== getAutomapCellWeight(c4)) {
|
||
chosenLevelType = lt
|
||
chosenStyle = st
|
||
chosenSeq = sq
|
||
expectedType3Cel = c3
|
||
expectedType4Cel = c4
|
||
break outer
|
||
}
|
||
}
|
||
}
|
||
|
||
expect(expectedType3Cel).toBeGreaterThanOrEqual(0)
|
||
expect(expectedType4Cel).toBeGreaterThanOrEqual(0)
|
||
|
||
// Construct a synthetic Ds1 with ONLY wall.type = 3 at cell (0, 0)
|
||
const makeDs1 = (): Ds1 => ({
|
||
version: 18,
|
||
width: 1,
|
||
height: 1,
|
||
act: 1,
|
||
substitutionType: 0,
|
||
wallLayers: 1,
|
||
floorLayers: 1,
|
||
objects: [],
|
||
npcPathOffset: null,
|
||
cells: [
|
||
[
|
||
{
|
||
floors: [],
|
||
shadows: [],
|
||
substitutions: [],
|
||
walls: [
|
||
{
|
||
prop1: 1,
|
||
sequence: chosenSeq,
|
||
unknown1: 0,
|
||
style: chosenStyle,
|
||
unknown2: 0,
|
||
hidden: false,
|
||
type: 3,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
],
|
||
})
|
||
|
||
const automap = buildLevelAutomap(makeDs1(), {
|
||
automapTable,
|
||
levelId: 5,
|
||
layerId: 5,
|
||
levelTypeId: chosenLevelType,
|
||
act: 1,
|
||
})
|
||
|
||
const expectedWinner = deduplicateAutomapCells([
|
||
{
|
||
cellNo: expectedType3Cel,
|
||
xPixel: 0,
|
||
yPixel: 0,
|
||
cellX: 0,
|
||
cellY: 0,
|
||
fSaved: 0,
|
||
weight: getAutomapCellWeight(expectedType3Cel),
|
||
},
|
||
{
|
||
cellNo: expectedType4Cel,
|
||
xPixel: 0,
|
||
yPixel: 0,
|
||
cellX: 0,
|
||
cellY: 0,
|
||
fSaved: 0,
|
||
weight: getAutomapCellWeight(expectedType4Cel),
|
||
},
|
||
])
|
||
expect(automap.walls).toEqual(expectedWinner)
|
||
})
|
||
|
||
it('adv_f7_shadow_layer_tiletype13_and_prop1_zero_or_hidden_filtering', () => {
|
||
// White-box test of d2map.ts:645-670 (shadow layer routing into automap.walls with tileType = 13 "sh")
|
||
const customShadowTable = parseAutomapTable(
|
||
[
|
||
'LevelName\tTileName\tStyle\tStartSequence\tEndSequence\tType1\tCel1\tType2\tCel2\tType2\tCel3\tType4\tCel4',
|
||
'1 Town\tsh\t0\t0\t0\tsh\t77\t\t-1\t\t-1\t\t-1',
|
||
].join('\n'),
|
||
)
|
||
|
||
const ds1: Ds1 = {
|
||
version: 18,
|
||
width: 1,
|
||
height: 1,
|
||
act: 1,
|
||
substitutionType: 0,
|
||
wallLayers: 3,
|
||
floorLayers: 2,
|
||
objects: [],
|
||
npcPathOffset: null,
|
||
cells: [
|
||
[
|
||
{
|
||
substitutions: [],
|
||
floors: [
|
||
// prop1=0 floor -> skipped
|
||
{ prop1: 0, sequence: 0, unknown1: 0, style: 0, unknown2: 0, hidden: false },
|
||
// hidden=true floor -> skipped
|
||
{ prop1: 1, sequence: 0, unknown1: 0, style: 0, unknown2: 0, hidden: true },
|
||
],
|
||
shadows: [
|
||
// Valid shadow -> routed to automap.walls with tileType=13 -> cellNo=77
|
||
{ prop1: 1, sequence: 0, unknown1: 0, style: 0, unknown2: 0, hidden: false },
|
||
// Hidden shadow -> skipped
|
||
{ prop1: 1, sequence: 0, unknown1: 0, style: 0, unknown2: 0, hidden: true },
|
||
// prop1=0 shadow -> skipped
|
||
{ prop1: 0, sequence: 0, unknown1: 0, style: 0, unknown2: 0, hidden: false },
|
||
],
|
||
walls: [
|
||
// prop1=0 wall -> skipped
|
||
{ prop1: 0, sequence: 0, unknown1: 0, style: 0, unknown2: 0, hidden: false, type: 1 },
|
||
// hidden=true wall -> skipped
|
||
{ prop1: 1, sequence: 0, unknown1: 0, style: 0, unknown2: 0, hidden: true, type: 1 },
|
||
// out-of-range wall.type=25 -> skipped
|
||
{ prop1: 1, sequence: 0, unknown1: 0, style: 0, unknown2: 0, hidden: false, type: 25 },
|
||
],
|
||
},
|
||
],
|
||
],
|
||
}
|
||
|
||
const automap = buildLevelAutomap(ds1, {
|
||
automapTable: customShadowTable,
|
||
levelId: 1,
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
act: 1,
|
||
})
|
||
expect(automap.floors).toHaveLength(0)
|
||
expect(automap.walls).toHaveLength(1)
|
||
expect(automap.walls[0]!.cellNo).toBe(77)
|
||
})
|
||
|
||
it('adv_f7_ds1_preset_monster_type1_and_object_type2_in_build_level_automap', () => {
|
||
// Find a DS1 object id in Act 1 for type 1 (preset monster/NPC, e.g. cain1) and type 2 (game object, e.g. Bank/Stash)
|
||
const act1Presets = monstersTable.presetPlaceByAct.get('1') ?? []
|
||
const cainIdx = act1Presets.findIndex((p) => p.toLowerCase().includes('cain'))
|
||
expect(cainIdx).toBeGreaterThanOrEqual(0)
|
||
|
||
const ds1: Ds1 = {
|
||
version: 18,
|
||
width: 20,
|
||
height: 20,
|
||
act: 1,
|
||
substitutionType: 0,
|
||
wallLayers: 0,
|
||
floorLayers: 0,
|
||
npcPathOffset: null,
|
||
cells: [],
|
||
objects: [
|
||
// Type 1: Deckard Cain preset in Act 1 -> resolves to cellNo 267
|
||
{ type: 1, id: cainIdx, x: 25, y: 25, flags: 0 },
|
||
// Type 1: Out-of-bounds preset id -> gracefully skipped (d2map.ts:736 / 769)
|
||
{ type: 1, id: 99999, x: 30, y: 30, flags: 0 },
|
||
// Type 2: Out-of-bounds object id -> gracefully skipped (d2map.ts:736)
|
||
{ type: 2, id: 99999, x: 35, y: 35, flags: 0 },
|
||
],
|
||
}
|
||
|
||
const automap = buildLevelAutomap(ds1, {
|
||
automapTable,
|
||
levelId: 1,
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
act: 1,
|
||
objectsTable,
|
||
monstersTable,
|
||
monstats2ById,
|
||
})
|
||
|
||
expect(automap.objects.some((o) => o.cellNo === 267 && o.cellX === 5 && o.cellY === 5)).toBe(
|
||
true,
|
||
)
|
||
})
|
||
|
||
it('adv_f7_waypoint_deduplication_radius_2_vs_3_and_arcane_318', () => {
|
||
const emptyDs1: Ds1 = {
|
||
version: 18,
|
||
width: 20,
|
||
height: 20,
|
||
act: 1,
|
||
substitutionType: 0,
|
||
wallLayers: 0,
|
||
floorLayers: 0,
|
||
objects: [],
|
||
npcPathOffset: null,
|
||
cells: [],
|
||
}
|
||
|
||
// 1. Three waypoints: first at sub-tile (50, 50) -> cell (10, 10),
|
||
// second at sub-tile (60, 60) -> cell (12, 12) (|dx|=2, |dy|=2 -> deduplicated!),
|
||
// third at sub-tile (65, 50) -> cell (13, 10) (|dx|=3, |dy|=0 -> NOT deduplicated, added!).
|
||
const resNormal = buildLevelAutomap(emptyDs1, {
|
||
automapTable,
|
||
levelId: 2,
|
||
layerId: 2,
|
||
levelTypeId: 2,
|
||
act: 1,
|
||
waypoints: [
|
||
{ x: 50, y: 50 },
|
||
{ x: 60, y: 60 },
|
||
{ x: 65, y: 50 },
|
||
],
|
||
})
|
||
const wpCellsNormal = resNormal.objects.filter((c) => c.cellNo === 307)
|
||
expect(wpCellsNormal).toHaveLength(2)
|
||
expect(wpCellsNormal.map((c) => [c.cellX, c.cellY])).toEqual([
|
||
[10, 10],
|
||
[13, 10],
|
||
])
|
||
|
||
// 2. Level 74 (Arcane Sanctuary) emits cellNo = 318 for waypoints
|
||
const resArcane = buildLevelAutomap(emptyDs1, {
|
||
automapTable,
|
||
levelId: 74,
|
||
layerId: 74,
|
||
levelTypeId: 19,
|
||
act: 2,
|
||
waypoints: [{ x: 75, y: 75 }],
|
||
})
|
||
expect(resArcane.objects).toHaveLength(1)
|
||
expect(resArcane.objects[0]!.cellNo).toBe(318)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// Suite 4: F8/F9/F11/F12/F13 — Minimap Viewport Frustum, Sheet Routing & HUD
|
||
// =========================================================================
|
||
describe('Suite 4: F8/F9/F11/F12/F13 — Minimap Frustum Culling, Sheet Routing & HUD', () => {
|
||
it('adv_f8_f9_frustum_culling_exact_1px_boundaries_all_4_edges', () => {
|
||
const { renderer, sprites, glCalls, scissors } = createRecordingRenderer()
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
|
||
// Configure Corner Minimap (bMinimap = true, rightPanelOpen = false) on 800x600 screen
|
||
// Viewport is vpX=505, vpY=16, vpW=279, vpH=225 -> right=784, bottom=241, center=(644, 128)
|
||
minimap.bMinimap = true
|
||
minimap.visible = true
|
||
|
||
const maxiSMeta = minimap.getSheetMeta('MaxiMapS')
|
||
const frame0 = maxiSMeta.frames[0]!
|
||
expect(frame0.width).toBeGreaterThan(0)
|
||
expect(frame0.height).toBeGreaterThan(0)
|
||
|
||
const baseLeftAtZero = 644 + frame0.offsetX - 4
|
||
const baseTopAtZero = 128 - frame0.height + frame0.offsetY
|
||
|
||
// 1. Left edge: drawLeft + width === 504 (outside) vs 505 (inside)
|
||
const dxLeftOutside = 504 - frame0.width - baseLeftAtZero
|
||
const dxLeftInside = 505 - frame0.width - baseLeftAtZero
|
||
// 2. Right edge: drawLeft === 785 (outside) vs 784 (inside)
|
||
const dxRightOutside = 785 - baseLeftAtZero
|
||
const dxRightInside = 784 - baseLeftAtZero
|
||
// 3. Top edge: drawTop + height === 15 (outside) vs 16 (inside)
|
||
const dyTopOutside = 15 - frame0.height - baseTopAtZero
|
||
const dyTopInside = 16 - frame0.height - baseTopAtZero
|
||
// 4. Bottom edge: drawTop === 242 (outside) vs 241 (inside)
|
||
const dyBottomOutside = 242 - baseTopAtZero
|
||
const dyBottomInside = 241 - baseTopAtZero
|
||
|
||
const testCells: AutomapCell[] = [
|
||
{ cellNo: 0, xPixel: dxLeftOutside * 2, yPixel: 0, cellX: 0, cellY: 0, fSaved: 0 },
|
||
{ cellNo: 0, xPixel: dxLeftInside * 2, yPixel: 0, cellX: 0, cellY: 0, fSaved: 0 },
|
||
{ cellNo: 0, xPixel: dxRightOutside * 2, yPixel: 0, cellX: 0, cellY: 0, fSaved: 0 },
|
||
{ cellNo: 0, xPixel: dxRightInside * 2, yPixel: 0, cellX: 0, cellY: 0, fSaved: 0 },
|
||
{ cellNo: 0, xPixel: 0, yPixel: dyTopOutside * 2, cellX: 0, cellY: 0, fSaved: 0 },
|
||
{ cellNo: 0, xPixel: 0, yPixel: dyTopInside * 2, cellX: 0, cellY: 0, fSaved: 0 },
|
||
{ cellNo: 0, xPixel: 0, yPixel: dyBottomOutside * 2, cellX: 0, cellY: 0, fSaved: 0 },
|
||
{ cellNo: 0, xPixel: 0, yPixel: dyBottomInside * 2, cellX: 0, cellY: 0, fSaved: 0 },
|
||
]
|
||
|
||
const level: MinimapLevel = {
|
||
key: 'frustum-test',
|
||
cellsX: 10,
|
||
cellsY: 10,
|
||
blocked: new Uint8Array(2500),
|
||
gridWidth: 50,
|
||
markers: [],
|
||
automap: {
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
floors: testCells,
|
||
walls: [],
|
||
objects: [],
|
||
extras: [],
|
||
},
|
||
}
|
||
|
||
minimap.reveal(level, 0, 0, 0, 0, true)
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
0,
|
||
0,
|
||
{ x: 400, y: 300, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX: 0, playerWorldY: 0 },
|
||
)
|
||
|
||
// Hardware scissor must be enabled at (505, 600 - (16 + 225) = 359, 279, 225) and disabled after
|
||
expect(glCalls.enableCount).toBe(1)
|
||
expect(glCalls.disableCount).toBe(1)
|
||
expect(scissors[0]).toEqual({ x: 505, y: 359, width: 279, height: 225 })
|
||
expect(scissors[1]).toBeNull()
|
||
|
||
// Exactly the 4 inside cells must be drawn, and the 4 outside cells culled!
|
||
expect(sprites).toHaveLength(4)
|
||
expect(minimap.spritesDrawnLastFrame.floors).toBe(4)
|
||
})
|
||
|
||
it('adv_f8_f9_simultaneous_panel_states_pan_clamp_and_bminimap_toggle', () => {
|
||
// All 4 (leftPanelOpen, rightPanelOpen) states in Full Automap (bMinimap = false)
|
||
expect(computeAutomapViewport(800, 600, false, false, false)).toMatchObject({
|
||
vpX: 0, vpY: 0, vpW: 800, vpH: 552, centerX: 400, centerY: 276, centerShiftX: 0, divisor: 10,
|
||
})
|
||
expect(computeAutomapViewport(800, 600, false, true, false)).toMatchObject({
|
||
vpX: 0, vpY: 0, vpW: 800, vpH: 552, centerX: 560, centerY: 276, centerShiftX: 160, divisor: 10,
|
||
})
|
||
expect(computeAutomapViewport(800, 600, false, false, true)).toMatchObject({
|
||
vpX: 0, vpY: 0, vpW: 800, vpH: 552, centerX: 240, centerY: 276, centerShiftX: -160, divisor: 10,
|
||
})
|
||
expect(computeAutomapViewport(800, 600, false, true, true)).toMatchObject({
|
||
vpX: 0, vpY: 0, vpW: 800, vpH: 552, centerX: 400, centerY: 276, centerShiftX: 0, divisor: 10,
|
||
})
|
||
|
||
// All 4 (leftPanelOpen, rightPanelOpen) states in Corner Minimap (bMinimap = true)
|
||
expect(computeAutomapViewport(800, 600, true, false, false)).toMatchObject({
|
||
vpX: 505, vpY: 16, vpW: 279, vpH: 225, centerX: 644, centerY: 128, divisor: 20,
|
||
})
|
||
expect(computeAutomapViewport(800, 600, true, true, false)).toMatchObject({
|
||
vpX: 505, vpY: 16, vpW: 279, vpH: 225, centerX: 644, centerY: 128, divisor: 20,
|
||
})
|
||
expect(computeAutomapViewport(800, 600, true, false, true)).toMatchObject({
|
||
vpX: 16, vpY: 16, vpW: 279, vpH: 225, centerX: 155, centerY: 128, divisor: 20,
|
||
})
|
||
expect(computeAutomapViewport(800, 600, true, true, true)).toMatchObject({
|
||
vpX: 16, vpY: 16, vpW: 279, vpH: 225, centerX: 155, centerY: 128, divisor: 20,
|
||
})
|
||
|
||
// Pan behavior across mode switches
|
||
const minimap = new Minimap()
|
||
expect(minimap.bMinimap).toBe(true)
|
||
expect(minimap.panBySteps(2, -1)).toBe(false)
|
||
expect(minimap.panX).toBe(0)
|
||
|
||
minimap.toggleMode()
|
||
expect(minimap.bMinimap).toBe(false)
|
||
expect(minimap.panBySteps(5, -3)).toBe(true)
|
||
expect(minimap.panX).toBe(40)
|
||
expect(minimap.panY).toBe(-24)
|
||
|
||
minimap.toggleMode()
|
||
expect(minimap.bMinimap).toBe(true)
|
||
expect(minimap.panX).toBe(0)
|
||
expect(minimap.panY).toBe(0)
|
||
})
|
||
|
||
it('adv_f8_resolve_automap_sheet_name_all_numeric_string_and_level_fallbacks', () => {
|
||
// Numeric slot 0..3
|
||
expect(resolveAutomapSheetName(0, false, 1)).toBe('MaxiMap')
|
||
expect(resolveAutomapSheetName(0, true, 1)).toBe('MaxiMapS')
|
||
expect(resolveAutomapSheetName(1, false, 1)).toBe('Act2Map')
|
||
expect(resolveAutomapSheetName(1, true, 1)).toBe('Act2MapS')
|
||
expect(resolveAutomapSheetName(2, false, 1)).toBe('Act4Map')
|
||
expect(resolveAutomapSheetName(2, true, 1)).toBe('Act4MapS')
|
||
expect(resolveAutomapSheetName(3, false, 1)).toBe('ExTnMap')
|
||
expect(resolveAutomapSheetName(3, true, 1)).toBe('ExTnMapS')
|
||
|
||
// String sheet key
|
||
expect(resolveAutomapSheetName('Act2Map', false, 1)).toBe('Act2Map')
|
||
expect(resolveAutomapSheetName('Act2Map', true, 1)).toBe('Act2MapS')
|
||
expect(resolveAutomapSheetName('Act4MapS', false, 1)).toBe('Act4Map')
|
||
expect(resolveAutomapSheetName('ExTnMapS', true, 1)).toBe('ExTnMapS')
|
||
|
||
// LevelId fallback when slot is 0 or undefined
|
||
expect(resolveAutomapSheetName(undefined, false, 47)).toBe('Act2Map')
|
||
expect(resolveAutomapSheetName(0, true, 48)).toBe('Act2MapS')
|
||
expect(resolveAutomapSheetName(0, false, 49)).toBe('Act2Map')
|
||
expect(resolveAutomapSheetName(0, false, 52)).toBe('ExTnMap')
|
||
expect(resolveAutomapSheetName(0, true, 109)).toBe('ExTnMapS')
|
||
expect(resolveAutomapSheetName(0, false, 136)).toBe('ExTnMap')
|
||
expect(resolveAutomapSheetName(0, false, 103)).toBe('Act4Map')
|
||
expect(resolveAutomapSheetName(0, true, 103)).toBe('Act4MapS')
|
||
expect(resolveAutomapSheetName(0, false, 2)).toBe('MaxiMap')
|
||
})
|
||
|
||
it('adv_f9_load_atlases_candidate_url_fallback_and_missing_sheet_resilience', async () => {
|
||
const { renderer } = createRecordingRenderer()
|
||
const minimap = new Minimap()
|
||
|
||
const origFetch = globalThis.fetch
|
||
const origCreateImageBitmap = globalThis.createImageBitmap
|
||
try {
|
||
const fetchedUrls: string[] = []
|
||
globalThis.fetch = vi.fn(async (urlInput: RequestInfo | URL) => {
|
||
const url = String(urlInput)
|
||
fetchedUrls.push(url)
|
||
if (!url.startsWith('/fallback-base/')) {
|
||
return { ok: false, status: 404 } as Response
|
||
}
|
||
return {
|
||
ok: true,
|
||
blob: async () => new Blob([new Uint8Array([1, 2, 3])]),
|
||
} as Response
|
||
})
|
||
globalThis.createImageBitmap = vi.fn(async () => ({
|
||
width: 64,
|
||
height: 64,
|
||
close: vi.fn(),
|
||
}) as unknown as ImageBitmap)
|
||
|
||
const loadedCount = await minimap.loadAtlases(renderer, '/fallback-base')
|
||
expect(loadedCount).toBe(8)
|
||
expect(minimap.atlasesLoadedCount).toBe(8)
|
||
expect(fetchedUrls.some((u) => u.startsWith('/fallback-base/'))).toBe(true)
|
||
} finally {
|
||
globalThis.fetch = origFetch
|
||
globalThis.createImageBitmap = origCreateImageBitmap
|
||
}
|
||
})
|
||
|
||
it('adv_f9_is_automap_cell_revealed_nan_cell_coord_fallback', () => {
|
||
const { renderer, sprites } = createRecordingRenderer()
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
|
||
// Cell with NaN cellX/cellY but valid xPixel=16, yPixel=8 -> automapPixelToCellCoords(16, 8) = (2, 0)
|
||
const nanCell: AutomapCell = {
|
||
cellNo: 0,
|
||
xPixel: 16,
|
||
yPixel: 8,
|
||
cellX: Number.NaN,
|
||
cellY: Number.NaN,
|
||
fSaved: 0,
|
||
}
|
||
expect(automapPixelToCellCoords(16, 8)).toEqual({ cellX: 2, cellY: 0 })
|
||
|
||
const level: MinimapLevel = {
|
||
key: 'nan-cell-fallback',
|
||
cellsX: 20,
|
||
cellsY: 20,
|
||
blocked: new Uint8Array(10000),
|
||
gridWidth: 100,
|
||
markers: [],
|
||
automap: {
|
||
layerId: 2,
|
||
levelTypeId: 2,
|
||
floors: [nanCell],
|
||
walls: [],
|
||
objects: [],
|
||
extras: [],
|
||
},
|
||
}
|
||
|
||
minimap.bMinimap = false
|
||
minimap.visible = true
|
||
minimap.draw(renderer, level, 0, 0, { x: 400, y: 300, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(sprites).toHaveLength(0)
|
||
|
||
minimap.reveal(level, 0, 0)
|
||
minimap.draw(renderer, level, 0, 0, { x: 400, y: 300, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(sprites).toHaveLength(1)
|
||
})
|
||
|
||
it('adv_f11_f12_crosshair_entity_colors_dead_monster_suppression_and_portal_300_306_307', () => {
|
||
const { renderer, quads, sprites } = createRecordingRenderer()
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
|
||
const level: MinimapLevel = {
|
||
key: 'crosshair-portal-test',
|
||
levelId: 4, // Stony Field -> Red portal uses frame 300
|
||
cellsX: 20,
|
||
cellsY: 20,
|
||
blocked: new Uint8Array(10000),
|
||
gridWidth: 100,
|
||
markers: [],
|
||
automap: {
|
||
layerId: 4,
|
||
levelTypeId: 2,
|
||
floors: [],
|
||
walls: [],
|
||
objects: [],
|
||
extras: [],
|
||
},
|
||
}
|
||
|
||
minimap.bMinimap = false
|
||
minimap.visible = true
|
||
minimap.showNames = true
|
||
minimap.reveal(level, 0, 0, 0, 0, true)
|
||
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
0,
|
||
0,
|
||
{ x: 400, y: 300, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 0,
|
||
levelId: 4,
|
||
portals: [
|
||
{ x: 0, y: 0, active: true, kind: 'red', label: 'Tristram' }, // frame 300 in Level 4
|
||
{ x: 0, y: 0, active: false, kind: 'blue' }, // inactive -> skipped!
|
||
],
|
||
entities: [
|
||
{ x: 0, y: 0, kind: 'summon', classId: 357, alive: true }, // Valkyrie -> white (0x1F)
|
||
{ x: 0, y: 0, kind: 'summon', classId: 291, alive: true }, // Iron Golem -> white (0x1F)
|
||
{ x: 0, y: 0, kind: 'mercenary', classId: 271, alive: true }, // Mercenary -> dark green (0x81)
|
||
{ x: 0, y: 0, kind: 'boss', alive: false }, // Dead boss -> skipped!
|
||
{ x: 0, y: 0, kind: 'boss', alive: true }, // Alive boss -> red (0x0A)
|
||
],
|
||
},
|
||
)
|
||
|
||
// 1 active portal drawn (frame 300 in Stony Field), 1 inactive skipped
|
||
expect(minimap.spritesDrawnLastFrame.portals).toBe(1)
|
||
expect(sprites).toHaveLength(1)
|
||
expect(sprites[0]!.frame).toEqual({
|
||
x: AUTOMAP_UI_MANIFEST.atlases.MaxiMap.frames[300]!.x,
|
||
y: AUTOMAP_UI_MANIFEST.atlases.MaxiMap.frames[300]!.y,
|
||
width: AUTOMAP_UI_MANIFEST.atlases.MaxiMap.frames[300]!.width,
|
||
height: AUTOMAP_UI_MANIFEST.atlases.MaxiMap.frames[300]!.height,
|
||
})
|
||
expect(minimap.warpLabelsLastFrame.some((l) => l.text === 'Tristram' && l.kind === 'portal')).toBe(true)
|
||
|
||
// Crosshairs drawn: 3 mercenaries/summons + 1 alive boss + 1 player = 5 crosshairs * 12 segments = 60 quads
|
||
expect(minimap.crosshairsDrawnLastFrame).toEqual({
|
||
player: 1,
|
||
party: 0,
|
||
npcs: 0,
|
||
mercenaries: 3,
|
||
bosses: 1,
|
||
total: 5,
|
||
})
|
||
expect(quads).toHaveLength(60)
|
||
expect(quads[0]!.color).toEqual(AUTOMAP_CROSSHAIR_COLORS.valkyrieOrIronGolem)
|
||
expect(quads[12]!.color).toEqual(AUTOMAP_CROSSHAIR_COLORS.valkyrieOrIronGolem)
|
||
expect(quads[24]!.color).toEqual(AUTOMAP_CROSSHAIR_COLORS.mercenary)
|
||
expect(quads[36]!.color).toEqual(AUTOMAP_CROSSHAIR_COLORS.boss)
|
||
expect(quads[48]!.color).toEqual(AUTOMAP_CROSSHAIR_COLORS.player)
|
||
})
|
||
|
||
it('adv_f13_hud_overlay_non_800x600_canvas_scaling_and_label_layout', () => {
|
||
const { renderer } = createRecordingRenderer()
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
|
||
const level: MinimapLevel = {
|
||
key: 'hud-scale-test',
|
||
cellsX: 20,
|
||
cellsY: 20,
|
||
blocked: new Uint8Array(10000),
|
||
gridWidth: 100,
|
||
markers: [
|
||
{ cellX: 2, cellY: 2, kind: 'exit', targetName: 'Den of Evil' },
|
||
],
|
||
levelName: 'Blood Moor',
|
||
difficulty: 'Hell',
|
||
}
|
||
|
||
minimap.bMinimap = false
|
||
minimap.visible = true
|
||
minimap.showNames = true
|
||
minimap.reveal(level, 2, 2)
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
2,
|
||
2,
|
||
{ x: 400, y: 300, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
entities: [{ x: 160, y: 160, kind: 'npc', name: 'Flavie', alive: true }],
|
||
stashes: [{ x: 160, y: 160, label: 'Stash' }],
|
||
},
|
||
)
|
||
|
||
const drawnTexts: Array<{
|
||
text: string
|
||
x: number
|
||
y: number
|
||
font?: string
|
||
color?: D2ColorCode
|
||
align?: string
|
||
}> = []
|
||
const mockFont: D2FontRenderer = {
|
||
drawText(
|
||
_ctx: CanvasRenderingContext2D,
|
||
text: string,
|
||
x: number,
|
||
y: number,
|
||
opts?: { font?: string; color?: D2ColorCode; align?: string },
|
||
) {
|
||
drawnTexts.push({ text, x, y, ...opts })
|
||
},
|
||
} as unknown as D2FontRenderer
|
||
|
||
// Render onto a 1600x1200 logical canvas (scaleX = 2, scaleY = 2)
|
||
minimap.drawHudOverlay({} as CanvasRenderingContext2D, mockFont, 1600, 1200)
|
||
|
||
expect(drawnTexts.some((t) => t.text === 'Den of Evil' && t.font === 'font6' && t.color === 'white')).toBe(true)
|
||
expect(drawnTexts.some((t) => t.text === 'Stash' && t.font === 'font6' && t.color === 'gold')).toBe(true)
|
||
expect(drawnTexts.some((t) => t.text === 'Flavie' && t.font === 'font6' && t.color === 'gold')).toBe(true)
|
||
expect(
|
||
drawnTexts.some(
|
||
(t) => t.text === 'Blood Moor' && t.x === 1584 && t.y === 16 && t.font === 'font16' && t.align === 'right',
|
||
),
|
||
).toBe(true)
|
||
expect(
|
||
drawnTexts.some(
|
||
(t) => t.text === 'Hell' && t.x === 1584 && t.y === 34 && t.font === 'font16' && t.align === 'right',
|
||
),
|
||
).toBe(true)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// Suite 5: F10 — Exhaustive Octile Distance Locus & Level Isolation
|
||
// =========================================================================
|
||
describe('Suite 5: F10 — Exhaustive Octile Distance Locus & Level Isolation', () => {
|
||
it('adv_f10_exhaustive_octile_distance_locus_symmetry_and_159_160_boundary', () => {
|
||
let count159 = 0
|
||
let count160 = 0
|
||
|
||
// Exhaustively verify all (dx, dy) in [-100, 100] (40,401 integer coordinate pairs)
|
||
// against D2Client.dll 0x6fb12af0: max(|dx|, |dy|) * 2 + min(|dx|, |dy|) >= 160
|
||
for (let dx = -100; dx <= 100; dx++) {
|
||
for (let dy = -100; dy <= 100; dy++) {
|
||
const adx = Math.abs(dx)
|
||
const ady = Math.abs(dy)
|
||
const oracleDist = Math.max(adx, ady) * 2 + Math.min(adx, ady)
|
||
|
||
const dist = computeAutomapRevealDistance(dx, dy)
|
||
expect(dist).toBe(oracleDist)
|
||
|
||
if (oracleDist === 159 || oracleDist === 160) {
|
||
expect(computeAutomapRevealDistance(-dx, dy)).toBe(oracleDist)
|
||
expect(computeAutomapRevealDistance(dx, -dy)).toBe(oracleDist)
|
||
expect(computeAutomapRevealDistance(-dx, -dy)).toBe(oracleDist)
|
||
expect(computeAutomapRevealDistance(dy, dx)).toBe(oracleDist)
|
||
expect(computeAutomapRevealDistance(-dy, -dx)).toBe(oracleDist)
|
||
}
|
||
|
||
if (oracleDist === 159) count159++
|
||
if (oracleDist === 160) count160++
|
||
}
|
||
}
|
||
|
||
expect(count159).toBeGreaterThan(100)
|
||
expect(count160).toBeGreaterThan(100)
|
||
})
|
||
|
||
it('adv_f10_matrons_den_level_133_vs_non_133_octile_reveal_and_key_isolation', () => {
|
||
const minimap = new Minimap()
|
||
|
||
const makeLevel = (key: string, levelId: number): MinimapLevel => ({
|
||
key,
|
||
levelId,
|
||
cellsX: 80,
|
||
cellsY: 80,
|
||
blocked: new Uint8Array(160000),
|
||
gridWidth: 400,
|
||
markers: [],
|
||
})
|
||
|
||
const bloodMoor = makeLevel('level-2', 2)
|
||
const matronsDen = makeLevel('level-133', 133)
|
||
|
||
expect(minimap.reveal(bloodMoor, 0, 0, 0, 0)).toBe(true)
|
||
expect(computeAutomapRevealDistance(53, 53)).toBe(159)
|
||
expect(minimap.reveal(bloodMoor, 10, 10, 53, 53)).toBe(false)
|
||
expect(computeAutomapRevealDistance(53, 54)).toBe(161)
|
||
expect(minimap.reveal(bloodMoor, 10, 10, 53, 54)).toBe(true)
|
||
|
||
expect(minimap.revealedRoomCount('level-133')).toBe(0)
|
||
expect(minimap.reveal(matronsDen, 16, 16, 1280, 1280)).toBe(true)
|
||
expect(minimap.revealedRoomCount('level-133')).toBe(9)
|
||
})
|
||
})
|
||
})
|