diablo2-web/tests/e2e-automap-113c/tier3-cross-feature-combina...

866 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Tier 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']),
)
})
})