897 lines
30 KiB
TypeScript
897 lines
30 KiB
TypeScript
/**
|
||
* Tier 4 — Real-World E2E Scenarios (`tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts`).
|
||
*
|
||
* Covers 12 realistic end-to-end gameplay and full-spectrum 136-level verification scenarios
|
||
* (`>= 10` required) exercising the complete Diablo II 1.13c Isometric Automap & Minimap pipeline.
|
||
*/
|
||
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import { beforeAll, describe, expect, it } from 'vitest'
|
||
import { MpqArchive, memorySource } from '../../src/mpq/index.ts'
|
||
import {
|
||
type AutomapLayerData,
|
||
type AutomapTable,
|
||
computeAutomapTilePixelCoords,
|
||
parseAutomapTable,
|
||
} from '../../src/formats/automap-table.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 {
|
||
AUTOMAP_CROSSHAIR_COLORS,
|
||
AUTOMAP_FADE_ALPHAS,
|
||
AUTOMAP_PAN_STEP_PX,
|
||
type AutomapEntityMarker,
|
||
Minimap,
|
||
type MinimapLevel,
|
||
resolveAutomapSheetName,
|
||
} from '../../src/ui/minimap.ts'
|
||
|
||
const ROOT_DIR = process.cwd()
|
||
const PACK_DIR = path.resolve(ROOT_DIR, 'samples/d2-packs')
|
||
const MPQ_EXP_PATH = path.resolve(ROOT_DIR, 'samples/d2/d2exp.mpq')
|
||
|
||
interface PackIndexEntry {
|
||
readonly act: number
|
||
readonly levelId: number
|
||
readonly slug: string
|
||
readonly label: string
|
||
readonly levelName: string
|
||
readonly path: string
|
||
readonly kind?: string
|
||
}
|
||
|
||
interface PackedSceneJson {
|
||
readonly act: number
|
||
readonly levelId: number
|
||
readonly levelName: string
|
||
readonly cellsX: number
|
||
readonly cellsY: number
|
||
readonly originX: number
|
||
readonly originY: number
|
||
readonly widthPx: number
|
||
readonly heightPx: number
|
||
readonly collision: {
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly runs: readonly (readonly number[])[]
|
||
}
|
||
readonly spawn: readonly number[] | null
|
||
readonly entrances?: readonly { readonly x: number; readonly y: number; readonly toLevelId?: number }[]
|
||
readonly warps?: readonly { readonly x: number; readonly y: number; readonly toLevelId?: number }[]
|
||
readonly waypoints?: readonly { readonly x: number; readonly y: number; readonly waypointId?: number }[]
|
||
readonly automap?: AutomapLayerData
|
||
}
|
||
|
||
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]
|
||
}
|
||
|
||
function createScenarioRenderer(): {
|
||
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', x: x0, y: 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)
|
||
}
|
||
}
|
||
|
||
let packIndex: { readonly levels: readonly PackIndexEntry[] }
|
||
let entriesByLevelId: Map<number, PackIndexEntry[]>
|
||
let automapTable: AutomapTable
|
||
|
||
function loadSceneByLevelId(levelId: number, variantIndex = 0): {
|
||
entry: PackIndexEntry
|
||
scene: PackedSceneJson
|
||
levelModel: MinimapLevel
|
||
playerCellX: number
|
||
playerCellY: number
|
||
playerWorldX: number
|
||
playerWorldY: number
|
||
} {
|
||
const entries = entriesByLevelId.get(levelId) ?? []
|
||
if (entries.length === 0) {
|
||
throw new Error(`No baked scene found for LevelId ${String(levelId)}`)
|
||
}
|
||
const entry = entries[Math.min(variantIndex, entries.length - 1)]!
|
||
const sceneRaw = fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8')
|
||
const scene = JSON.parse(sceneRaw) as PackedSceneJson
|
||
const automap = scene.automap!
|
||
|
||
const refCell =
|
||
automap.floors[0] ?? automap.walls[0] ?? automap.objects[0] ?? automap.extras[0]!
|
||
const playerCellX = Math.max(
|
||
0,
|
||
Math.min(scene.cellsX - 1, refCell.cellX ?? Math.floor(scene.cellsX / 2)),
|
||
)
|
||
const playerCellY = Math.max(
|
||
0,
|
||
Math.min(scene.cellsY - 1, refCell.cellY ?? Math.floor(scene.cellsY / 2)),
|
||
)
|
||
const playerWorldX = scene.originX + (playerCellX - playerCellY) * 80
|
||
const playerWorldY = scene.originY + (playerCellX + playerCellY) * 40
|
||
|
||
const levelModel: MinimapLevel = {
|
||
key: `e2e:${entry.path}`,
|
||
cellsX: scene.cellsX,
|
||
cellsY: scene.cellsY,
|
||
blocked: new Uint8Array(scene.collision.width * scene.collision.height),
|
||
gridWidth: scene.collision.width,
|
||
originX: scene.originX,
|
||
originY: scene.originY,
|
||
automap,
|
||
levelId,
|
||
levelName: scene.levelName,
|
||
difficulty: 'Normal',
|
||
markers: [
|
||
...(scene.warps ?? []).map(w => ({
|
||
cellX: Math.floor(w.x / 5),
|
||
cellY: Math.floor(w.y / 5),
|
||
kind: 'exit' as const,
|
||
...(w.toLevelId !== undefined ? { toLevelId: w.toLevelId } : {}),
|
||
targetName: `Level ${String(w.toLevelId ?? 0)}`,
|
||
})),
|
||
],
|
||
}
|
||
|
||
return {
|
||
entry,
|
||
scene,
|
||
levelModel,
|
||
playerCellX,
|
||
playerCellY,
|
||
playerWorldX,
|
||
playerWorldY,
|
||
}
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
const indexRaw = fs.readFileSync(path.join(PACK_DIR, 'index.json'), 'utf8')
|
||
packIndex = JSON.parse(indexRaw) as { readonly levels: readonly PackIndexEntry[] }
|
||
entriesByLevelId = new Map<number, PackIndexEntry[]>()
|
||
for (const entry of packIndex.levels) {
|
||
const list = entriesByLevelId.get(entry.levelId) ?? []
|
||
list.push(entry)
|
||
entriesByLevelId.set(entry.levelId, list)
|
||
}
|
||
|
||
const expArchive = await MpqArchive.open(
|
||
memorySource(new Uint8Array(fs.readFileSync(MPQ_EXP_PATH))),
|
||
)
|
||
const autoMapEntry = expArchive.find('data\\global\\excel\\AutoMap.txt')
|
||
if (!autoMapEntry) throw new Error('AutoMap.txt missing in d2exp.mpq')
|
||
automapTable = parseAutomapTable(await expArchive.read(autoMapEntry))
|
||
})
|
||
|
||
describe('Tier 4 — Real-World E2E Scenarios (12 end-to-end scenarios across Acts 1–5 & all 136 Level IDs)', () => {
|
||
it('S1: Act 1 Rogue Encampment (LevelId=1) to Blood Moor (LevelId=2) — town icons, MaxiMap/MaxiMapS sheets, and mode switch', () => {
|
||
expect(automapTable.rows.length).toBeGreaterThan(3000)
|
||
|
||
const town = loadSceneByLevelId(1)
|
||
const bloodMoor = loadSceneByLevelId(2)
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer } = createScenarioRenderer()
|
||
|
||
// Reveal Act 1 Town and render in Full Automap (bMinimap=false, nDivisor=10)
|
||
minimap.mode = 'full'
|
||
minimap.reveal(
|
||
town.levelModel,
|
||
town.playerCellX,
|
||
town.playerCellY,
|
||
town.playerWorldX,
|
||
town.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
town.levelModel,
|
||
town.playerCellX,
|
||
town.playerCellY,
|
||
{ x: town.playerWorldX, y: town.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX: town.playerWorldX, playerWorldY: town.playerWorldY, levelId: 1 },
|
||
)
|
||
expect(minimap.lastRenderStats.divisor).toBe(10)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(20)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.player).toBe(1)
|
||
expect(minimap.lastRenderStats.hudText?.levelName).toBe(town.scene.levelName)
|
||
|
||
// Switch to Blood Moor in Corner Minimap (bMinimap=true, nDivisor=20)
|
||
minimap.mode = 'minimap'
|
||
minimap.reveal(
|
||
bloodMoor.levelModel,
|
||
bloodMoor.playerCellX,
|
||
bloodMoor.playerCellY,
|
||
bloodMoor.playerWorldX,
|
||
bloodMoor.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
bloodMoor.levelModel,
|
||
bloodMoor.playerCellX,
|
||
bloodMoor.playerCellY,
|
||
{ x: bloodMoor.playerWorldX, y: bloodMoor.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: bloodMoor.playerWorldX,
|
||
playerWorldY: bloodMoor.playerWorldY,
|
||
levelId: 2,
|
||
},
|
||
)
|
||
expect(minimap.lastRenderStats.divisor).toBe(20)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(0)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.player).toBe(1)
|
||
})
|
||
|
||
it('S2: Act 2 Lut Gholein (LevelId=40) to Sewers Level 1 (LevelId=47) & Palace Cellar (LevelId=50) — fSaved=1 town extras & Act2Map routing', () => {
|
||
const lutGholein = loadSceneByLevelId(40)
|
||
const sewers1 = loadSceneByLevelId(47)
|
||
const palaceCellar1 = loadSceneByLevelId(50)
|
||
|
||
// Lut Gholein has pre-revealed fSaved=1 Act2Map town extras
|
||
const act2Extras = lutGholein.scene.automap!.extras.filter(c => c.fSaved === 1)
|
||
expect(act2Extras.length).toBeGreaterThan(0)
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer, draws } = createScenarioRenderer()
|
||
|
||
// Even before revealing the player's room, pre-revealed fSaved=1 extras in Lut Gholein are drawn
|
||
// when camera is centered near them
|
||
const firstExtra = act2Extras[0]!
|
||
const extraCellX = firstExtra.cellX ?? 10
|
||
const extraCellY = firstExtra.cellY ?? 10
|
||
const extraWorldX = lutGholein.scene.originX + (extraCellX - extraCellY) * 80
|
||
const extraWorldY = lutGholein.scene.originY + (extraCellX + extraCellY) * 40
|
||
|
||
minimap.mode = 'full'
|
||
minimap.draw(
|
||
renderer,
|
||
lutGholein.levelModel,
|
||
extraCellX,
|
||
extraCellY,
|
||
{ x: extraWorldX, y: extraWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX: extraWorldX, playerWorldY: extraWorldY, levelId: 40 },
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.extras).toBeGreaterThan(0)
|
||
expect(draws.some(d => d.kind === 'sprite' && (d.atlas?.texture as unknown) === 'Act2Map')).toBe(
|
||
true,
|
||
)
|
||
|
||
// Transition to Sewers Level 1 (LevelId=47) and Palace Cellar Level 1 (LevelId=50)
|
||
for (const target of [sewers1, palaceCellar1]) {
|
||
draws.length = 0
|
||
minimap.mode = 'minimap'
|
||
minimap.reveal(
|
||
target.levelModel,
|
||
target.playerCellX,
|
||
target.playerCellY,
|
||
target.playerWorldX,
|
||
target.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
target.levelModel,
|
||
target.playerCellX,
|
||
target.playerCellY,
|
||
{ x: target.playerWorldX, y: target.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: target.playerWorldX,
|
||
playerWorldY: target.playerWorldY,
|
||
levelId: target.scene.levelId,
|
||
},
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('S3: Act 3 Kurast Docks (LevelId=75) & Travincal (LevelId=83) — multi-layer pass ordering & HUD overlay', () => {
|
||
const kurast = loadSceneByLevelId(75)
|
||
const travincal = loadSceneByLevelId(83)
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer } = createScenarioRenderer()
|
||
|
||
minimap.mode = 'full'
|
||
minimap.reveal(
|
||
kurast.levelModel,
|
||
kurast.playerCellX,
|
||
kurast.playerCellY,
|
||
kurast.playerWorldX,
|
||
kurast.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
kurast.levelModel,
|
||
kurast.playerCellX,
|
||
kurast.playerCellY,
|
||
{ x: kurast.playerWorldX, y: kurast.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: kurast.playerWorldX,
|
||
playerWorldY: kurast.playerWorldY,
|
||
levelId: 75,
|
||
difficulty: 'Nightmare',
|
||
},
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(0)
|
||
expect(minimap.lastRenderStats.hudText?.levelName).toBe(kurast.scene.levelName)
|
||
expect(minimap.lastRenderStats.hudText?.difficulty).toBe('Nightmare')
|
||
|
||
minimap.reveal(
|
||
travincal.levelModel,
|
||
travincal.playerCellX,
|
||
travincal.playerCellY,
|
||
travincal.playerWorldX,
|
||
travincal.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
travincal.levelModel,
|
||
travincal.playerCellX,
|
||
travincal.playerCellY,
|
||
{ x: travincal.playerWorldX, y: travincal.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: travincal.playerWorldX,
|
||
playerWorldY: travincal.playerWorldY,
|
||
levelId: 83,
|
||
difficulty: 'Hell',
|
||
},
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(0)
|
||
expect(minimap.lastRenderStats.hudText?.difficulty).toBe('Hell')
|
||
})
|
||
|
||
it('S4: Act 4 Pandemonium Fortress (LevelId=103) to Chaos Sanctuary (LevelId=108) — fSaved=2 Act4Map extras & Chaos Sanctuary rendering', () => {
|
||
const fortress = loadSceneByLevelId(103)
|
||
const chaos = loadSceneByLevelId(108)
|
||
|
||
const act4Extras = fortress.scene.automap!.extras.filter(c => c.fSaved === 2)
|
||
expect(act4Extras.length).toBe(4)
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer, draws } = createScenarioRenderer()
|
||
|
||
minimap.mode = 'full'
|
||
minimap.reveal(
|
||
fortress.levelModel,
|
||
fortress.playerCellX,
|
||
fortress.playerCellY,
|
||
fortress.playerWorldX,
|
||
fortress.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
fortress.levelModel,
|
||
fortress.playerCellX,
|
||
fortress.playerCellY,
|
||
{ x: fortress.playerWorldX, y: fortress.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX: fortress.playerWorldX, playerWorldY: fortress.playerWorldY, levelId: 103 },
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(0)
|
||
expect(draws.some(d => d.kind === 'sprite' && (d.atlas?.texture as unknown) === 'Act4Map')).toBe(
|
||
true,
|
||
)
|
||
|
||
minimap.reveal(
|
||
chaos.levelModel,
|
||
chaos.playerCellX,
|
||
chaos.playerCellY,
|
||
chaos.playerWorldX,
|
||
chaos.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
chaos.levelModel,
|
||
chaos.playerCellX,
|
||
chaos.playerCellY,
|
||
{ x: chaos.playerWorldX, y: chaos.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX: chaos.playerWorldX, playerWorldY: chaos.playerWorldY, levelId: 108 },
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('S5: Act 5 Harrogath (LevelId=109) to Worldstone Chamber (LevelId=132) & Uber Tristram (LevelId=136) — fSaved=3 ExTnMap extras & expansion levels', () => {
|
||
const harrogath = loadSceneByLevelId(109)
|
||
const worldstone = loadSceneByLevelId(132)
|
||
const uberTristram = loadSceneByLevelId(136)
|
||
|
||
const act5Extras = harrogath.scene.automap!.extras.filter(c => c.fSaved === 3)
|
||
expect(act5Extras.length).toBe(6)
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer, draws } = createScenarioRenderer()
|
||
|
||
minimap.mode = 'full'
|
||
minimap.reveal(
|
||
harrogath.levelModel,
|
||
harrogath.playerCellX,
|
||
harrogath.playerCellY,
|
||
harrogath.playerWorldX,
|
||
harrogath.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
harrogath.levelModel,
|
||
harrogath.playerCellX,
|
||
harrogath.playerCellY,
|
||
{ x: harrogath.playerWorldX, y: harrogath.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: harrogath.playerWorldX,
|
||
playerWorldY: harrogath.playerWorldY,
|
||
levelId: 109,
|
||
},
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(0)
|
||
expect(draws.some(d => d.kind === 'sprite' && (d.atlas?.texture as unknown) === 'ExTnMap')).toBe(
|
||
true,
|
||
)
|
||
|
||
for (const target of [worldstone, uberTristram]) {
|
||
minimap.reveal(
|
||
target.levelModel,
|
||
target.playerCellX,
|
||
target.playerCellY,
|
||
target.playerWorldX,
|
||
target.playerWorldY,
|
||
true,
|
||
)
|
||
minimap.draw(
|
||
renderer,
|
||
target.levelModel,
|
||
target.playerCellX,
|
||
target.playerCellY,
|
||
{ x: target.playerWorldX, y: target.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: target.playerWorldX,
|
||
playerWorldY: target.playerWorldY,
|
||
levelId: target.scene.levelId,
|
||
},
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.total).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('S6: Progressive Fog-of-War Dungeon Exploration Walk — step-by-step 160px threshold room discovery & irreversible fog persistence', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer } = createScenarioRenderer()
|
||
minimap.mode = 'full'
|
||
|
||
// Construct a 3-room dungeon corridor: Room (0,0) -> Room (2,0) -> Room (4,0)
|
||
const r0 = computeAutomapTilePixelCoords(4, 4, 0)
|
||
const r2 = computeAutomapTilePixelCoords(20, 4, 0)
|
||
const r4 = computeAutomapTilePixelCoords(36, 4, 0)
|
||
|
||
const dungeon: MinimapLevel = {
|
||
key: 'e2e-progressive-walk',
|
||
cellsX: 48,
|
||
cellsY: 16,
|
||
blocked: new Uint8Array(48 * 5 * 16 * 5),
|
||
gridWidth: 48 * 5,
|
||
originX: 0,
|
||
originY: 0,
|
||
levelId: 8,
|
||
levelName: 'Den of Evil',
|
||
difficulty: 'Normal',
|
||
automap: {
|
||
layerId: 8,
|
||
levelTypeId: 3,
|
||
floors: [
|
||
{ cellNo: 5, xPixel: r0.xPixel, yPixel: r0.yPixel, cellX: 4, cellY: 4, fSaved: 0, weight: 0 },
|
||
{ cellNo: 5, xPixel: r2.xPixel, yPixel: r2.yPixel, cellX: 20, cellY: 4, fSaved: 0, weight: 0 },
|
||
{ cellNo: 5, xPixel: r4.xPixel, yPixel: r4.yPixel, cellX: 36, cellY: 4, fSaved: 0, weight: 0 },
|
||
],
|
||
walls: [],
|
||
objects: [],
|
||
extras: [],
|
||
},
|
||
markers: [
|
||
{ cellX: 36, cellY: 4, kind: 'exit', toLevelId: 2, targetName: 'Blood Moor' },
|
||
],
|
||
}
|
||
|
||
// Step 1: Spawn at Room (0,0) — only 1 floor cell visible, exit in Room (4,0) hidden
|
||
minimap.reveal(dungeon, 4, 4, 0, 0, true)
|
||
minimap.draw(renderer, dungeon, 4, 4, { x: 0, y: 0, zoom: 1 }, { width: 1600, height: 1200 }, {
|
||
playerWorldX: 0,
|
||
playerWorldY: 0,
|
||
})
|
||
expect(minimap.lastRenderStats.spritesDrawn.floors).toBe(1)
|
||
expect(minimap.lastRenderStats.warpLabels).toHaveLength(0)
|
||
|
||
// Step 2: Walk < 160 sub-units in octile distance — reveal suppressed until 160 threshold
|
||
expect(minimap.reveal(dungeon, 20, 4, 50, 0, false)).toBe(false)
|
||
expect(minimap.isCellExplored(dungeon, 20, 4)).toBe(false)
|
||
|
||
// Step 3: Cross >= 160 sub-units into Room (2,0) — Room (2,0) reveals (now 2 floors visible)
|
||
expect(minimap.reveal(dungeon, 20, 4, 200, 0, false)).toBe(true)
|
||
expect(minimap.isCellExplored(dungeon, 20, 4)).toBe(true)
|
||
minimap.draw(
|
||
renderer,
|
||
dungeon,
|
||
20,
|
||
4,
|
||
{ x: 200, y: 0, zoom: 1 },
|
||
{ width: 1600, height: 1200 },
|
||
{ playerWorldX: 200, playerWorldY: 0 },
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.floors).toBe(2)
|
||
|
||
// Step 4: Walk >= 160 sub-units into Room (4,0) — all 3 floors and the exit label are now visible
|
||
expect(minimap.reveal(dungeon, 36, 4, 400, 0, false)).toBe(true)
|
||
expect(minimap.isCellExplored(dungeon, 36, 4)).toBe(true)
|
||
expect(minimap.isCellExplored(dungeon, 4, 4)).toBe(true) // Room (0,0) remains irreversibly explored
|
||
minimap.draw(
|
||
renderer,
|
||
dungeon,
|
||
36,
|
||
4,
|
||
{ x: 400, y: 0, zoom: 1 },
|
||
{ width: 1600, height: 1200 },
|
||
{ playerWorldX: 400, playerWorldY: 0 },
|
||
)
|
||
expect(minimap.lastRenderStats.spritesDrawn.floors).toBe(3)
|
||
expect(minimap.lastRenderStats.warpLabels).toHaveLength(1)
|
||
expect(minimap.lastRenderStats.warpLabels[0]!.text).toBe('Blood Moor')
|
||
})
|
||
|
||
it('S7: Live Combat Encounter on Automap — player, mercenary, party ally, town portal, SuperUnique boss, and dead corpses', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer, draws } = createScenarioRenderer()
|
||
minimap.mode = 'full'
|
||
|
||
const c = computeAutomapTilePixelCoords(8, 8, 0)
|
||
const level: MinimapLevel = {
|
||
key: 'e2e-combat-encounter',
|
||
cellsX: 32,
|
||
cellsY: 32,
|
||
blocked: new Uint8Array(32 * 5 * 32 * 5),
|
||
gridWidth: 32 * 5,
|
||
originX: 0,
|
||
originY: 0,
|
||
automap: {
|
||
layerId: 2,
|
||
levelTypeId: 2,
|
||
floors: [
|
||
{ cellNo: 5, xPixel: c.xPixel, yPixel: c.yPixel, cellX: 8, cellY: 8, fSaved: 0, weight: 0 },
|
||
],
|
||
walls: [],
|
||
objects: [],
|
||
extras: [],
|
||
},
|
||
markers: [],
|
||
}
|
||
minimap.reveal(level, 8, 8, 0, 0, true)
|
||
|
||
const entities: AutomapEntityMarker[] = [
|
||
{ x: 20, y: 20, kind: 'mercenary', alive: true },
|
||
{ x: 30, y: -20, kind: 'party', name: 'Waheed', alive: true },
|
||
{ x: 50, y: 40, kind: 'unique', alive: true },
|
||
{ x: 60, y: 50, kind: 'superunique', alive: true },
|
||
{ x: 70, y: 60, kind: 'boss', alive: false }, // dead corpse — must be skipped
|
||
]
|
||
|
||
minimap.draw(renderer, level, 8, 8, { x: 0, y: 0, zoom: 1 }, { width: 800, height: 600 }, {
|
||
playerWorldX: 0,
|
||
playerWorldY: 0,
|
||
entities,
|
||
portals: [{ x: -30, y: 10, active: true, isLocalPlayerOwner: true }],
|
||
})
|
||
|
||
// 1 player + 1 party + 1 mercenary + 2 bosses (unique + superunique) = 5 crosshairs (60 quads)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.player).toBe(1)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.party).toBe(1)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.mercenaries).toBe(1)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.bosses).toBe(2)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.total).toBe(5)
|
||
expect(minimap.lastRenderStats.spritesDrawn.portals).toBe(1)
|
||
|
||
const quads = draws.filter(d => d.kind === 'quad')
|
||
expect(quads).toHaveLength(5 * 12)
|
||
expect(quads.filter(q => q.color === AUTOMAP_CROSSHAIR_COLORS.player)).toHaveLength(12)
|
||
expect(quads.filter(q => q.color === AUTOMAP_CROSSHAIR_COLORS.party)).toHaveLength(12)
|
||
expect(quads.filter(q => q.color === AUTOMAP_CROSSHAIR_COLORS.mercenary)).toHaveLength(12)
|
||
expect(quads.filter(q => q.color === AUTOMAP_CROSSHAIR_COLORS.boss)).toHaveLength(24)
|
||
})
|
||
|
||
it('S8: Complete Player UI Interaction Workflow — Tab, V, F9, F10, Arrow Panning, Home Re-center, and Side Panel Shifts', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer, draws } = createScenarioRenderer()
|
||
const town = loadSceneByLevelId(1)
|
||
minimap.reveal(
|
||
town.levelModel,
|
||
town.playerCellX,
|
||
town.playerCellY,
|
||
town.playerWorldX,
|
||
town.playerWorldY,
|
||
true,
|
||
)
|
||
|
||
// 1. Default mode is Corner Minimap ('minimap'); Tab toggles visibility off and back on
|
||
expect(minimap.mode).toBe('minimap')
|
||
expect(minimap.visible).toBe(true)
|
||
expect(minimap.toggle()).toBe(false)
|
||
expect(minimap.visible).toBe(false)
|
||
expect(minimap.toggle()).toBe(true)
|
||
expect(minimap.visible).toBe(true)
|
||
|
||
// 2. V switches between Corner Minimap (div=20) and Full Automap (div=10)
|
||
expect(minimap.toggleMode()).toBe('full')
|
||
expect(minimap.mode).toBe('full')
|
||
expect(minimap.toggleMode()).toBe('minimap')
|
||
expect(minimap.toggleMode()).toBe('full')
|
||
|
||
// 3. F9 cycles fadeState through 0 -> 1 -> 2 -> 0
|
||
expect(minimap.fadeState).toBe(0)
|
||
expect(minimap.cycleFade()).toBe(1)
|
||
expect(minimap.cycleFade()).toBe(2)
|
||
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2])
|
||
|
||
// 4. F10 toggles showNames
|
||
expect(minimap.showNames).toBe(true)
|
||
expect(minimap.toggleShowNames()).toBe(false)
|
||
expect(minimap.toggleShowNames()).toBe(true)
|
||
|
||
// 5. Arrow keys pan by AUTOMAP_PAN_STEP_PX (8px) and Home resets to (0,0)
|
||
expect(minimap.panBySteps(1, -2)).toBe(true)
|
||
expect(minimap.panX).toBe(AUTOMAP_PAN_STEP_PX)
|
||
expect(minimap.panY).toBe(-2 * AUTOMAP_PAN_STEP_PX)
|
||
minimap.resetPan()
|
||
expect(minimap.panX).toBe(0)
|
||
expect(minimap.panY).toBe(0)
|
||
|
||
// 6. Left panel open (+160 shift) with fadeState=2 (alpha=0.5)
|
||
draws.length = 0
|
||
minimap.draw(
|
||
renderer,
|
||
town.levelModel,
|
||
town.playerCellX,
|
||
town.playerCellY,
|
||
{ x: town.playerWorldX, y: town.playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{
|
||
playerWorldX: town.playerWorldX,
|
||
playerWorldY: town.playerWorldY,
|
||
leftPanelOpen: true,
|
||
rightPanelOpen: false,
|
||
},
|
||
)
|
||
expect(minimap.lastRenderStats.viewport.centerShiftX).toBe(160)
|
||
expect(minimap.lastRenderStats.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2])
|
||
})
|
||
|
||
it('S9: Multi-Floor Dungeon Traversal — independent per-level fog-of-war state across Catacombs Level 1 (34) & Level 2 (35)', () => {
|
||
const catacombs1 = loadSceneByLevelId(34)
|
||
const catacombs2 = loadSceneByLevelId(35)
|
||
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
|
||
// Reveal starting room in Catacombs Level 1
|
||
minimap.reveal(
|
||
catacombs1.levelModel,
|
||
catacombs1.playerCellX,
|
||
catacombs1.playerCellY,
|
||
catacombs1.playerWorldX,
|
||
catacombs1.playerWorldY,
|
||
true,
|
||
)
|
||
expect(
|
||
minimap.isCellExplored(catacombs1.levelModel, catacombs1.playerCellX, catacombs1.playerCellY),
|
||
).toBe(true)
|
||
expect(
|
||
minimap.isCellExplored(catacombs2.levelModel, catacombs2.playerCellX, catacombs2.playerCellY),
|
||
).toBe(false)
|
||
|
||
// Descend to Catacombs Level 2 and reveal its starting room
|
||
minimap.reveal(
|
||
catacombs2.levelModel,
|
||
catacombs2.playerCellX,
|
||
catacombs2.playerCellY,
|
||
catacombs2.playerWorldX,
|
||
catacombs2.playerWorldY,
|
||
true,
|
||
)
|
||
expect(
|
||
minimap.isCellExplored(catacombs2.levelModel, catacombs2.playerCellX, catacombs2.playerCellY),
|
||
).toBe(true)
|
||
|
||
// Return to Catacombs Level 1 — its previously revealed room is still explored
|
||
expect(
|
||
minimap.isCellExplored(catacombs1.levelModel, catacombs1.playerCellX, catacombs1.playerCellY),
|
||
).toBe(true)
|
||
})
|
||
|
||
it('S10: Full-Spectrum 136-Level (1..136) Offline Pack Integrity Audit — all 365 baked scenes have non-empty automap cells within valid sheet bounds', () => {
|
||
let totalScenesVerified = 0
|
||
let totalCellsVerified = 0
|
||
|
||
for (let levelId = 1; levelId <= 136; levelId += 1) {
|
||
const entries = entriesByLevelId.get(levelId) ?? []
|
||
expect(entries.length, `LevelId ${String(levelId)} missing in index.json`).toBeGreaterThan(0)
|
||
|
||
for (const entry of entries) {
|
||
const sceneRaw = fs.readFileSync(path.join(PACK_DIR, entry.path, 'scene.json'), 'utf8')
|
||
const scene = JSON.parse(sceneRaw) as PackedSceneJson
|
||
const automap = scene.automap
|
||
expect(automap, `${entry.path} missing scene.automap`).toBeDefined()
|
||
if (!automap) continue
|
||
|
||
const allCells = [
|
||
...automap.floors,
|
||
...automap.walls,
|
||
...automap.objects,
|
||
...automap.extras,
|
||
]
|
||
expect(
|
||
allCells.length,
|
||
`${entry.path} (LevelId=${String(levelId)}) has 0 automap cells`,
|
||
).toBeGreaterThan(0)
|
||
|
||
for (const cell of allCells) {
|
||
const sheetFull = resolveAutomapSheetName(cell.fSaved ?? 0, false)
|
||
const sheetHalf = resolveAutomapSheetName(cell.fSaved ?? 0, true)
|
||
const metaFull = AUTOMAP_UI_MANIFEST.atlases[sheetFull]
|
||
const metaHalf = AUTOMAP_UI_MANIFEST.atlases[sheetHalf]
|
||
expect(cell.cellNo).toBeGreaterThanOrEqual(0)
|
||
expect(cell.cellNo).toBeLessThan(metaFull.frames.length)
|
||
expect(cell.cellNo).toBeLessThan(metaHalf.frames.length)
|
||
}
|
||
|
||
totalScenesVerified += 1
|
||
totalCellsVerified += allCells.length
|
||
}
|
||
}
|
||
|
||
expect(totalScenesVerified).toBe(136)
|
||
expect(totalCellsVerified).toBeGreaterThan(100000)
|
||
})
|
||
|
||
it('S11: Full-Spectrum 136-Level (1..136) Corner Minimap (bMinimap=true, nDivisor=20) Render Pipeline Audit', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer } = createScenarioRenderer()
|
||
minimap.mode = 'minimap'
|
||
|
||
for (let levelId = 1; levelId <= 136; levelId += 1) {
|
||
const { scene, levelModel, playerCellX, playerCellY, playerWorldX, playerWorldY } =
|
||
loadSceneByLevelId(levelId)
|
||
|
||
minimap.reveal(levelModel, playerCellX, playerCellY, playerWorldX, playerWorldY, true)
|
||
minimap.draw(
|
||
renderer,
|
||
levelModel,
|
||
playerCellX,
|
||
playerCellY,
|
||
{ x: playerWorldX, y: playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX, playerWorldY, levelId },
|
||
)
|
||
|
||
expect(minimap.lastRenderStats.divisor).toBe(20)
|
||
expect(
|
||
minimap.lastRenderStats.spritesDrawn.total,
|
||
`LevelId ${String(levelId)} (${scene.levelName}) drew 0 sprites in Corner Minimap mode`,
|
||
).toBeGreaterThan(0)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.player).toBe(1)
|
||
expect(minimap.lastRenderStats.hudText?.levelName).toBe(scene.levelName)
|
||
}
|
||
})
|
||
|
||
it('S12: Full-Spectrum 136-Level (1..136) Full Automap (bMinimap=false, nDivisor=10) Render Pipeline Audit', () => {
|
||
const minimap = new Minimap()
|
||
registerAtlases(minimap)
|
||
const { renderer } = createScenarioRenderer()
|
||
minimap.mode = 'full'
|
||
|
||
for (let levelId = 1; levelId <= 136; levelId += 1) {
|
||
const { scene, levelModel, playerCellX, playerCellY, playerWorldX, playerWorldY } =
|
||
loadSceneByLevelId(levelId)
|
||
|
||
minimap.reveal(levelModel, playerCellX, playerCellY, playerWorldX, playerWorldY, true)
|
||
minimap.draw(
|
||
renderer,
|
||
levelModel,
|
||
playerCellX,
|
||
playerCellY,
|
||
{ x: playerWorldX, y: playerWorldY, zoom: 1 },
|
||
{ width: 800, height: 600 },
|
||
{ playerWorldX, playerWorldY, levelId },
|
||
)
|
||
|
||
expect(minimap.lastRenderStats.divisor).toBe(10)
|
||
expect(
|
||
minimap.lastRenderStats.spritesDrawn.total,
|
||
`LevelId ${String(levelId)} (${scene.levelName}) drew 0 sprites in Full Automap mode`,
|
||
).toBeGreaterThan(0)
|
||
expect(minimap.lastRenderStats.crosshairsDrawn.player).toBe(1)
|
||
expect(minimap.lastRenderStats.hudText?.levelName).toBe(scene.levelName)
|
||
}
|
||
})
|
||
})
|