import { describe, expect, it } from 'vitest' import { applySubstitution, applySubstitutions, buildSubstitutionCandidatePermutation, createCanvas, stampSubstitutionPatch, } from '../src/game/wilderness.ts' import type { WildernessSubstitution } from '../src/game/wilderness.ts' import { Rng } from '../src/game/rng.ts' import type { Ds1 } from '../src/formats/ds1.ts' function makePatchDs1(width: number, height: number, floorSeq: number, wallSeq = 10, roofSeq = 20, shadowSeq = 30): Ds1 { const cells = Array.from({ length: height }, () => Array.from({ length: width }, () => ({ floors: [{ prop1: 1, style: 5, sequence: floorSeq, hidden: false, unknown1: 0, unknown2: 0 }], walls: [ { prop1: 1, style: 6, sequence: wallSeq, type: 1, hidden: false, unknown1: 0, unknown2: 0 }, { prop1: 1, style: 7, sequence: roofSeq, type: 15, hidden: false, unknown1: 0, unknown2: 0 }, ], shadows: [{ prop1: 1, style: 8, sequence: shadowSeq, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [{ value: 1 }], })), ) return { version: 18, width, height, act: 1, substitutionType: 1, wallLayers: 2, floorLayers: 1, cells, objects: [{ type: 2, id: 15, x: 2, y: 2, flags: 0 }], npcPathOffset: null, } } describe('Blizzard D2 1.13c LvlSub.txt Trials & Exhaustive Permutation Substitution (Issue #76)', () => { it('generates a complete, duplicate-free Fisher-Yates candidate permutation that varies by seed', () => { const permA = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(1001)) const permB = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(2002)) expect(permA).toHaveLength(36) expect(permB).toHaveLength(36) // Verify all 36 grid coordinates are present with zero duplicates const keysA = new Set(permA.map(p => `${p.ox},${p.oy}`)) expect(keysA.size).toBe(36) // Verify permutation order differs between seeds (not fixed top-left scan) const firstFiveA = permA.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|') const firstFiveB = permB.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|') expect(firstFiveA).not.toBe(firstFiveB) }) it('finds the sole remaining free candidate slot when Trials == -1 (exhaustive scan guarantee)', () => { const gridWidth = 8 const gridHeight = 8 const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) // Claim every interior block EXCEPT (bx = 5, by = 4) const claimedBlocks = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => true), ) claimedBlocks[4]![5] = false const patch = makePatchDs1(8, 8, 42) const subRow: WildernessSubstitution = { name: 'Act 1 - Exhaustive Shrine', type: 1, bordType: -1, gridSize: 1, dt1Mask: 0, prob: [100, 100, 100, 100, 100], trials: [-1, -1, -1, -1, -1], max: [1, 1, 1, 1, 1], levels: [patch], } const stamped = applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(999), claimedBlocks) expect(stamped).toBe(1) // Verify the patch was stamped at exact block (bx = 5, by = 4) => tile (40, 32) const targetCell = canvas.cells[32]![40]! expect(targetCell.floors[0]?.sequence).toBe(42) }) it('distributes Trials == -1 placements across varied interior locations rather than top-left (1,1)', () => { const gridWidth = 10 const gridHeight = 10 const patch = makePatchDs1(8, 8, 55) const subRow: WildernessSubstitution = { name: 'Act 1 - Swamp Pool', type: 1, bordType: -1, gridSize: 1, dt1Mask: 0, prob: [100, 100, 100, 100, 100], trials: [-1, -1, -1, -1, -1], max: [1, 1, 1, 1, 1], levels: [patch], } const placedCoords = new Set() for (let seed = 1; seed <= 10; seed += 1) { const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(seed * 7919), claimed) for (let by = 1; by < gridHeight - 1; by += 1) { for (let bx = 1; bx < gridWidth - 1; bx += 1) { if (claimed[by]![bx]) placedCoords.add(`${bx},${by}`) } } } // Across 10 seeds, Fisher-Yates permutation places the cluster in many different spots (not just 1,1) expect(placedCoords.size).toBeGreaterThan(5) }) it('atomically replaces Floor, Wall, Roof (type 15), and Shadow layers while clearing stale procedural clutter', () => { const canvas = createCanvas(16, 16, 2, 1, 1) // Pre-populate cell (4, 4) with procedural clutter on floor, wall, roof, and shadow layers const staleCell = canvas.cells[4]![4]! ;(staleCell.floors as unknown[])[0] = { prop1: 1, style: 1, sequence: 1, hidden: false, unknown1: 0, unknown2: 0 } ;(staleCell.walls as unknown[])[0] = { prop1: 1, style: 99, sequence: 99, type: 1, hidden: false, unknown1: 0, unknown2: 0 } ;(staleCell.walls as unknown[])[1] = { prop1: 1, style: 99, sequence: 98, type: 15, hidden: false, unknown1: 0, unknown2: 0 } ;(staleCell.shadows as unknown[])[0] = { prop1: 1, style: 99, sequence: 97, hidden: false, unknown1: 0, unknown2: 0 } const patch = makePatchDs1(2, 2, 11, 22, 33, 44) const written = stampSubstitutionPatch(canvas, patch, 4, 4) expect(written).toBe(4) const updatedCell = canvas.cells[4]![4]! expect(updatedCell.floors[0]?.sequence).toBe(11) expect(updatedCell.walls[0]?.sequence).toBe(22) expect(updatedCell.walls[0]?.type).toBe(1) expect(updatedCell.walls[1]?.sequence).toBe(33) expect(updatedCell.walls[1]?.type).toBe(15) expect(updatedCell.shadows[0]?.sequence).toBe(44) expect(updatedCell.substitutions[0]?.value).toBe(1) expect(canvas.objects).toHaveLength(1) }) it('applies theme-indexed Prob0..4, Trials0..4, and Max0..4 with multi-variant selection', () => { const gridWidth = 10 const gridHeight = 10 const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) const variantA = makePatchDs1(8, 8, 101) const variantB = makePatchDs1(8, 8, 102) const rows: WildernessSubstitution[] = [ { name: 'Theme 2 Multi-Variant Bivouac', type: 1, bordType: -1, gridSize: 1, dt1Mask: 0, prob: [0, 0, 100, 0, 0], trials: [0, 0, 20, 0, 0], max: [0, 0, 4, 0, 0], levels: [variantA, variantB], }, ] const stats = { levelId: 2, seed: 12345, subTheme: 2, gridWidth, gridHeight, substitutions: [] as { name: string; role: string; enabled: boolean; clusters: number }[], borderPieces: {}, unresolved: [], notes: [], } const total = applySubstitutions(canvas, rows, 2, gridWidth, gridHeight, new Rng(12345), stats as never, claimed) expect(total).toBe(4) expect(stats.substitutions[0]?.enabled).toBe(true) expect(stats.substitutions[0]?.clusters).toBe(4) }) })