import { describe, expect, test } from 'vitest' import { pickVariant, type PoolTile } from '../src/game/d2map.ts' import { generateWilderness, type WildernessPiece } from '../src/game/wilderness.ts' import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' import { ACT5_SIEGE_STRIP_NAMES } from '../src/game/wilderness-siege.ts' function makeMockDs1( width: number, height: number, hasGapInWalls: boolean = false, floorStyle: number = 1, wallStyle: number = 1, ): Ds1 { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] for (let x = 0; x < width; x += 1) { const isGap = hasGapInWalls && x === Math.floor(width / 2) row.push({ walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: wallStyle, type: 0, unknown1: 0, unknown2: 0, hidden: false }], floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], shadows: [], substitutions: [], }) } cells.push(row) } return { version: 18, width, height, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null, } } function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { const borderDs1 = makeMockDs1(8, 8, false) const openBorderDs1 = makeMockDs1(8, 8, true) return [ { name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, { name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] }, { name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] }, { name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, { name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] }, { name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] }, { name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] }, { name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] }, ] } function makeMockStripDs1(width: number, height: number, tagSequence: number): Ds1 { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] for (let x = 0; x < width; x += 1) { row.push({ walls: [], floors: [{ prop1: 2, sequence: x === 0 && y === 0 ? tagSequence : 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], shadows: [], substitutions: [], }) } cells.push(row) } return { version: 18, width, height, act: 5, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null, } } function createMockSiegePieces(): WildernessPiece[] { return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({ name, border: false, levels: [makeMockStripDs1(16, 48, 100 + idx)], })) } describe('Variant Distribution & Valid DT1 Tile Styles (Issue #116)', () => { describe('pickVariant 2D spatial coordinate hash and diagonal decorrelation', () => { test('handles empty or single candidate groups', () => { expect(pickVariant(undefined, 0, 0, 1)).toBeUndefined() expect(pickVariant([], 0, 0, 1)).toBeUndefined() const single: PoolTile = { library: 0, tile: 42, weight: 10, animated: false, frameIndex: 0 } expect(pickVariant([single], 5, 10, 99)).toBe(single) }) test('distributes variants uniformly across 64x64 grid without diagonal periodicity', () => { const candidates: PoolTile[] = [ { library: 0, tile: 0, weight: 1, animated: false, frameIndex: 0 }, { library: 0, tile: 1, weight: 1, animated: false, frameIndex: 0 }, { library: 0, tile: 2, weight: 1, animated: false, frameIndex: 0 }, { library: 0, tile: 3, weight: 1, animated: false, frameIndex: 0 }, ] const N = 64 const seed = 123456 const grid: number[][] = [] const counts = [0, 0, 0, 0] for (let y = 0; y < N; y += 1) { const row: number[] = [] for (let x = 0; x < N; x += 1) { const picked = pickVariant(candidates, x, y, seed) expect(picked).toBeDefined() const tileIdx = picked!.tile counts[tileIdx] += 1 row.push(tileIdx) } grid.push(row) } const totalCells = N * N // 4096 cells // 1. Uniformity: each of the 4 variants should receive ~25% of the total cells (between 20% and 30%) for (let i = 0; i < 4; i += 1) { const ratio = counts[i] / totalCells expect(ratio).toBeGreaterThan(0.20) expect(ratio).toBeLessThan(0.30) } // 2. Eliminate 45-degree diagonal periodicity: // In 2.5D isometric projection, diagonal step (dx=1, dy=1) or (dx=1, dy=-1) projects to screen lines. // With the old multiplicative formula (seed+x)*y, diagonal periodicity caused stripes. // With a decorrelated 2D coordinate hash, the diagonal match rate is close to 25% (random chance). let diagMatches1 = 0 let totalDiag1 = 0 for (let y = 0; y < N - 1; y += 1) { for (let x = 0; x < N - 1; x += 1) { if (grid[y][x] === grid[y + 1][x + 1]) diagMatches1 += 1 totalDiag1 += 1 } } let diagMatches2 = 0 let totalDiag2 = 0 for (let y = 1; y < N; y += 1) { for (let x = 0; x < N - 1; x += 1) { if (grid[y][x] === grid[y - 1][x + 1]) diagMatches2 += 1 totalDiag2 += 1 } } const diagRate1 = diagMatches1 / totalDiag1 const diagRate2 = diagMatches2 / totalDiag2 expect(diagRate1).toBeGreaterThan(0.20) expect(diagRate1).toBeLessThan(0.30) expect(diagRate2).toBeGreaterThan(0.20) expect(diagRate2).toBeLessThan(0.30) // 3. No degenerate rows or columns: // The old formula produced state=0 for all cells where y=0 (row 0 was all 0s). const row0Distinct = new Set(grid[0]).size expect(row0Distinct).toBe(4) const col0Distinct = new Set(grid.map(row => row[0])).size expect(col0Distinct).toBe(4) }) test('respects candidate rarity weights', () => { const candidates: PoolTile[] = [ { library: 0, tile: 0, weight: 1, animated: false, frameIndex: 0 }, { library: 0, tile: 1, weight: 3, animated: false, frameIndex: 0 }, ] const N = 64 let count0 = 0 let count1 = 0 for (let y = 0; y < N; y += 1) { for (let x = 0; x < N; x += 1) { const picked = pickVariant(candidates, x, y, 777) if (picked?.tile === 0) count0 += 1 else count1 += 1 } } const ratio1 = count1 / (count0 + count1) // Theoretical ratio is 3/4 = 75%; allow +/- 5% margin expect(ratio1).toBeGreaterThan(0.70) expect(ratio1).toBeLessThan(0.80) }) }) describe('Kurast causeway valid stone floor styles (Level 82 & 79~81)', () => { test('Kurast Causeway (82) uses valid stone floor style 20 and balustrade wall style 20', () => { const groundDs1 = makeMockDs1(48, 16) const pieces: WildernessPiece[] = [ { name: 'Act 3 - Ground', border: false, levels: [groundDs1] }, ] const result = generateWilderness({ levelId: 82, levelName: 'Kurast Causeway', levelTypeName: 'Act 3 - Kurast', sizeX: 48, sizeY: 16, subType: 0, subTheme: 0, seed: 82001, pieces, substitutions: [], }) let foundCausewayFloor = false let foundBalustradeWall = false let foundInvalidStyle5 = false for (let y = 0; y < result.level.height; y += 1) { for (let x = 0; x < result.level.width; x += 1) { const cell = result.level.cells[y]![x]! for (const floor of cell.floors) { if (floor.style === 5) foundInvalidStyle5 = true if (floor.style === 20 && floor.prop1 === 194) foundCausewayFloor = true } for (const wall of cell.walls) { if (wall.style === 20 && wall.prop1 === 129) foundBalustradeWall = true } } } // Must NOT use fictional style 5 (not present in Kurast DT1s) expect(foundInvalidStyle5).toBe(false) // Must use valid Kurast stone floor style 20 expect(foundCausewayFloor).toBe(true) // Must use valid Kurast balustrade wall style 20 expect(foundBalustradeWall).toBe(true) }) test('Kurast Urban Districts (79 Lower Kurast) avenues and bridges use valid style 20', () => { const pieces = makeMockBorderPieces('Act 3') const result = generateWilderness({ levelId: 79, levelName: 'Lower Kurast', levelTypeName: 'Act 3 - Kurast', sizeX: 80, sizeY: 80, subType: 0, subTheme: 0, seed: 79001, pieces, substitutions: [], }) let foundAvenueFloor = false let foundBridgeFloor = false let foundInvalidStyle5 = false for (let y = 0; y < result.level.height; y += 1) { for (let x = 0; x < result.level.width; x += 1) { const cell = result.level.cells[y]![x]! for (const floor of cell.floors) { if (floor.style === 5) foundInvalidStyle5 = true if (floor.style === 20 && floor.prop1 === 194) foundAvenueFloor = true } } } expect(foundInvalidStyle5).toBe(false) expect(foundAvenueFloor).toBe(true) }) }) describe('Mesa canyon and chasm valid tile styles (Levels 104, 105, 106)', () => { test.each([ { levelId: 104, levelName: 'Outer Steppes' }, { levelId: 105, levelName: 'Plains of Despair' }, { levelId: 106, levelName: 'City of the Damned' }, ])('$levelName ($levelId) uses Mesa floor style 10 instead of Lava style 20', ({ levelId, levelName }) => { const pieces = makeMockBorderPieces('Act 4') const result = generateWilderness({ levelId, levelName, levelTypeName: 'Act 4 - Mesa', sizeX: 80, sizeY: 80, subType: 0, subTheme: 0, seed: levelId * 1000 + 1, pieces, substitutions: [], }) let foundMesaChasmFloor = false let foundMesaBridgeFloor = false let foundLavaStyle20 = false for (let y = 0; y < result.level.height; y += 1) { for (let x = 0; x < result.level.width; x += 1) { const cell = result.level.cells[y]![x]! for (const floor of cell.floors) { if (floor.style === 20) foundLavaStyle20 = true if (floor.style === 10 && floor.prop1 === 2) { if (floor.sequence === 0) foundMesaChasmFloor = true } } } } // Mesa DT1s have no style 20 (only present in Lava.dt1) expect(foundLavaStyle20).toBe(false) // Valid Mesa chasm floor is style 10 expect(foundMesaChasmFloor).toBe(true) }) }) describe('Siege barricades valid fence sequence numbers (Level 110)', () => { test('Bloody Foothills (110) barricades use fence style 2 with valid sequence numbers 10 and 11', () => { const pieces = createMockSiegePieces() const result = generateWilderness({ levelId: 110, levelName: 'Bloody Foothills', levelTypeName: 'Act 5 - Siege', sizeX: 240, sizeY: 48, subType: -1, subTheme: -1, seed: 42, pieces, substitutions: [], }) let foundBarricadePalisade = false let foundSplinteredEndCap = false let foundInvalidSequence = false for (let y = 0; y < result.level.height; y += 1) { for (let x = 0; x < result.level.width; x += 1) { const cell = result.level.cells[y]![x]! for (const wall of cell.walls) { if (wall.style === 2 && wall.prop1 === 129) { // Intact palisade (type 1) if (wall.type === 1) { if (wall.sequence === 10) foundBarricadePalisade = true else foundInvalidSequence = true } // Splintered end-cap post (type 7) if (wall.type === 7) { if (wall.sequence === 10 || wall.sequence === 11) foundSplinteredEndCap = true else foundInvalidSequence = true } } } } } expect(foundInvalidSequence).toBe(false) expect(foundBarricadePalisade).toBe(true) expect(foundSplinteredEndCap).toBe(true) }) }) })