diablo2-web/tests/variant-distribution.test.ts

366 lines
12 KiB
TypeScript

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)
})
})
})