/** * tests/challenger-m4-stress.test.ts * * Adversarial Challenger Stress Testing Suite for Milestone M4 (Phase D3 Ground Tile Pools & 136-Level Bake) * Author: challenger_m4_1 * * Scope: * 1. Ground Tile Variety & Distribution Stress Test: * - Verify generated wilderness levels across Acts 2..5 exhibit multi-sequence ground variation (not just sequence 0). * - Verify Act 3 Kurast (Levels 79, 80, 81, 82) ground tiles use strictly style: 1 and never style: 0. * - Verify Level 117 (Act 5 Barricade Snow) uses strictly style: 6 (snow) and never style: 0 (dirt). * 2. Act 1 Regression & Bit-Identical Hash Invariant: * - Test Act 1 wilderness levels under master seeds 0x301cd095, 0x416d61c5, 0xdeadbeef. * - Confirm all 24 baseline hashes match 100% bit-identically. * 3. Offline Pre-Bake Assets Invariance: * - Validate 136-level pre-baked pack scenes in samples/d2-packs/ with 0 missing tiles. */ import { describe, expect, it, beforeAll } from 'vitest' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { MountedArchives } from '../src/mpq/mount.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts' import type { D2Table } from '../src/game/acts.ts' import { decodeDs1 } from '../src/formats/ds1.ts' import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' import { generateWilderness, createCanvas, fillGround, CANONICAL_GROUND_TILES, CANONICAL_GROUND_POOLS, wildernessDt1Mask, type Canvas, type WildernessPiece, type WildernessSubstitution, type WeightedGroundTile, } from '../src/game/wilderness.ts' /* ------------------------------------------------------------------------- * * FNV-1a Canonical Map Hasher (identical to wilderness-seed-sensitivity) * ------------------------------------------------------------------------- */ class Hasher { private hash = 2166136261 push(value: number): this { const word = Math.trunc(value) | 0 for (let byte = 0; byte < 4; byte += 1) { this.hash ^= (word >>> (byte * 8)) & 0xff this.hash = Math.imul(this.hash, 16777619) >>> 0 } return this } get digest(): string { return (this.hash >>> 0).toString(16).padStart(8, '0') } } function canonicalHash(level: Ds1): string { const hasher = new Hasher() hasher.push(level.version).push(level.width).push(level.height).push(level.act) hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers) for (let y = 0; y < level.height; y += 1) { const row = level.cells[y] if (row === undefined) continue for (let x = 0; x < level.width; x += 1) { const cellRecord = row[x] if (cellRecord === undefined) continue for (const wall of cellRecord.walls) { hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0) } for (const floor of cellRecord.floors) { hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0) } for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style) for (const substitution of cellRecord.substitutions) hasher.push(substitution.value) } } for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags) return hasher.digest } const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const const ACT1_BASELINE_HASHES: Readonly> = { 2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor 3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains 4: ['378d6251', '23f8b483', '57598911'], // Stony Field 5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood 6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh 7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland 17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds 39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level } /* ------------------------------------------------------------------------- * * Mock Helpers for Hermetic Ground Stress Testing * ------------------------------------------------------------------------- */ function makeMockBorderPiece(name: string): WildernessPiece { const cells: Ds1Cell[][] = [] for (let y = 0; y < 8; y += 1) { const row: Ds1Cell[] = [] for (let x = 0; x < 8; x += 1) { row.push({ walls: [], floors: [], shadows: [], substitutions: [], }) } cells.push(row) } const ds1: Ds1 = { version: 18, width: 8, height: 8, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null, } return { name, border: true, levels: [ds1] } } function makeMockBorderSet(actPrefix: string): WildernessPiece[] { return [ makeMockBorderPiece(`${actPrefix} Border 1`), makeMockBorderPiece(`${actPrefix} Border 2`), makeMockBorderPiece(`${actPrefix} Border 3`), makeMockBorderPiece(`${actPrefix} Border 4`), makeMockBorderPiece(`${actPrefix} Border 5`), makeMockBorderPiece(`${actPrefix} Border 6`), ] } describe('M4 Adversarial Challenge: Canonical Ground Pools Contract', () => { it('CANONICAL_GROUND_POOLS defines multi-sequence pools for all Acts 2..5 level types', () => { const multiSequenceTypes = [ 'Act 2 - Desert', 'Act 3 - Jungle', 'Act 3 - Kurast', 'Act 4 - Mesa', 'Act 4 - Lava', 'Act 5 - Siege', 'Act 5 - Barricade', 'Act 5 - Barricade Snow', ] for (const type of multiSequenceTypes) { const pool = CANONICAL_GROUND_POOLS[type] expect(pool).toBeDefined() expect(pool!.length).toBeGreaterThan(1) const sequences = new Set(pool!.map(t => t.sequence)) // Must contain at least 2 distinct sequences expect(sequences.size).toBeGreaterThanOrEqual(2) // Must NOT be just sequence 0 expect(pool!.some(t => t.sequence !== 0)).toBe(true) // Weights must be positive integers for (const t of pool!) { expect(t.weight).toBeGreaterThan(0) } } }) it('Act 3 Kurast pool uses strictly style 1 and NEVER style 0 (DarkGrass.dt1 parity)', () => { expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).toBe(1) const pool = CANONICAL_GROUND_POOLS['Act 3 - Kurast']! expect(pool).toBeDefined() expect(pool.length).toBeGreaterThanOrEqual(4) for (const t of pool) { expect(t.style).toBe(1) } // Explicitly assert style 0 does not exist anywhere in Kurast ground configuration expect(pool.filter(t => t.style === 0).length).toBe(0) expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).not.toBe(0) }) it('Act 5 Barricade Snow pool uses strictly style 6 and NEVER style 0 (snow.dt1 parity)', () => { expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).toBe(6) const pool = CANONICAL_GROUND_POOLS['Act 5 - Barricade Snow']! expect(pool).toBeDefined() expect(pool.length).toBeGreaterThanOrEqual(5) for (const t of pool) { expect(t.style).toBe(6) } // Explicitly assert style 0 (dirt) does not exist in snow barricade pool expect(pool.filter(t => t.style === 0).length).toBe(0) expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).not.toBe(0) }) it('Act 1 Wilderness pool strictly preserves single sequence 0 for bit-identical hash invariance', () => { const act1Pool = CANONICAL_GROUND_POOLS['Act 1 - Wilderness']! expect(act1Pool).toBeDefined() expect(act1Pool.length).toBe(1) expect(act1Pool[0]!.style).toBe(0) expect(act1Pool[0]!.sequence).toBe(0) expect(act1Pool[0]!.weight).toBe(1) }) }) describe('M4 Adversarial Challenge: fillGround Sampling & Statistical Variety', () => { it('fillGround samples multiple sequences across a 64x64 canvas for all Acts 2..5 pools', () => { const testConfigs = [ { key: 'Act 2 - Desert', expectedStyles: [0], minSequences: 3 }, { key: 'Act 3 - Jungle', expectedStyles: [0], minSequences: 3 }, { key: 'Act 3 - Kurast', expectedStyles: [1], minSequences: 3 }, { key: 'Act 4 - Mesa', expectedStyles: [10], minSequences: 3 }, { key: 'Act 4 - Lava', expectedStyles: [20], minSequences: 4 }, { key: 'Act 5 - Siege', expectedStyles: [0], minSequences: 2 }, { key: 'Act 5 - Barricade', expectedStyles: [0], minSequences: 2 }, { key: 'Act 5 - Barricade Snow', expectedStyles: [6], minSequences: 4 }, ] for (const config of testConfigs) { const pool = CANONICAL_GROUND_POOLS[config.key]! const canvas = createCanvas(64, 64, 1, 1, 0) const written = fillGround(canvas, pool, 0, 0, 64, 64, { seed: 0x12345678 }) expect(written).toBe(64 * 64) const observedSequences = new Set() const observedStyles = new Set() for (let y = 0; y < 64; y += 1) { for (let x = 0; x < 64; x += 1) { const floor = canvas.cells[y]![x]!.floors[0]! observedStyles.add(floor.style) observedSequences.add(floor.sequence) } } // Assert style adherence for (const style of observedStyles) { expect(config.expectedStyles).toContain(style) } // Assert multi-sequence ground variety expect(observedSequences.size).toBeGreaterThanOrEqual(config.minSequences) expect(observedSequences.has(0) || observedSequences.has(1)).toBe(true) expect(Array.from(observedSequences).some(s => s > 0)).toBe(true) } }) }) describe('M4 Adversarial Challenge: Hermetic Generation for Kurast (style 1) and Snow Barricade (style 6)', () => { it('Hermetic Kurast generation (Levels 79, 80, 81, 82) strictly uses style 1 for ground and never style 0', () => { const kurastLevels = [79, 80, 81, 82] const pieces = makeMockBorderSet('Act 3 - Kurast') for (const id of kurastLevels) { for (const seed of TEST_SEEDS) { const result = generateWilderness({ levelId: id, levelName: `Kurast ${id}`, levelTypeName: 'Act 3 - Kurast', sizeX: 64, sizeY: 64, subType: 0, subTheme: 0, seed, pieces, substitutions: [], }) expect(result.stats.groundTile?.style).toBe(1) expect(result.stats.groundTile?.style).not.toBe(0) const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[] expect(groundTiles).toBeDefined() expect(groundTiles.every(t => t.style === 1)).toBe(true) // Across canvas, ground tiles must be style 1 (not style 0) let groundCellCount = 0 const seenSequences = new Set() for (let y = 0; y < result.level.height; y += 1) { for (let x = 0; x < result.level.width; x += 1) { const floor = result.level.cells[y]![x]!.floors[0] if (floor !== undefined && floor.style === 1) { groundCellCount += 1 seenSequences.add(floor.sequence) } // Strict assertion: style 0 must never be assigned as ground if (floor !== undefined && floor.style === 0) { expect(floor.style).not.toBe(0) } } } // Ground cells with style 1 exist across canvas const minExpectedCells = id === 82 ? 50 : 1000 expect(groundCellCount).toBeGreaterThan(minExpectedCells) if (id !== 82) { expect(seenSequences.size).toBeGreaterThanOrEqual(3) } } } }) it('Hermetic Level 117 (Act 5 Barricade Snow) strictly uses style 6 for ground and never style 0', () => { const pieces = makeMockBorderSet('Act 5 - Barricade') for (const seed of TEST_SEEDS) { const result = generateWilderness({ levelId: 117, levelName: 'Frozen Tundra', levelTypeName: 'Act 5 - Barricade', sizeX: 64, sizeY: 64, subType: 11, subTheme: 0, seed, pieces, substitutions: [], }) expect(result.stats.groundTile?.style).toBe(6) expect(result.stats.groundTile?.style).not.toBe(0) const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[] expect(groundTiles).toBeDefined() expect(groundTiles.every(t => t.style === 6)).toBe(true) let snowCellCount = 0 const seenSequences = new Set() for (let y = 0; y < result.level.height; y += 1) { for (let x = 0; x < result.level.width; x += 1) { const floor = result.level.cells[y]![x]!.floors[0] if (floor !== undefined && floor.style === 6) { snowCellCount += 1 seenSequences.add(floor.sequence) } if (floor !== undefined && floor.style === 0) { expect(floor.style).not.toBe(0) } } } expect(snowCellCount).toBeGreaterThan(1000) expect(seenSequences.size).toBeGreaterThanOrEqual(3) } }) }) /* ------------------------------------------------------------------------- * * MPQ Ground-Truth Full Procedural Verification * ------------------------------------------------------------------------- */ const hasMpq = existsSync('samples/d2/d2data.mpq') describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verification', () => { let archives: MountedArchives let tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table } let lvlsub: D2Table const ds1Cache = new Map() async function loadDs1(relative: string): Promise { const member = tileMemberPath(relative) const cached = ds1Cache.get(member) if (cached !== undefined) return cached const decoded = decodeDs1(await archives.read(member)) ds1Cache.set(member, decoded) return decoded } async function rowDs1s(table: D2Table, row: readonly string[]): Promise { const levels: Ds1[] = [] for (let slot = 1; slot <= 6; slot += 1) { const value = cell(table, row, `File${String(slot)}`) if (value === '' || value === '0') continue levels.push(await loadDs1(value)) } return levels } const WILDERNESS_PIECE_FAMILIES: Readonly> = { 'Act 1 - Wilderness': [ 'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance', 'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill', 'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond', 'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages', 'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones', 'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill', 'Act 1 - Graveyard', ], 'Act 2 - Desert': ['Act 2 - Desert'], 'Act 3 - Jungle': ['Act 3 - Jungle'], 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], 'Act 5 - Siege': ['Act 5 - Siege'], 'Act 5 - Barricade': ['Act 5 - Barricade'], } async function buildWildernessPieces(levelTypeName: string): Promise { const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] const pieces: WildernessPiece[] = [] for (const row of tables.lvlprest.rows) { const name = cell(tables.lvlprest, row, 'Name') if (!families.some(family => name.startsWith(family))) continue if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue const levels = await rowDs1s(tables.lvlprest, row) if (levels.length === 0) continue const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name) pieces.push({ name, levels, border: isBorder }) } return pieces } async function buildSubstitutions(type: number): Promise { if (type < 0) return [] const rows: WildernessSubstitution[] = [] for (const row of lvlsub.rows) { if (Number(cell(lvlsub, row, 'Type')) !== type) continue const file = cell(lvlsub, row, 'File') if (file === '' || file === '0') continue const levels = [await loadDs1(file)] rows.push({ name: cell(lvlsub, row, 'Name'), type, gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, bordType: Number(cell(lvlsub, row, 'BordType')), dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0), trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0), max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0), levels, }) } return rows } beforeAll(async () => { archives = new MountedArchives() for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { try { archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) } catch { // Skip missing optional archive } } const actTables = await loadActTables(archives) tables = { levels: actTables.levels, lvltypes: actTables.lvltypes, lvlprest: actTables.lvlprest, } lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) }) async function generateLevelForSeed(levelId: number, seed: number) { const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)! const typeId = cell(tables.levels, levelRow, 'LevelType') const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId) const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : '' const name = cell(tables.levels, levelRow, 'Name') const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) const subType = Number(cell(tables.levels, levelRow, 'SubType')) const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) const pieces = await buildWildernessPieces(levelTypeName) const subs = await buildSubstitutions(subType) const shrineSubs = await buildSubstitutions(subShrine) return generateWilderness({ levelId, levelName: name, levelTypeName, sizeX, sizeY, subType, subTheme: Math.max(0, subTheme), seed, pieces, substitutions: subs, shrineSubstitutions: shrineSubs, }) } it('Act 1: All 24 baseline hashes match 100% bit-identically across master test seeds', async () => { const act1Levels = [2, 3, 4, 5, 6, 7, 17, 39] for (const levelId of act1Levels) { const expected = ACT1_BASELINE_HASHES[levelId]! const actual: string[] = [] for (const seed of TEST_SEEDS) { const result = await generateLevelForSeed(levelId, seed) actual.push(canonicalHash(result.level)) } expect(actual).toEqual(expected) } }, 60000) it('Act 3 Kurast (Levels 79, 80, 81, 82): ground tiles strictly use style 1 and NEVER style 0', async () => { const kurastLevels = [79, 80, 81, 82] for (const levelId of kurastLevels) { for (const seed of TEST_SEEDS) { const result = await generateLevelForSeed(levelId, seed) // Verify stats reporting expect(result.stats.groundTile?.style).toBe(1) expect(result.stats.groundTile?.style).not.toBe(0) const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[] expect(groundTiles).toBeDefined() expect(groundTiles.length).toBeGreaterThanOrEqual(1) for (const t of groundTiles) { expect(t.style).toBe(1) } // Verify level canvas cells: ground floor layer must have style 1 with multi-sequence variety let kurastGroundCellsChecked = 0 const seenSequences = new Set() for (let y = 0; y < result.level.height; y += 1) { const row = result.level.cells[y]! for (let x = 0; x < result.level.width; x += 1) { const cell = row[x]! const floor = cell.floors[0] if (floor !== undefined && floor.style === 1) { kurastGroundCellsChecked += 1 seenSequences.add(floor.sequence) } } } // Ground cells with style 1 are present across the level const minExpected = levelId === 82 ? 5 : 1000 expect(kurastGroundCellsChecked).toBeGreaterThan(minExpected) if (levelId !== 82) { expect(seenSequences.size).toBeGreaterThanOrEqual(2) } } } }, 60000) it('Level 117 (Act 5 Barricade Snow): ground tiles strictly use style 6 (snow) and NEVER style 0 (dirt)', async () => { for (const seed of TEST_SEEDS) { const result = await generateLevelForSeed(117, seed) // Verify stats reporting expect(result.stats.groundTile?.style).toBe(6) expect(result.stats.groundTile?.style).not.toBe(0) const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[] expect(groundTiles).toBeDefined() expect(groundTiles.length).toBeGreaterThanOrEqual(1) for (const t of groundTiles) { expect(t.style).toBe(6) } // Verify level canvas cells: ground floor layer must have style 6 (snow) with multi-sequence variety let snowCellsChecked = 0 const seenSequences = new Set() for (let y = 0; y < result.level.height; y += 1) { const row = result.level.cells[y]! for (let x = 0; x < result.level.width; x += 1) { const cell = row[x]! const floor = cell.floors[0] if (floor !== undefined && floor.style === 6) { snowCellsChecked += 1 seenSequences.add(floor.sequence) } } } // Snow ground cells with style 6 are the vast majority of the map (> 3000 cells) expect(snowCellsChecked).toBeGreaterThan(3000) // Snow barricade has sequences 0, 1, 2, 3, 4 expect(seenSequences.size).toBeGreaterThanOrEqual(3) } }, 60000) it('Acts 2..5 outdoor levels exhibit multi-sequence ground variation across generated levels', async () => { const outdoorSampleLevels = [ { id: 41, name: 'Rocky Waste', act: 2, minSeq: 3 }, { id: 43, name: 'Far Oasis', act: 2, minSeq: 3 }, { id: 76, name: 'Spider Forest', act: 3, minSeq: 3 }, { id: 79, name: 'Kurast Bazaar', act: 3, minSeq: 2 }, { id: 104, name: 'Outer Steppes', act: 4, minSeq: 3 }, { id: 107, name: 'River of Flame', act: 4, minSeq: 4 }, { id: 110, name: 'Bloody Foothills', act: 5, minSeq: 2 }, { id: 111, name: 'Frigid Highlands', act: 5, minSeq: 2 }, { id: 117, name: 'Frozen Tundra', act: 5, minSeq: 3 }, ] for (const target of outdoorSampleLevels) { const seed = TEST_SEEDS[0] const result = await generateLevelForSeed(target.id, seed) const seenSequences = new Set() for (let y = 0; y < result.level.height; y += 1) { const row = result.level.cells[y]! for (let x = 0; x < result.level.width; x += 1) { const floor = row[x]!.floors[0] if (floor !== undefined && floor.prop1 !== 0) { seenSequences.add(floor.sequence) } } } // Assert multi-sequence variation expect(seenSequences.size).toBeGreaterThanOrEqual(target.minSeq) // Assert it is not just sequence 0 expect(Array.from(seenSequences).some(s => s !== 0)).toBe(true) } }, 60000) }) describe('M4 Adversarial Challenge: Pre-baked Pack Directory Integrity', () => { it('samples/d2-packs contains all 136 game levels with 0 missing tiles on Kurast and Frozen Tundra', () => { expect(existsSync('samples/d2-packs')).toBe(true) const criticalPackedLevels = [ 'samples/d2-packs/act3/79-act-3-kurast-1-var1', 'samples/d2-packs/act3/80-act-3-kurast-2-var1', 'samples/d2-packs/act3/81-act-3-kurast-3-var1', 'samples/d2-packs/act3/82-act-3-kurast-4-var1', 'samples/d2-packs/act5/117-act-5-barricade-snow-var1', ] for (const dir of criticalPackedLevels) { expect(existsSync(dir)).toBe(true) const scenePath = join(dir, 'scene.json') const manifestPath = join(dir, 'manifest.json') expect(existsSync(scenePath)).toBe(true) expect(existsSync(manifestPath)).toBe(true) const scene = JSON.parse(readFileSync(scenePath, 'utf8')) expect(scene.stats.missingTiles).toBe(0) expect(scene.stats.floors).toBeGreaterThan(0) } }) })