diablo2-web/tests/wilderness-ground-blend.tes...

513 lines
18 KiB
TypeScript

import { describe, expect, test } from "vitest"
import {
fillGround,
sampleGroundTile,
deriveGroundTilesFromDt1Libraries,
deriveGroundTiles,
deriveGroundTile,
cellHash,
smoothNoise2D,
createCanvas,
generateWilderness,
CANONICAL_GROUND_POOLS,
} from "../src/game/wilderness.ts"
import type {
WeightedGroundTile,
WildernessPiece,
} from "../src/game/wilderness.ts"
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(
width: number,
height: number,
floorStyle = 0,
floorSequence = 0,
customFloor?: (x: number, y: number) => { readonly style: number; readonly sequence: number } | undefined,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const custom = customFloor?.(x, y)
const style = custom ? custom.style : floorStyle
const sequence = custom ? custom.sequence : floorSequence
row.push({
walls: [],
floors: [{ prop1: 2, sequence, style, 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 makeMockDt1(tiles: Partial<Dt1Tile>[]): Dt1 {
return {
warnings: [],
versionMajor: 7,
versionMinor: 6,
tiles: tiles.map(t => ({
direction: 0,
height: -80,
width: 160,
type: t.type ?? 0,
style: t.style ?? 0,
sequence: t.sequence ?? 0,
materialFlags: 0,
roofHeight: 0,
animated: t.animated ?? 0,
rarityFrameIndex: t.rarityFrameIndex ?? 1,
subTileFlags: [],
minBlockY: 0,
bitmapHeight: 80,
blocks: [],
...t,
})),
}
}
function makeTestPieces(): WildernessPiece[] {
return [
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
]
}
describe("Wilderness Ground Tile Blending (Issue #52)", () => {
describe("PRNG & Noise Primitives", () => {
test("cellHash produces deterministic values in [0, 1)", () => {
const v1 = cellHash(10, 20, 12345)
const v2 = cellHash(10, 20, 12345)
const v3 = cellHash(10, 20, 99999)
const v4 = cellHash(11, 20, 12345)
expect(v1).toBe(v2)
expect(v1).toBeGreaterThanOrEqual(0)
expect(v1).toBeLessThan(1)
expect(v1).not.toBe(v3)
expect(v1).not.toBe(v4)
})
test("smoothNoise2D produces values in [0, 1] with spatial smoothness", () => {
const n0 = smoothNoise2D(10, 10, 8, 42)
const n1 = smoothNoise2D(10.1, 10.1, 8, 42)
const nFar = smoothNoise2D(50, 50, 8, 42)
expect(n0).toBeGreaterThanOrEqual(0)
expect(n0).toBeLessThanOrEqual(1)
// Small step produces small difference
expect(Math.abs(n1 - n0)).toBeLessThan(0.1)
expect(nFar).toBeGreaterThanOrEqual(0)
expect(nFar).toBeLessThanOrEqual(1)
})
})
describe("Single-Tile Fallback (Backward Compatibility)", () => {
test("fillGround with a single tile object fills entire area uniformly", () => {
const canvas = createCanvas(20, 20, 1, 1, 0)
const written = fillGround(canvas, { style: 3, sequence: 7 }, 0, 0, 20, 20)
expect(written).toBe(400)
for (let y = 0; y < 20; y += 1) {
for (let x = 0; x < 20; x += 1) {
const floor = canvas.cells[y]![x]!.floors[0]!
expect(floor.style).toBe(3)
expect(floor.sequence).toBe(7)
expect(floor.hidden).toBe(false)
}
}
})
test("fillGround with a 1-element array fills entire area uniformly", () => {
const canvas = createCanvas(15, 15, 1, 1, 0)
const written = fillGround(canvas, [{ style: 1, sequence: 4, weight: 100 }], 0, 0, 15, 15)
expect(written).toBe(225)
for (let y = 0; y < 15; y += 1) {
for (let x = 0; x < 15; x += 1) {
const floor = canvas.cells[y]![x]!.floors[0]!
expect(floor.style).toBe(1)
expect(floor.sequence).toBe(4)
}
}
})
test("sampleGroundTile with a single tile returns it directly", () => {
const single = { style: 2, sequence: 5 }
expect(sampleGroundTile(single, 10, 10, 123)).toEqual(single)
const singleArr = [{ style: 4, sequence: 9, weight: 1 }]
expect(sampleGroundTile(singleArr, 10, 10, 123)).toEqual(singleArr[0])
})
})
describe("Multi-Tile Ground Blending & Weight Adherence", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 70 },
{ style: 0, sequence: 1, weight: 20 },
{ style: 0, sequence: 2, weight: 10 },
]
test("fillGround in spatial mode generates all candidate tiles and respects weight ratios", () => {
const width = 60
const height = 60
const totalCells = width * height
const canvas = createCanvas(width, height, 1, 1, 0)
fillGround(canvas, candidates, 0, 0, width, height, {
seed: 424242,
mode: "spatial",
spatialScale: 6,
spatialCoherence: 0.6,
})
const counts = new Map<number, number>()
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const seq = canvas.cells[y]![x]!.floors[0]!.sequence
counts.set(seq, (counts.get(seq) ?? 0) + 1)
}
}
// All candidate sequences must be present
expect(counts.has(0)).toBe(true)
expect(counts.has(1)).toBe(true)
expect(counts.has(2)).toBe(true)
const c0 = counts.get(0)! / totalCells
const c1 = counts.get(1)! / totalCells
const c2 = counts.get(2)! / totalCells
// Expected: ~70% sequence 0, ~20% sequence 1, ~10% sequence 2
// Spatial noise percentile ranking guarantees global proportions very close to exact weights
expect(c0).toBeGreaterThanOrEqual(0.60)
expect(c0).toBeLessThanOrEqual(0.80)
expect(c1).toBeGreaterThanOrEqual(0.12)
expect(c1).toBeLessThanOrEqual(0.28)
expect(c2).toBeGreaterThanOrEqual(0.04)
expect(c2).toBeLessThanOrEqual(0.18)
})
test("fillGround in CDF mode generates all candidate tiles and respects weight ratios", () => {
const width = 60
const height = 60
const totalCells = width * height
const canvas = createCanvas(width, height, 1, 1, 0)
fillGround(canvas, candidates, 0, 0, width, height, {
seed: 88888,
mode: "cdf",
})
const counts = new Map<number, number>()
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const seq = canvas.cells[y]![x]!.floors[0]!.sequence
counts.set(seq, (counts.get(seq) ?? 0) + 1)
}
}
expect(counts.has(0)).toBe(true)
expect(counts.has(1)).toBe(true)
expect(counts.has(2)).toBe(true)
const c0 = counts.get(0)! / totalCells
const c1 = counts.get(1)! / totalCells
const c2 = counts.get(2)! / totalCells
// Law of large numbers for 3600 samples: within +-8% of true probabilities
expect(c0).toBeGreaterThanOrEqual(0.62)
expect(c0).toBeLessThanOrEqual(0.78)
expect(c1).toBeGreaterThanOrEqual(0.14)
expect(c1).toBeLessThanOrEqual(0.26)
expect(c2).toBeGreaterThanOrEqual(0.05)
expect(c2).toBeLessThanOrEqual(0.16)
})
test("spatial mode creates contiguous natural patches (higher neighborhood correlation than random)", () => {
const width = 40
const height = 40
const canvasSpatial = createCanvas(width, height, 1, 1, 0)
const canvasCdf = createCanvas(width, height, 1, 1, 0)
fillGround(canvasSpatial, candidates, 0, 0, width, height, { seed: 100, mode: "spatial", spatialScale: 6 })
fillGround(canvasCdf, candidates, 0, 0, width, height, { seed: 100, mode: "cdf" })
// Count horizontal neighbor matches: how often cell(x, y) has the same sequence as cell(x+1, y)
let spatialMatches = 0
let cdfMatches = 0
let totalPairs = 0
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width - 1; x += 1) {
totalPairs += 1
if (canvasSpatial.cells[y]![x]!.floors[0]!.sequence === canvasSpatial.cells[y]![x + 1]!.floors[0]!.sequence) {
spatialMatches += 1
}
if (canvasCdf.cells[y]![x]!.floors[0]!.sequence === canvasCdf.cells[y]![x + 1]!.floors[0]!.sequence) {
cdfMatches += 1
}
}
}
// Spatial coherent noise should have significantly higher neighbor continuity than independent CDF noise
const spatialCorr = spatialMatches / totalPairs
const cdfCorr = cdfMatches / totalPairs
expect(spatialCorr).toBeGreaterThan(cdfCorr)
expect(spatialCorr).toBeGreaterThan(0.60)
})
})
describe("Deterministic PRNG Consistency", () => {
test("same seed produces byte-for-byte identical tile layouts", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 60 },
{ style: 0, sequence: 1, weight: 30 },
{ style: 0, sequence: 2, weight: 10 },
]
const canvasA = createCanvas(25, 25, 1, 1, 0)
const canvasB = createCanvas(25, 25, 1, 1, 0)
fillGround(canvasA, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef })
fillGround(canvasB, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef })
for (let y = 0; y < 25; y += 1) {
for (let x = 0; x < 25; x += 1) {
const floorA = canvasA.cells[y]![x]!.floors[0]!
const floorB = canvasB.cells[y]![x]!.floors[0]!
expect(floorA.style).toBe(floorB.style)
expect(floorA.sequence).toBe(floorB.sequence)
}
}
})
test("different seeds produce different ground layouts", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 50 },
{ style: 0, sequence: 1, weight: 50 },
]
const canvasA = createCanvas(20, 20, 1, 1, 0)
const canvasB = createCanvas(20, 20, 1, 1, 0)
fillGround(canvasA, candidates, 0, 0, 20, 20, { seed: 111 })
fillGround(canvasB, candidates, 0, 0, 20, 20, { seed: 999 })
let differences = 0
for (let y = 0; y < 20; y += 1) {
for (let x = 0; x < 20; x += 1) {
if (canvasA.cells[y]![x]!.floors[0]!.sequence !== canvasB.cells[y]![x]!.floors[0]!.sequence) {
differences += 1
}
}
}
expect(differences).toBeGreaterThan(50)
})
})
describe("DT1 Library Derivation (deriveGroundTilesFromDt1Libraries)", () => {
test("filters libraries strictly according to dwDt1Mask", () => {
const lib0 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 50 },
])
const lib1 = makeMockDt1([
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
const lib2 = makeMockDt1([
{ type: 0, style: 0, sequence: 2, rarityFrameIndex: 10 },
])
const libraries = [lib0, lib1, lib2]
// Mask 0b001 = 1 (only lib 0)
const onlyLib0 = deriveGroundTilesFromDt1Libraries(libraries, 1)
expect(onlyLib0).toHaveLength(1)
expect(onlyLib0[0]).toMatchObject({ style: 0, sequence: 0, weight: 50 })
// Mask 0b101 = 5 (lib 0 and lib 2, skipping lib 1)
const lib0and2 = deriveGroundTilesFromDt1Libraries(libraries, 5)
expect(lib0and2).toHaveLength(2)
expect(lib0and2.map(t => t.sequence)).toEqual([0, 2])
// Mask 0b000 = 0 (no libraries active)
const empty = deriveGroundTilesFromDt1Libraries(libraries, 0)
expect(empty).toHaveLength(0)
})
test("filters out non-floor tiles (type !== 0) and weights by rarityFrameIndex", () => {
const dt1 = makeMockDt1([
// Floor tiles (type === 0)
{ type: 0, style: 1, sequence: 0, rarityFrameIndex: 60 },
{ type: 0, style: 1, sequence: 1, rarityFrameIndex: 40 },
// Same style/seq variant in library: weights should accumulate
{ type: 0, style: 1, sequence: 1, rarityFrameIndex: 10 },
// Wall tile (type === 1) - must be excluded
{ type: 1, style: 1, sequence: 0, rarityFrameIndex: 99 },
// Roof tile (type === 2) - must be excluded
{ type: 2, style: 1, sequence: 0, rarityFrameIndex: 99 },
])
const result = deriveGroundTilesFromDt1Libraries([dt1], 1)
expect(result).toHaveLength(2)
// Expected ordering: highest weight first
// seq 0 has 60, seq 1 has 40 + 10 = 50
expect(result[0]).toMatchObject({ style: 1, sequence: 0, weight: 60 })
expect(result[1]).toMatchObject({ style: 1, sequence: 1, weight: 50 })
})
test("handles empty libraries or libraries with no floor tiles gracefully", () => {
expect(deriveGroundTilesFromDt1Libraries([], 0xff)).toEqual([])
const noFloors = makeMockDt1([{ type: 1, style: 0, sequence: 0 }])
expect(deriveGroundTilesFromDt1Libraries([noFloors], 1)).toEqual([])
})
})
describe("Piece Floor Derivation (deriveGroundTiles & deriveGroundTile)", () => {
test("derives candidate ground tiles with frequencies from interior floor cells", () => {
// Create a piece with interior floor tiles:
// 8x8 piece has interior from x=1..6, y=1..6 (36 interior cells)
// Set cells x=2..4, y=2..4 to sequence 1 (9 cells)
const level = makeMockDs1(8, 8, 0, 0, (x, y) => {
if (x >= 2 && x <= 4 && y >= 2 && y <= 4) {
return { style: 0, sequence: 1 }
}
return undefined
})
const pieces: WildernessPiece[] = [{ name: "Test Piece", border: true, levels: [level] }]
const tiles = deriveGroundTiles(pieces)
expect(tiles.length).toBe(2)
// Sequence 0 is the majority, sequence 1 has 9 cells
expect(tiles[0]!.sequence).toBe(0)
expect(tiles[1]!.sequence).toBe(1)
expect(tiles[1]!.weight).toBe(9)
// deriveGroundTile should return the most frequent interior tile
const primary = deriveGroundTile(pieces)
expect(primary).toEqual({ style: 0, sequence: 0 })
})
})
describe("generateWilderness Integration", () => {
test("generates wilderness with explicit groundTiles blend", () => {
const pieces = makeTestPieces()
const candidateTiles: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 70 },
{ style: 0, sequence: 1, weight: 30 },
]
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor Blend Test",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 123456,
pieces,
substitutions: [],
groundTiles: candidateTiles,
})
expect(result.stats.groundTiles).toBeDefined()
const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(statsTiles).toHaveLength(2)
// Verify level cells contain both sequences
let seq0Count = 0
let seq1Count = 0
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 && !floor.hidden) {
if (floor.sequence === 0) seq0Count += 1
if (floor.sequence === 1) seq1Count += 1
}
}
}
expect(seq0Count).toBeGreaterThan(0)
expect(seq1Count).toBeGreaterThan(0)
expect(seq0Count).toBeGreaterThan(seq1Count)
})
test("generates wilderness with single groundTile maintaining pure single-tile fallback", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor Single Tile",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 654321,
pieces,
substitutions: [],
groundTile: { style: 0, sequence: 0 },
})
expect(result.stats.groundTile).toEqual({ style: 0, sequence: 0 })
})
test("generates wilderness with dt1Libraries deriving ground tiles automatically", () => {
const pieces = makeTestPieces()
const dt1 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor DT1 Test",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 777777,
pieces,
substitutions: [],
dt1Libraries: [dt1],
})
expect(result.stats.groundTiles).toBeDefined()
const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(statsTiles).toHaveLength(2)
expect(statsTiles[0]!.sequence).toBe(0)
expect(statsTiles[1]!.sequence).toBe(1)
})
})
})