866 lines
29 KiB
TypeScript
866 lines
29 KiB
TypeScript
/**
|
||
* Tier 3 — Cross-Feature Combinations E2E Tests (`tests/e2e-automap-113c/tier3-cross-feature-combinations.test.ts`).
|
||
*
|
||
* Covers pairwise and multi-feature interactions (`18` tests total, `>= 15` required) across
|
||
* `F1`–`F13` in `PROJECT.md § Feature Inventory`.
|
||
*/
|
||
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import { beforeAll, describe, expect, it } from 'vitest'
|
||
import { MpqArchive, memorySource } from '../../src/mpq/index.ts'
|
||
import { decodeDs1 } from '../../src/formats/ds1.ts'
|
||
import {
|
||
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 { 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 { D2ColorCode, D2FontRenderer } from '../../src/ui/font.ts'
|
||
import {
|
||
AUTOMAP_CROSSHAIR_COLORS,
|
||
Minimap,
|
||
type MinimapLevel,
|
||
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')
|
||
|
||
let expArchive: MpqArchive
|
||
let dataArchive: MpqArchive
|
||
let automapTable: AutomapTable
|
||
|
||
interface RecordedDraw {
|
||
readonly kind: 'sprite' | 'quad'
|
||
readonly frame?: AtlasFrame
|
||
readonly atlas?: AtlasHandle
|
||
readonly x?: number
|
||
readonly y?: number
|
||
readonly tint?: readonly [number, number, number, number]
|
||
readonly color?: readonly [number, number, number, number]
|
||
readonly x0?: number
|
||
readonly y0?: number
|
||
}
|
||
|
||
function createRecordingRenderer(): {
|
||
renderer: SpriteRenderer
|
||
draws: RecordedDraw[]
|
||
} {
|
||
const draws: RecordedDraw[] = []
|
||
const renderer = {
|
||
flush() {},
|
||
draw(
|
||
frame: AtlasFrame,
|
||
x: number,
|
||
y: number,
|
||
options?: {
|
||
atlas?: AtlasHandle
|
||
tint?: readonly [number, number, number, number]
|
||
},
|
||
) {
|
||
draws.push({
|
||
kind: 'sprite',
|
||
frame,
|
||
x,
|
||
y,
|
||
...(options?.atlas ? { atlas: options.atlas } : {}),
|
||
...(options?.tint ? { tint: options.tint } : {}),
|
||
})
|
||
},
|
||
drawSolid() {},
|
||
drawSolidQuad(
|
||
x0: number,
|
||
y0: number,
|
||
_x1: number,
|
||
_y1: number,
|
||
_x2: number,
|
||
_y2: number,
|
||
_x3: number,
|
||
_y3: number,
|
||
color: readonly [number, number, number, number],
|
||
) {
|
||
draws.push({ kind: 'quad', x0, y0, color })
|
||
},
|
||
} as unknown as SpriteRenderer
|
||
return { renderer, draws }
|
||
}
|
||
|
||
function registerAtlases(minimap: Minimap): void {
|
||
const keys: readonly AutomapSpriteKey[] = [
|
||
'MaxiMap',
|
||
'MaxiMapS',
|
||
'Act2Map',
|
||
'Act2MapS',
|
||
'Act4Map',
|
||
'Act4MapS',
|
||
'ExTnMap',
|
||
'ExTnMapS',
|
||
]
|
||
for (const key of keys) {
|
||
const meta = minimap.getSheetMeta(key)
|
||
minimap.setAtlasHandle(key, {
|
||
texture: key as unknown as WebGLTexture,
|
||
width: meta.atlasWidth,
|
||
height: meta.atlasHeight,
|
||
} as AtlasHandle)
|
||
}
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
expArchive = await MpqArchive.open(memorySource(new Uint8Array(fs.readFileSync(MPQ_EXP_PATH))))
|
||
dataArchive = await MpqArchive.open(memorySource(new Uint8Array(fs.readFileSync(MPQ_DATA_PATH))))
|
||
const autoMapEntry = expArchive.find('data\\global\\excel\\AutoMap.txt')
|
||
if (!autoMapEntry) throw new Error('AutoMap.txt missing in d2exp.mpq')
|
||
automapTable = parseAutomapTable(await expArchive.read(autoMapEntry))
|
||
})
|
||
|
||
describe('Tier 3 — Cross-Feature Combinations (18 pairwise/multi-feature tests across F1–F13)', () => {
|
||
it('C1 (F1 + F2 + F7): LvlTypes.txt name resolution + AutoMap.txt bucket lookup + Lower Wall (+24 yPixel) coordinate shift', () => {
|
||
const lvlTypeId = getAutomapLevelTypeId('Act 1 - Town')
|
||
const upperWallType = getAutomapTileType('wl') // 1
|
||
const lowerWallType = getAutomapTileType('ld') // 16
|
||
|
||
const upperCellNo = getAutomapCellId(automapTable, lvlTypeId, upperWallType, 0, 0)
|
||
expect(upperCellNo).toBe(61)
|
||
|
||
const upperCoords = computeAutomapTilePixelCoords(6, 2, upperWallType)
|
||
const lowerCoords = computeAutomapTilePixelCoords(6, 2, lowerWallType)
|
||
expect(lowerCoords.xPixel).toBe(upperCoords.xPixel)
|
||
expect(lowerCoords.yPixel).toBe(upperCoords.yPixel + 24)
|
||
|
||
// Note that a lower wall at (cellX, cellY) = (3, 3) has xPixel=0, yPixel=24+24=48,
|
||
// which collides with an upper wall at (cellX, cellY) = (6, 6) where xPixel=0, yPixel=48!
|
||
const lowerAt3_3 = computeAutomapTilePixelCoords(3, 3, lowerWallType)
|
||
const upperAt6_6 = computeAutomapTilePixelCoords(6, 6, upperWallType)
|
||
expect(lowerAt3_3).toEqual(upperAt6_6)
|
||
})
|
||
|
||
it('C2 (F2 + F3 + F7): Lower wall colliding in pixel space with a high-priority door/stairs cell is deduplicated by 0x6fba1528 weight', () => {
|
||
const lowerCoords = computeAutomapTilePixelCoords(3, 3, 16) // (0, 48)
|
||
const upperCoords = computeAutomapTilePixelCoords(6, 6, 8) // (0, 48)
|
||
expect(lowerCoords).toEqual(upperCoords)
|
||
|
||
const cells: AutomapCell[] = [
|
||
{
|
||
cellNo: 11, // ordinary wall weight 2
|
||
xPixel: lowerCoords.xPixel,
|
||
yPixel: lowerCoords.yPixel,
|
||
cellX: 3,
|
||
cellY: 3,
|
||
weight: getAutomapCellWeight(11),
|
||
},
|
||
{
|
||
cellNo: 520, // stairs weight 16
|
||
xPixel: upperCoords.xPixel,
|
||
yPixel: upperCoords.yPixel,
|
||
cellX: 6,
|
||
cellY: 6,
|
||
weight: getAutomapCellWeight(520),
|
||
},
|
||
]
|
||
const deduped = deduplicateAutomapCells(cells)
|
||
expect(deduped).toHaveLength(1)
|
||
expect(deduped[0]!.cellNo).toBe(520)
|
||
expect(deduped[0]!.weight).toBe(16)
|
||
})
|
||
|
||
it('C3 (F3 + F4 + F7): Special override icons (Campfire 405, Palace Cellar 305, Stash 319) survive deduplication over unweighted tiles', () => {
|
||
const campfireCellNo = resolveTileAutomapCellId(automapTable, {
|
||
levelId: 2,
|
||
levelTypeId: 2,
|
||
tileType: 0,
|
||
style: 8,
|
||
sequence: 0,
|
||
})
|
||
const palaceCellNo = resolveTileAutomapCellId(automapTable, {
|
||
levelId: 40,
|
||
levelTypeId: 12,
|
||
tileType: 8,
|
||
style: 25,
|
||
sequence: 0,
|
||
})
|
||
const stashCellNo = resolveObjectAutomapCellId({ objectClassId: 267, autoMapCol: 0 })
|
||
|
||
expect(campfireCellNo).toBe(405)
|
||
expect(palaceCellNo).toBe(305)
|
||
expect(stashCellNo).toBe(319)
|
||
|
||
const deduped = deduplicateAutomapCells([
|
||
{ cellNo: 90, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 }, // unweighted -1
|
||
{ cellNo: campfireCellNo, xPixel: 16, yPixel: 8, cellX: 2, cellY: 0 }, // 405
|
||
{ cellNo: 91, xPixel: 32, yPixel: 16, cellX: 4, cellY: 0 }, // unweighted -1
|
||
{ cellNo: palaceCellNo, xPixel: 32, yPixel: 16, cellX: 4, cellY: 0 }, // 305
|
||
{ cellNo: stashCellNo, xPixel: 48, yPixel: 24, cellX: 6, cellY: 0 }, // 319
|
||
{ cellNo: 92, xPixel: 48, yPixel: 24, cellX: 6, cellY: 0 }, // unweighted -1
|
||
])
|
||
expect(deduped.map(c => c.cellNo)).toEqual([405, 305, 319])
|
||
})
|
||
|
||
it('C4 (F5 + F6 + F8): Pre-baked town chunks (fSaved=1..3) + 8-sheet manifest frames + dual bMinimap=0/1 projection', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
|
||
for (const [levelId, fSaved, fullKey, halfKey, count] of [
|
||
[40, 1, 'Act2Map', 'Act2MapS', 14],
|
||
[103, 2, 'Act4Map', 'Act4MapS', 4],
|
||
[109, 3, 'ExTnMap', 'ExTnMapS', 6],
|
||
] as const) {
|
||
const extras = generateTownSavedExtras(levelId, 80, 0, 80)
|
||
expect(extras).toHaveLength(count)
|
||
expect(resolveAutomapSheetName(fSaved, false)).toBe(fullKey)
|
||
expect(resolveAutomapSheetName(fSaved, true)).toBe(halfKey)
|
||
|
||
const level: MinimapLevel = {
|
||
key: `c4-town-${String(levelId)}`,
|
||
cellsX: 64,
|
||
cellsY: 64,
|
||
blocked: new Uint8Array(64 * 64 * 25),
|
||
gridWidth: 320,
|
||
markers: [],
|
||
automap: {
|
||
layerId: levelId,
|
||
levelTypeId: 12,
|
||
floors: [],
|
||
walls: [],
|
||
objects: [],
|
||
extras,
|
||
},
|
||
}
|
||
|
||
// Full Automap (bMinimap=false -> fullKey)
|
||
minimap.mode = 'full'
|
||
const { renderer: rFull, draws: dFull } = createRecordingRenderer()
|
||
minimap.draw(rFull, level, 0, 0, { x: 0, y: 0, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(dFull.some(d => d.kind === 'sprite' && d.atlas?.texture === fullKey)).toBe(true)
|
||
|
||
// Corner Minimap (bMinimap=true -> halfKey)
|
||
minimap.mode = 'minimap'
|
||
const { renderer: rMini, draws: dMini } = createRecordingRenderer()
|
||
minimap.draw(rMini, level, 0, 0, { x: 0, y: 0, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(dMini.some(d => d.kind === 'sprite' && d.atlas?.texture === halfKey)).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('C5 (F8 + F9 + F11): Full Automap HUD panel shift (+-160px) + Arrow-key panning (+-8px) + 12-segment player crosshair shift', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
minimap.panBySteps(2, -1) // panX = +16, panY = -8
|
||
|
||
const vpLeft = computeAutomapViewport(800, 600, false, true, false, minimap.panX, minimap.panY)
|
||
expect(vpLeft.centerShiftX).toBe(160)
|
||
expect(vpLeft.centerX).toBe(560)
|
||
expect(vpLeft.effectivePanX).toBe(16)
|
||
expect(vpLeft.effectivePanY).toBe(-8)
|
||
|
||
// Player at (0, 0) -> offsetX = 0 - 560 + 16 = -544 -> playerScreenX = 0 - (-544) = 544
|
||
// offsetY = 0 - 276 + (-8) = -284 -> playerScreenY = 0 - (-284) = 284
|
||
const expectedSegs = getCrosshairSegments(544, 284, false)
|
||
expect(expectedSegs[0]).toEqual({ x0: 544, y0: 282, x1: 548, y1: 280 })
|
||
|
||
const level: MinimapLevel = {
|
||
key: 'c5-shift-pan',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 0, 0, 0, 0, true)
|
||
const { renderer, draws } = createRecordingRenderer()
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
0,
|
||
0,
|
||
{ x: 0, y: 0, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 0,
|
||
leftPanelOpen: true,
|
||
rightPanelOpen: false,
|
||
},
|
||
)
|
||
const quads = draws.filter(d => d.kind === 'quad')
|
||
expect(quads).toHaveLength(12)
|
||
})
|
||
|
||
it('C6 (F9 + F11): Corner Minimap right-panel shift (505,16 -> 16,16) + (-1, +5) crosshair adjustment', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'minimap'
|
||
const level: MinimapLevel = {
|
||
key: 'c6-mini-right-panel',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 0, 0, 0, 0, true)
|
||
const { renderer } = createRecordingRenderer()
|
||
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
0,
|
||
0,
|
||
{ x: 0, y: 0, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX: 0, playerWorldY: 0, rightPanelOpen: false },
|
||
)
|
||
expect(minimap.viewportLastFrame.x).toBe(505)
|
||
expect(minimap.viewportLastFrame.centerX).toBe(644)
|
||
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
0,
|
||
0,
|
||
{ x: 0, y: 0, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX: 0, playerWorldY: 0, rightPanelOpen: true },
|
||
)
|
||
expect(minimap.viewportLastFrame.x).toBe(16)
|
||
expect(minimap.viewportLastFrame.centerX).toBe(155)
|
||
expect(minimap.crosshairsDrawnLastFrame.player).toBe(1)
|
||
})
|
||
|
||
it('C7 (F10 + F12): Room fog-of-war progressive reveal unlocks warp destination labels only after player approaches within octile threshold', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'c7-fog-warp',
|
||
cellsX: 32,
|
||
cellsY: 32,
|
||
blocked: new Uint8Array(32 * 32 * 25),
|
||
gridWidth: 160,
|
||
markers: [
|
||
{ cellX: 3, cellY: 3, kind: 'exit', targetName: 'Near Cave' },
|
||
{ cellX: 26, cellY: 26, kind: 'exit', targetName: 'Far Dungeon' },
|
||
],
|
||
}
|
||
|
||
// Step 1: Reveal near (3, 3) -> only "Near Cave" label is emitted
|
||
minimap.reveal(level, 3, 3, 0, 240, true)
|
||
const { renderer } = createRecordingRenderer()
|
||
minimap.draw(renderer, level, 3, 3, { x: 0, y: 240, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(minimap.warpLabelsLastFrame.map(l => l.text)).toContain('Near Cave')
|
||
expect(minimap.warpLabelsLastFrame.map(l => l.text)).not.toContain('Far Dungeon')
|
||
|
||
// Step 2: Move to (26, 26) -> "Far Dungeon" room is revealed and label appears
|
||
minimap.reveal(level, 26, 26, 0, 2080)
|
||
minimap.draw(renderer, level, 26, 26, { x: 0, y: 2080, zoom: 1 }, { width: 800, height: 600 })
|
||
expect(minimap.warpLabelsLastFrame.map(l => l.text)).toContain('Far Dungeon')
|
||
})
|
||
|
||
it('C8 (F4 + F11 + F12): Level 4 Stony Field Cairn Stones Red Portal (cellNo 300) + Portal label + HUD overlay rendering', () => {
|
||
const redPortalCell = resolveObjectAutomapCellId({
|
||
objectClassId: 60,
|
||
autoMapCol: 0,
|
||
levelId: 4,
|
||
mode: 2,
|
||
})
|
||
expect(redPortalCell).toBe(300)
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'c8-stony-field',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
levelId: 4,
|
||
levelName: 'Stony Field',
|
||
difficulty: 'Hell',
|
||
markers: [],
|
||
automap: { layerId: 4, levelTypeId: 2, floors: [], walls: [], objects: [], extras: [] },
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
const { renderer, draws } = createRecordingRenderer()
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
4,
|
||
4,
|
||
{ x: 0, y: 320, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 320,
|
||
levelId: 4,
|
||
portals: [{ x: 0, y: 320, kind: 'red', label: 'Tristram' }],
|
||
},
|
||
)
|
||
|
||
const spriteDraws = draws.filter(d => d.kind === 'sprite')
|
||
expect(spriteDraws).toHaveLength(1)
|
||
const maxiMeta = minimap.getSheetMeta('MaxiMap')
|
||
expect(spriteDraws[0]!.frame?.x).toBe(maxiMeta.frames[300]!.x)
|
||
expect(minimap.warpLabelsLastFrame).toContainEqual(
|
||
expect.objectContaining({ text: 'Tristram', kind: 'portal', color: 'blue' }),
|
||
)
|
||
})
|
||
|
||
it('C9 (F8 + F9): F9 Fade Mode cycling (1.0 -> 0.75 -> 0.5) propagates exact tint alpha to all rendered sprite layers and portals', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const coords = computeAutomapTilePixelCoords(4, 4, 0)
|
||
const level: MinimapLevel = {
|
||
key: 'c9-fade-tint',
|
||
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 }],
|
||
walls: [{ cellNo: 46, xPixel: coords.xPixel, yPixel: coords.yPixel, cellX: 4, cellY: 4, fSaved: 0 }],
|
||
objects: [],
|
||
extras: [],
|
||
},
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
|
||
for (const expectedAlpha of [1.0, 0.75, 0.5]) {
|
||
const { renderer, draws } = createRecordingRenderer()
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
4,
|
||
4,
|
||
{ x: 0, y: 320, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 320,
|
||
portals: [{ x: 0, y: 320 }],
|
||
},
|
||
)
|
||
const sprites = draws.filter(d => d.kind === 'sprite')
|
||
expect(sprites.length).toBeGreaterThanOrEqual(3)
|
||
for (const s of sprites) {
|
||
expect(s.tint).toEqual([1, 1, 1, expectedAlpha])
|
||
}
|
||
minimap.cycleFade()
|
||
}
|
||
})
|
||
|
||
it('C10 (F9 + F11 + F12): F10 showNames toggle controls party/NPC Font6 labels while preserving crosshairs and warp/stash labels', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'c10-f10-toggle',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
levelName: 'Rogue Encampment',
|
||
difficulty: 'Normal',
|
||
markers: [{ cellX: 4, cellY: 4, kind: 'exit', targetName: 'Blood Moor' }],
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
|
||
// Toggle F10 off -> showNames = false
|
||
minimap.toggleNames()
|
||
expect(minimap.showNames).toBe(false)
|
||
|
||
const { renderer } = createRecordingRenderer()
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
4,
|
||
4,
|
||
{ x: 0, y: 320, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 320,
|
||
entities: [{ x: 0, y: 320, kind: 'npc', name: 'Charsi' }],
|
||
stashes: [{ x: 0, y: 320, label: 'Stash' }],
|
||
},
|
||
)
|
||
|
||
// Crosshairs are still drawn, warp & stash labels still collected, but entityLabels is empty
|
||
expect(minimap.crosshairsDrawnLastFrame.npcs).toBe(1)
|
||
expect(minimap.lastRenderStats.entityLabels).toHaveLength(0)
|
||
expect(minimap.warpLabelsLastFrame.map(l => l.text)).toEqual(
|
||
expect.arrayContaining(['Blood Moor', 'Stash']),
|
||
)
|
||
})
|
||
|
||
it('C11 (F4 + F7 + F8): Live DS1 buildLevelAutomap + deduplicateAutomapCells + Minimap 4-layer draw pipeline', async () => {
|
||
const ds1Entry = dataArchive.find('data\\global\\tiles\\ACT1\\TOWN\\townE1.ds1')
|
||
const ds1 = decodeDs1(await dataArchive.read(ds1Entry!))
|
||
const automap = buildLevelAutomap(ds1, {
|
||
automapTable,
|
||
levelId: 1,
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
act: 1,
|
||
seed: 99,
|
||
waypoints: [{ x: 60, y: 60 }],
|
||
})
|
||
|
||
const ref = automap.floors[0] ?? automap.walls[0]!
|
||
const wx = (ref.cellX - ref.cellY) * 80
|
||
const wy = (ref.cellX + ref.cellY) * 40
|
||
const level: MinimapLevel = {
|
||
key: 'c11-live-ds1',
|
||
cellsX: ds1.width,
|
||
cellsY: ds1.height,
|
||
blocked: new Uint8Array(ds1.width * ds1.height * 25),
|
||
gridWidth: ds1.width * 5,
|
||
automap,
|
||
levelId: 1,
|
||
levelName: 'Rogue Encampment',
|
||
difficulty: 'Normal',
|
||
markers: [],
|
||
}
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.reveal(level, ref.cellX, ref.cellY, wx, wy, true)
|
||
minimap.mode = 'full'
|
||
const { renderer, draws } = createRecordingRenderer()
|
||
minimap.draw(renderer, level, ref.cellX, ref.cellY, { x: wx, y: wy, zoom: 1 }, { width: 800, height: 600 }, { playerWorldX: wx, playerWorldY: wy })
|
||
|
||
expect(minimap.spritesDrawnLastFrame.total).toBeGreaterThan(0)
|
||
expect(draws.filter(d => d.kind === 'sprite')).toHaveLength(minimap.spritesDrawnLastFrame.total)
|
||
})
|
||
|
||
it('C12 (F6 + F8 + F9): Switching between Full Automap (bMinimap=0) and Corner Minimap (bMinimap=1) halves screen offsets and switches sheet pairs', () => {
|
||
const cellCoord = computeAutomapTilePixelCoords(10, 6, 0) // xPixel = 32, yPixel = 64
|
||
const fullMeta = AUTOMAP_UI_MANIFEST.atlases.MaxiMap.frames[61]!
|
||
const halfMeta = AUTOMAP_UI_MANIFEST.atlases.MaxiMapS.frames[61]!
|
||
|
||
const fullRect = projectAutomapCellScreenRect(cellCoord, fullMeta, 0, 0, false)
|
||
const halfRect = projectAutomapCellScreenRect(cellCoord, halfMeta, 0, 0, true)
|
||
|
||
expect(fullRect.screenX).toBe(32)
|
||
expect(fullRect.screenY).toBe(64)
|
||
expect(halfRect.screenX).toBe(16)
|
||
expect(halfRect.screenY).toBe(32)
|
||
})
|
||
|
||
it('C13 (F4 + F11): Deckard Cain monster override (267) + live NPC crosshair + party member green crosshair + Boss red crosshair', () => {
|
||
for (const cainClassId of [146, 254, 265, 266, 473, 532, 540]) {
|
||
expect(
|
||
resolveMonsterAutomapCellId({
|
||
monsterClassId: cainClassId,
|
||
automapCel: 0,
|
||
noMap: true,
|
||
isAlive: true,
|
||
}),
|
||
).toBe(267)
|
||
}
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const cainCoords = computeAutomapObjectPixelCoords(20, 20)
|
||
const level: MinimapLevel = {
|
||
key: 'c13-cain-entities',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
automap: {
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
floors: [],
|
||
walls: [],
|
||
objects: [{ cellNo: 267, xPixel: cainCoords.xPixel, yPixel: cainCoords.yPixel, cellX: 4, cellY: 4, fSaved: 0 }],
|
||
extras: [],
|
||
},
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
const { renderer, draws } = createRecordingRenderer()
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
4,
|
||
4,
|
||
{ x: 0, y: 320, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 320,
|
||
entities: [
|
||
{ x: 0, y: 320, kind: 'npc', name: 'Deckard Cain' },
|
||
{ x: 16, y: 320, kind: 'party', name: 'PaladinFriend' },
|
||
{ x: -16, y: 320, kind: 'superunique', name: 'Rakanishu' },
|
||
],
|
||
},
|
||
)
|
||
|
||
expect(minimap.spritesDrawnLastFrame.objects).toBe(1)
|
||
expect(minimap.crosshairsDrawnLastFrame).toEqual({
|
||
player: 1,
|
||
party: 1,
|
||
npcs: 1,
|
||
mercenaries: 0,
|
||
bosses: 1,
|
||
total: 4,
|
||
})
|
||
expect(draws.filter(d => d.kind === 'quad')).toHaveLength(4 * 12)
|
||
})
|
||
|
||
it('C14 (F7 + F8 + F10): Non-zero level origin (originX, originY) aligns world coordinates with automap cells and crosshairs', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const cellCoord = computeAutomapTilePixelCoords(5, 5, 0) // xPixel=0, yPixel=40
|
||
const originX = 4000
|
||
const originY = -2000
|
||
// Player at cell (5, 5) relative to origin -> worldX = 4000 + 0, worldY = -2000 + 400 = -1600
|
||
const playerWorldX = originX + (5 - 5) * 80
|
||
const playerWorldY = originY + (5 + 5) * 40
|
||
|
||
const level: MinimapLevel = {
|
||
key: 'c14-origin-offset',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
originX,
|
||
originY,
|
||
markers: [],
|
||
automap: {
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
floors: [{ cellNo: 1, xPixel: cellCoord.xPixel, yPixel: cellCoord.yPixel, cellX: 5, cellY: 5, fSaved: 0 }],
|
||
walls: [],
|
||
objects: [],
|
||
extras: [],
|
||
},
|
||
}
|
||
minimap.reveal(level, 5, 5, playerWorldX, playerWorldY, true)
|
||
const { renderer } = createRecordingRenderer()
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
5,
|
||
5,
|
||
{ x: playerWorldX, y: playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX, playerWorldY },
|
||
)
|
||
expect(minimap.spritesDrawnLastFrame.floors).toBe(1)
|
||
expect(minimap.crosshairsDrawnLastFrame.player).toBe(1)
|
||
})
|
||
|
||
it('C15 (F8 + F11): Camera zoom scaling (> 1) scales world-space sprite dimensions and crosshair line thickness inversely', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const coords = computeAutomapTilePixelCoords(4, 4, 0)
|
||
const level: MinimapLevel = {
|
||
key: 'c15-zoom-scaling',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
markers: [],
|
||
automap: {
|
||
layerId: 1,
|
||
levelTypeId: 1,
|
||
floors: [{ 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)
|
||
|
||
let drawnWidthZoom1 = 0
|
||
let drawnWidthZoom2 = 0
|
||
const makeZoomRenderer = (onWidth: (w: number) => void): SpriteRenderer =>
|
||
({
|
||
flush() {},
|
||
draw(_f: AtlasFrame, _x: number, _y: number, opts?: { width?: number }) {
|
||
if (opts?.width !== undefined) onWidth(opts.width)
|
||
},
|
||
drawSolid() {},
|
||
drawSolidQuad() {},
|
||
}) as unknown as SpriteRenderer
|
||
|
||
minimap.draw(makeZoomRenderer(w => { drawnWidthZoom1 = w }), level, 4, 4, { x: 0, y: 320, zoom: 1 }, { width: 800, height: 600 }, { playerWorldX: 0, playerWorldY: 320 })
|
||
minimap.draw(makeZoomRenderer(w => { drawnWidthZoom2 = w }), level, 4, 4, { x: 0, y: 320, zoom: 2 }, { width: 800, height: 600 }, { playerWorldX: 0, playerWorldY: 320 })
|
||
|
||
expect(drawnWidthZoom1).toBeGreaterThan(0)
|
||
expect(drawnWidthZoom2).toBeCloseTo(drawnWidthZoom1 / 2, 5)
|
||
})
|
||
|
||
it('C16 (F2 + F4 + F7): Multi-variant SplitMix64 spatial hash + tile override precedence across Act 1 Wilderness and Act 2 Town', () => {
|
||
// In Level 2 (Act 1 Wilderness), style=8 seq=0 is overridden to Campfire (405) regardless of seed/coords
|
||
expect(
|
||
resolveTileAutomapCellId(automapTable, {
|
||
levelId: 2,
|
||
levelTypeId: 2,
|
||
tileType: 0,
|
||
style: 8,
|
||
sequence: 0,
|
||
cellX: 10,
|
||
cellY: 20,
|
||
seed: 777,
|
||
}),
|
||
).toBe(405)
|
||
|
||
// In Level 3 (Cold Plains, also levelTypeId=2), style=8 seq=0 uses normal AutoMap.txt lookup
|
||
const coldPlainsCell = resolveTileAutomapCellId(automapTable, {
|
||
levelId: 3,
|
||
levelTypeId: 2,
|
||
tileType: 0,
|
||
style: 8,
|
||
sequence: 0,
|
||
cellX: 10,
|
||
cellY: 20,
|
||
seed: 777,
|
||
})
|
||
expect(coldPlainsCell).toBe(
|
||
getAutomapCellId(automapTable, 2, 0, 8, 0, 10, 20, 777),
|
||
)
|
||
})
|
||
|
||
it('C17 (F9 + F12): Widescreen / custom logical canvas dimensions in drawHudOverlay align top-right Font16 HUD text to logicalWidth - 16', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
const level: MinimapLevel = {
|
||
key: 'c17-widescreen-hud',
|
||
cellsX: 16,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(16 * 16 * 25),
|
||
gridWidth: 80,
|
||
levelName: 'Harrogath',
|
||
difficulty: 'Hell',
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 4, 4, 0, 320, true)
|
||
const { renderer } = createRecordingRenderer()
|
||
minimap.draw(renderer, level, 4, 4, { x: 0, y: 320, zoom: 1 }, { width: 800, height: 600 })
|
||
|
||
const xPositions: number[] = []
|
||
const mockFont = {
|
||
drawText(_ctx: CanvasRenderingContext2D, _text: string, x: number) {
|
||
xPositions.push(x)
|
||
},
|
||
} as unknown as D2FontRenderer
|
||
|
||
minimap.drawHudOverlay({} as CanvasRenderingContext2D, mockFont, 1066, 600)
|
||
expect(xPositions).toEqual([1066 - 16, 1066 - 16])
|
||
})
|
||
|
||
it('C18 (F5 + F8 + F9 + F11 + F12): Full Town Scene integration (Act 2 Lut Gholein fSaved=1 chunks + Palace Cellar 305 + Stash 319 + Portal 306 + Cain 267 + HUD overlay)', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
minimap.mode = 'full'
|
||
|
||
const extras = generateTownSavedExtras(40, 160, 0, 160)
|
||
const palaceCell = resolveTileAutomapCellId(automapTable, {
|
||
levelId: 40,
|
||
levelTypeId: 12,
|
||
tileType: 8,
|
||
style: 25,
|
||
sequence: 0,
|
||
})
|
||
const stashCell = resolveObjectAutomapCellId({ objectClassId: 267, autoMapCol: 0 })
|
||
const cainCell = resolveMonsterAutomapCellId({ monsterClassId: 254, automapCel: 0, noMap: true })
|
||
const centerCoords = computeAutomapTilePixelCoords(0, 0, 0)
|
||
|
||
const level: MinimapLevel = {
|
||
key: 'c18-lut-gholein-full',
|
||
cellsX: 40,
|
||
cellsY: 40,
|
||
blocked: new Uint8Array(40 * 40 * 25),
|
||
gridWidth: 200,
|
||
levelId: 40,
|
||
levelName: 'Lut Gholein',
|
||
difficulty: 'Nightmare',
|
||
markers: [{ cellX: 0, cellY: 0, kind: 'exit', targetName: 'Sewers Level 1' }],
|
||
automap: {
|
||
layerId: 40,
|
||
levelTypeId: 12,
|
||
floors: [{ cellNo: 1, xPixel: centerCoords.xPixel, yPixel: centerCoords.yPixel, cellX: 0, cellY: 0, fSaved: 0 }],
|
||
walls: [{ cellNo: palaceCell, xPixel: centerCoords.xPixel, yPixel: centerCoords.yPixel, cellX: 0, cellY: 0, fSaved: 0 }],
|
||
objects: [
|
||
{ cellNo: stashCell, xPixel: centerCoords.xPixel, yPixel: centerCoords.yPixel, cellX: 0, cellY: 0, fSaved: 0 },
|
||
{ cellNo: cainCell, xPixel: centerCoords.xPixel + 8, yPixel: centerCoords.yPixel, cellX: 0, cellY: 0, fSaved: 0 },
|
||
],
|
||
extras,
|
||
},
|
||
}
|
||
|
||
minimap.reveal(level, 0, 0, 0, 0, true)
|
||
const { renderer } = createRecordingRenderer()
|
||
minimap.draw(
|
||
renderer,
|
||
level,
|
||
0,
|
||
0,
|
||
{ x: 0, y: 0, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: 0,
|
||
playerWorldY: 0,
|
||
levelId: 40,
|
||
portals: [{ x: 0, y: 0, isLocalPlayerOwner: true, label: 'Town Portal' }],
|
||
stashes: [{ x: 0, y: 0, label: 'Stash' }],
|
||
entities: [{ x: 0, y: 0, kind: 'npc', name: 'Atma' }],
|
||
},
|
||
)
|
||
|
||
expect(minimap.spritesDrawnLastFrame.floors).toBe(1)
|
||
expect(minimap.spritesDrawnLastFrame.walls).toBe(1)
|
||
expect(minimap.spritesDrawnLastFrame.objects).toBe(2)
|
||
expect(minimap.spritesDrawnLastFrame.extras).toBeGreaterThan(0)
|
||
expect(minimap.spritesDrawnLastFrame.portals).toBe(1)
|
||
expect(minimap.crosshairsDrawnLastFrame.player).toBe(1)
|
||
expect(minimap.crosshairsDrawnLastFrame.npcs).toBe(1)
|
||
expect(minimap.warpLabelsLastFrame.map(l => l.text)).toEqual(
|
||
expect.arrayContaining(['Sewers Level 1', 'Stash', 'Town Portal']),
|
||
)
|
||
|
||
const hudCalls: { text: string; color?: D2ColorCode }[] = []
|
||
const mockFont = {
|
||
drawText(_ctx: CanvasRenderingContext2D, text: string, _x: number, _y: number, opts?: { color?: D2ColorCode }) {
|
||
hudCalls.push({ text, ...(opts?.color ? { color: opts.color } : {}) })
|
||
},
|
||
} as unknown as D2FontRenderer
|
||
minimap.drawHudOverlay({} as CanvasRenderingContext2D, mockFont, 800, 600)
|
||
expect(hudCalls.map(c => c.text)).toEqual(
|
||
expect.arrayContaining(['Lut Gholein', 'Nightmare', 'Sewers Level 1', 'Stash', 'Atma']),
|
||
)
|
||
})
|
||
})
|