1112 lines
39 KiB
TypeScript
1112 lines
39 KiB
TypeScript
import { describe, expect, test } from "vitest"
|
|
import {
|
|
fillGround,
|
|
sampleGroundTile,
|
|
deriveGroundTilesFromDt1Libraries,
|
|
deriveGroundTiles,
|
|
deriveGroundTile,
|
|
cellHash,
|
|
smoothNoise2D,
|
|
createCanvas,
|
|
generateWilderness,
|
|
CANONICAL_GROUND_TILES,
|
|
CANONICAL_GROUND_POOLS,
|
|
computeNeighborMask,
|
|
NEIGHBOR_MASK_NE,
|
|
NEIGHBOR_MASK_E,
|
|
NEIGHBOR_MASK_SE,
|
|
NEIGHBOR_MASK_N,
|
|
NEIGHBOR_MASK_S,
|
|
NEIGHBOR_MASK_NW,
|
|
NEIGHBOR_MASK_W,
|
|
NEIGHBOR_MASK_SW,
|
|
NEIGHBOR_OFFSETS,
|
|
DIRT_PATH_TILE_LUT,
|
|
WATER_EDGE_TILE_LUT,
|
|
DRLGROOMTILE_GetTileCache,
|
|
applyGroundTransitions,
|
|
} from "../src/game/wilderness.ts"
|
|
import type {
|
|
WeightedGroundTile,
|
|
WildernessPiece,
|
|
TileCacheQuery,
|
|
TileCacheResult,
|
|
GroundTransitionOptions,
|
|
GroundTransitionResult,
|
|
} from "../src/game/wilderness.ts"
|
|
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
|
|
import type { Ds1, Ds1Cell, Ds1Floor } 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> & { rarity?: number; Rarity?: number })[]): 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)
|
|
})
|
|
|
|
test("generateWilderness populates groundTransitions stats with 0 missing tiles", () => {
|
|
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 Transitions Test",
|
|
levelTypeName: "Act 1 - Wilderness",
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 6,
|
|
subTheme: 0,
|
|
seed: 888888,
|
|
pieces,
|
|
substitutions: [],
|
|
dt1Libraries: [dt1],
|
|
})
|
|
|
|
expect(result.stats.groundTransitions).toBeDefined()
|
|
expect(result.stats.groundTransitions?.transitionCells).toBeGreaterThanOrEqual(0)
|
|
expect(result.stats.groundTransitions?.roadTransitions).toBeGreaterThanOrEqual(0)
|
|
expect(result.stats.groundTransitions?.waterTransitions).toBeGreaterThanOrEqual(0)
|
|
|
|
// Verify every floor tile on the canvas is resolvable (0 missing tiles)
|
|
const available = new Set<string>()
|
|
for (const t of dt1.tiles) {
|
|
if (t.type === 0) available.add(`${String(t.style)}:${String(t.sequence)}`)
|
|
}
|
|
|
|
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 && floor.prop1 !== 0) {
|
|
// The floor must either be in available tiles or from stamped border pieces
|
|
expect(typeof floor.style).toBe("number")
|
|
expect(typeof floor.sequence).toBe("number")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("Neighbor Mask Calculation (Issue #70)", () => {
|
|
test("calculates bitmask 0 for isolated cell with no matching neighbors", () => {
|
|
const canvas = createCanvas(5, 5, 1, 1, 0)
|
|
const mask = computeNeighborMask(canvas, 2, 2, () => false)
|
|
expect(mask).toBe(0)
|
|
})
|
|
|
|
test("calculates individual cardinal neighbor bitmasks (N, S, E, W)", () => {
|
|
const canvas = createCanvas(5, 5, 1, 1, 0)
|
|
|
|
// Test North: (2, 1) -> dx=0, dy=-1
|
|
const maskN = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 1)
|
|
expect(maskN).toBe(NEIGHBOR_MASK_N)
|
|
expect(maskN).toBe(0x10)
|
|
|
|
// Test South: (2, 3) -> dx=0, dy=+1
|
|
const maskS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 3)
|
|
expect(maskS).toBe(NEIGHBOR_MASK_S)
|
|
expect(maskS).toBe(0x08)
|
|
|
|
// Test East: (3, 2) -> dx=+1, dy=0
|
|
const maskE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 2)
|
|
expect(maskE).toBe(NEIGHBOR_MASK_E)
|
|
expect(maskE).toBe(0x40)
|
|
|
|
// Test West: (1, 2) -> dx=-1, dy=0
|
|
const maskW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 2)
|
|
expect(maskW).toBe(NEIGHBOR_MASK_W)
|
|
expect(maskW).toBe(0x02)
|
|
})
|
|
|
|
test("calculates individual diagonal corner bitmasks (NE, NW, SE, SW)", () => {
|
|
const canvas = createCanvas(5, 5, 1, 1, 0)
|
|
|
|
// Test NE: (3, 1) -> dx=+1, dy=-1
|
|
const maskNE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 1)
|
|
expect(maskNE).toBe(NEIGHBOR_MASK_NE)
|
|
expect(maskNE).toBe(0x80)
|
|
|
|
// Test NW: (1, 1) -> dx=-1, dy=-1
|
|
const maskNW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 1)
|
|
expect(maskNW).toBe(NEIGHBOR_MASK_NW)
|
|
expect(maskNW).toBe(0x04)
|
|
|
|
// Test SE: (3, 3) -> dx=+1, dy=+1
|
|
const maskSE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 3)
|
|
expect(maskSE).toBe(NEIGHBOR_MASK_SE)
|
|
expect(maskSE).toBe(0x20)
|
|
|
|
// Test SW: (1, 3) -> dx=-1, dy=+1
|
|
const maskSW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 3)
|
|
expect(maskSW).toBe(NEIGHBOR_MASK_SW)
|
|
expect(maskSW).toBe(0x01)
|
|
})
|
|
|
|
test("calculates composite neighbor bitmasks correctly", () => {
|
|
const canvas = createCanvas(5, 5, 1, 1, 0)
|
|
|
|
// N + S = 0x10 | 0x08 = 0x18
|
|
const maskNS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 2 && ny === 1) || (nx === 2 && ny === 3))
|
|
expect(maskNS).toBe(0x18)
|
|
|
|
// E + W = 0x40 | 0x02 = 0x42
|
|
const maskEW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 3 && ny === 2) || (nx === 1 && ny === 2))
|
|
expect(maskEW).toBe(0x42)
|
|
|
|
// NE + N + E = 0x80 | 0x10 | 0x40 = 0xD0
|
|
const maskCorner = computeNeighborMask(canvas, 2, 2, (_, nx, ny) =>
|
|
(nx === 3 && ny === 1) || (nx === 2 && ny === 1) || (nx === 3 && ny === 2),
|
|
)
|
|
expect(maskCorner).toBe(0xD0)
|
|
|
|
// All 8 neighbors = 0xFF (255)
|
|
const maskAll = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx !== 2 || ny !== 2)
|
|
expect(maskAll).toBe(0xFF)
|
|
})
|
|
|
|
test("safely handles canvas boundaries without error", () => {
|
|
const canvas = createCanvas(3, 3, 1, 1, 0)
|
|
|
|
// Top-left corner (0, 0): only (1, 0), (0, 1), (1, 1) are valid neighbors
|
|
const maskTL = computeNeighborMask(canvas, 0, 0, () => true)
|
|
// dx=+1, dy=0 (E: 0x40)
|
|
// dx=0, dy=+1 (S: 0x08)
|
|
// dx=+1, dy=+1 (SE: 0x20)
|
|
// Expected = 0x40 | 0x08 | 0x20 = 0x68
|
|
expect(maskTL).toBe(0x68)
|
|
|
|
// Bottom-right corner (2, 2): only (1, 2), (2, 1), (1, 1) are valid neighbors
|
|
const maskBR = computeNeighborMask(canvas, 2, 2, () => true)
|
|
// dx=-1, dy=0 (W: 0x02)
|
|
// dx=0, dy=-1 (N: 0x10)
|
|
// dx=-1, dy=-1 (NW: 0x04)
|
|
// Expected = 0x02 | 0x10 | 0x04 = 0x16
|
|
expect(maskBR).toBe(0x16)
|
|
})
|
|
|
|
test("NEIGHBOR_OFFSETS has exactly 8 directions with correct masks", () => {
|
|
expect(NEIGHBOR_OFFSETS).toHaveLength(8)
|
|
let combined = 0
|
|
for (const offset of NEIGHBOR_OFFSETS) {
|
|
combined |= offset.mask
|
|
}
|
|
expect(combined).toBe(0xFF)
|
|
})
|
|
})
|
|
|
|
describe("DRLGROOMTILE_GetTileCache & Multi-DT1 Rarity Weighting (Issue #70)", () => {
|
|
test("filters candidate tiles strictly by dwDt1Mask", () => {
|
|
const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 10, rarityFrameIndex: 50 }])
|
|
const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 20, rarityFrameIndex: 50 }])
|
|
const libraries = [dt1A, dt1B]
|
|
|
|
// dwDt1Mask = 0x01 (slot 0 only)
|
|
const res0 = DRLGROOMTILE_GetTileCache(libraries, 0x01, { type: 0, seed: 0 })
|
|
expect(res0).not.toBeNull()
|
|
expect(res0?.sequence).toBe(10)
|
|
expect(res0?.libraryIndex).toBe(0)
|
|
|
|
// dwDt1Mask = 0x02 (slot 1 only)
|
|
const res1 = DRLGROOMTILE_GetTileCache(libraries, 0x02, { type: 0, seed: 0 })
|
|
expect(res1).not.toBeNull()
|
|
expect(res1?.sequence).toBe(20)
|
|
expect(res1?.libraryIndex).toBe(1)
|
|
|
|
// dwDt1Mask = 0x00 (no slots active)
|
|
const resNone = DRLGROOMTILE_GetTileCache(libraries, 0x00, { type: 0, seed: 0 })
|
|
expect(resNone).toBeNull()
|
|
})
|
|
|
|
test("filters candidate tiles by query type, style, and sequence", () => {
|
|
const dt1 = makeMockDt1([
|
|
{ type: 0, style: 2, sequence: 24, rarityFrameIndex: 10 },
|
|
{ type: 0, style: 2, sequence: 0, rarityFrameIndex: 10 },
|
|
{ type: 1, style: 2, sequence: 24, rarityFrameIndex: 10 }, // wall
|
|
])
|
|
|
|
// Query specific sequence
|
|
const resSeq = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 0, style: 2, sequence: 24, seed: 0 })
|
|
expect(resSeq?.type).toBe(0)
|
|
expect(resSeq?.style).toBe(2)
|
|
expect(resSeq?.sequence).toBe(24)
|
|
|
|
// Query wall type
|
|
const resWall = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 1, seed: 0 })
|
|
expect(resWall?.type).toBe(1)
|
|
|
|
// Non-existent style
|
|
const resMissing = DRLGROOMTILE_GetTileCache([dt1], 0x01, { style: 99, seed: 0 })
|
|
expect(resMissing).toBeNull()
|
|
})
|
|
|
|
test("supports tile rarityFrameIndex, rarity, and Rarity field names", () => {
|
|
const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 45 }])
|
|
const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 2, rarity: 75 }])
|
|
const dt1C = makeMockDt1([{ type: 0, style: 0, sequence: 3, Rarity: 95 }])
|
|
|
|
const resA = DRLGROOMTILE_GetTileCache([dt1A], 1, { type: 0, seed: 0 })
|
|
expect(resA?.weight).toBe(45)
|
|
|
|
const resB = DRLGROOMTILE_GetTileCache([dt1B], 1, { type: 0, seed: 0 })
|
|
expect(resB?.weight).toBe(75)
|
|
|
|
const resC = DRLGROOMTILE_GetTileCache([dt1C], 1, { type: 0, seed: 0 })
|
|
expect(resC?.weight).toBe(95)
|
|
})
|
|
|
|
test("distributes tile selection proportionally to rarity weights", () => {
|
|
const dt1 = makeMockDt1([
|
|
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
|
|
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
|
])
|
|
|
|
let count0 = 0
|
|
let count1 = 0
|
|
const totalSamples = 5000
|
|
|
|
for (let i = 0; i < totalSamples; i += 1) {
|
|
// Roll deterministically with coordinates (i, 0) and seed
|
|
const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 12345, x: i, y: 0 })
|
|
if (res?.sequence === 0) count0 += 1
|
|
if (res?.sequence === 1) count1 += 1
|
|
}
|
|
|
|
const ratio0 = count0 / totalSamples
|
|
const ratio1 = count1 / totalSamples
|
|
|
|
// Expected ~0.80 and ~0.20 within +/- 0.04
|
|
expect(ratio0).toBeGreaterThan(0.75)
|
|
expect(ratio0).toBeLessThan(0.85)
|
|
expect(ratio1).toBeGreaterThan(0.15)
|
|
expect(ratio1).toBeLessThan(0.25)
|
|
})
|
|
|
|
test("deriveGroundTilesFromDt1Libraries respects dwDt1Mask, rarities and sorts descending", () => {
|
|
const dt1 = makeMockDt1([
|
|
{ type: 0, style: 0, sequence: 0, rarity: 15 },
|
|
{ type: 0, style: 0, sequence: 1, Rarity: 85 },
|
|
])
|
|
|
|
const result = deriveGroundTilesFromDt1Libraries([dt1], 1)
|
|
expect(result).toHaveLength(2)
|
|
// Sequence 1 has higher rarity (85), so it must be sorted first
|
|
expect(result[0]!.sequence).toBe(1)
|
|
expect(result[0]!.weight).toBe(85)
|
|
expect(result[1]!.sequence).toBe(0)
|
|
expect(result[1]!.weight).toBe(15)
|
|
})
|
|
})
|
|
|
|
describe("applyGroundTransitions Auto-Tiling (Issue #70)", () => {
|
|
function setFloor(
|
|
canvas: ReturnType<typeof createCanvas>,
|
|
x: number,
|
|
y: number,
|
|
floor: { style: number; sequence: number; prop1?: number },
|
|
): void {
|
|
const cell = canvas.cells[y]?.[x]
|
|
if (!cell) return
|
|
;(cell.floors as Ds1Floor[])[0] = {
|
|
prop1: floor.prop1 ?? 2,
|
|
style: floor.style,
|
|
sequence: floor.sequence,
|
|
unknown1: 0,
|
|
unknown2: 0,
|
|
hidden: false,
|
|
}
|
|
}
|
|
|
|
test("generates road-to-ground edge transitions using DIRT_PATH_TILE_LUT while keeping interior road intact", () => {
|
|
const canvas = createCanvas(9, 9, 1, 1, 0)
|
|
|
|
// Initialize canvas with base ground: style 0, sequence 0
|
|
for (let y = 0; y < 9; y += 1) {
|
|
for (let x = 0; x < 9; x += 1) {
|
|
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
|
}
|
|
}
|
|
|
|
// Stamp a 3x3 road patch at (3, 3) to (5, 5) with style 0, sequence 1
|
|
for (let ry = 3; ry <= 5; ry += 1) {
|
|
for (let rx = 3; rx <= 5; rx += 1) {
|
|
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
|
}
|
|
}
|
|
|
|
const roadDt1 = makeMockDt1(
|
|
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
|
|
)
|
|
const res = applyGroundTransitions(canvas, {
|
|
libraries: [roadDt1],
|
|
dt1Mask: 1,
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
levelId: 2,
|
|
})
|
|
expect(res.roadTransitions).toBeGreaterThan(0)
|
|
expect(res.waterTransitions).toBe(0)
|
|
expect(res.transitionCells).toBe(res.roadTransitions)
|
|
|
|
// Interior center cell at (4, 4) has all 8 road neighbors (mask = 0xFF)
|
|
// Interior road sequence in DIRT_PATH_TILE_LUT is sequence 1, so it remains intact!
|
|
const centerFloor = canvas.cells[4]![4]!.floors[0]!
|
|
expect(centerFloor.style).toBe(0)
|
|
expect(centerFloor.sequence).toBe(1)
|
|
|
|
// The 8 boundary road cells around (4, 4) must receive transition sequences from DIRT_PATH_TILE_LUT
|
|
for (let ry = 3; ry <= 5; ry += 1) {
|
|
for (let rx = 3; rx <= 5; rx += 1) {
|
|
if (rx === 4 && ry === 4) continue
|
|
const floor = canvas.cells[ry]![rx]!.floors[0]!
|
|
expect(floor.style).toBe(0)
|
|
expect(floor.sequence).toBeGreaterThanOrEqual(1)
|
|
expect(floor.sequence).toBeLessThanOrEqual(46)
|
|
}
|
|
}
|
|
|
|
// Ground cells outside road (e.g. (0, 0)) remain sequence 0
|
|
expect(canvas.cells[0]![0]!.floors[0]!.sequence).toBe(0)
|
|
})
|
|
|
|
test("generates water-to-ground edge transitions using WATER_EDGE_TILE_LUT while keeping interior deep water (24)", () => {
|
|
const canvas = createCanvas(9, 9, 1, 1, 0)
|
|
|
|
// Initialize canvas with base ground: style 0, sequence 0
|
|
for (let y = 0; y < 9; y += 1) {
|
|
for (let x = 0; x < 9; x += 1) {
|
|
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
|
}
|
|
}
|
|
|
|
// Stamp a 3x3 water patch at (3, 3) to (5, 5) with style 2, sequence 0
|
|
for (let wy = 3; wy <= 5; wy += 1) {
|
|
for (let wx = 3; wx <= 5; wx += 1) {
|
|
setFloor(canvas, wx, wy, { style: 2, sequence: 0 })
|
|
}
|
|
}
|
|
|
|
const waterDt1 = makeMockDt1(
|
|
Array.from({ length: 25 }, (_, i) => ({ type: 0, style: 2, sequence: i })),
|
|
)
|
|
const res = applyGroundTransitions(canvas, {
|
|
libraries: [waterDt1],
|
|
dt1Mask: 1,
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
levelId: 2,
|
|
})
|
|
expect(res.waterTransitions).toBeGreaterThan(0)
|
|
expect(res.transitionCells).toBe(res.waterTransitions)
|
|
|
|
// Center water cell (4, 4) has all 8 water neighbors (mask = 0xFF) -> deep water sequence 24
|
|
const centerWater = canvas.cells[4]![4]!.floors[0]!
|
|
expect(centerWater.style).toBe(2)
|
|
expect(centerWater.sequence).toBe(24)
|
|
|
|
// Perimeter water cells receive water edge sequences (0..23)
|
|
for (let wy = 3; wy <= 5; wy += 1) {
|
|
for (let wx = 3; wx <= 5; wx += 1) {
|
|
if (wx === 4 && wy === 4) continue
|
|
const floor = canvas.cells[wy]![wx]!.floors[0]!
|
|
expect(floor.style).toBe(2)
|
|
expect(floor.sequence).toBeLessThan(24)
|
|
}
|
|
}
|
|
})
|
|
|
|
test("returns 0 transitions when dt1Libraries are undefined or empty (Issue #113)", () => {
|
|
const canvas = createCanvas(9, 9, 1, 1, 0)
|
|
for (let y = 0; y < 9; y += 1) {
|
|
for (let x = 0; x < 9; x += 1) {
|
|
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
|
}
|
|
}
|
|
for (let ry = 3; ry <= 5; ry += 1) {
|
|
for (let rx = 3; rx <= 5; rx += 1) {
|
|
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
|
}
|
|
}
|
|
|
|
// No options -> 0 transitions
|
|
const resNoOptions = applyGroundTransitions(canvas)
|
|
expect(resNoOptions.transitionCells).toBe(0)
|
|
expect(resNoOptions.roadTransitions).toBe(0)
|
|
expect(resNoOptions.waterTransitions).toBe(0)
|
|
|
|
// Empty libraries array -> 0 transitions
|
|
const resEmptyLibs = applyGroundTransitions(canvas, { libraries: [] })
|
|
expect(resEmptyLibs.transitionCells).toBe(0)
|
|
expect(resEmptyLibs.roadTransitions).toBe(0)
|
|
})
|
|
|
|
test("does not inject transition tiles into non-Act-1 environments (Issue #113)", () => {
|
|
const canvas = createCanvas(9, 9, 1, 1, 0)
|
|
for (let y = 0; y < 9; y += 1) {
|
|
for (let x = 0; x < 9; x += 1) {
|
|
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
|
}
|
|
}
|
|
for (let ry = 3; ry <= 5; ry += 1) {
|
|
for (let rx = 3; rx <= 5; rx += 1) {
|
|
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
|
}
|
|
}
|
|
|
|
const roadDt1 = makeMockDt1(
|
|
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
|
|
)
|
|
|
|
// Mesa 1 (levelId 104, Act 4 - Mesa)
|
|
const resMesa = applyGroundTransitions(canvas, {
|
|
libraries: [roadDt1],
|
|
dt1Mask: 1,
|
|
levelTypeName: 'Act 4 - Mesa',
|
|
levelId: 104,
|
|
})
|
|
expect(resMesa.transitionCells).toBe(0)
|
|
|
|
// Desert 3 (levelId 43, Act 2 - Desert)
|
|
const resDesert = applyGroundTransitions(canvas, {
|
|
libraries: [roadDt1],
|
|
dt1Mask: 1,
|
|
levelTypeName: 'Act 2 - Desert',
|
|
levelId: 43,
|
|
})
|
|
expect(resDesert.transitionCells).toBe(0)
|
|
|
|
// Barricade 2 (levelId 112, Act 5 - Barricade)
|
|
const resBarricade2 = applyGroundTransitions(canvas, {
|
|
libraries: [roadDt1],
|
|
dt1Mask: 1,
|
|
levelTypeName: 'Act 5 - Barricade',
|
|
levelId: 112,
|
|
})
|
|
expect(resBarricade2.transitionCells).toBe(0)
|
|
|
|
// Barricade Snow (levelId 117, Act 5 - Barricade)
|
|
const resBarricadeSnow = applyGroundTransitions(canvas, {
|
|
libraries: [roadDt1],
|
|
dt1Mask: 1,
|
|
levelTypeName: 'Act 5 - Barricade',
|
|
levelId: 117,
|
|
})
|
|
expect(resBarricadeSnow.transitionCells).toBe(0)
|
|
})
|
|
|
|
test("WATER_EDGE_TILE_LUT has exactly 256 entries with 24 at 0xFF", () => {
|
|
expect(WATER_EDGE_TILE_LUT).toHaveLength(256)
|
|
expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24)
|
|
for (let i = 0; i < 255; i += 1) {
|
|
expect(WATER_EDGE_TILE_LUT[i]).toBeLessThanOrEqual(23)
|
|
}
|
|
})
|
|
|
|
test("guarantees 0 missing tiles by strictly respecting supplied DT1 libraries", () => {
|
|
const canvas = createCanvas(9, 9, 1, 1, 0)
|
|
for (let y = 0; y < 9; y += 1) {
|
|
for (let x = 0; x < 9; x += 1) {
|
|
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
|
}
|
|
}
|
|
|
|
// Stamp road
|
|
for (let ry = 3; ry <= 5; ry += 1) {
|
|
for (let rx = 3; rx <= 5; rx += 1) {
|
|
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
|
}
|
|
}
|
|
|
|
// Mock DT1 that only has style 0, sequence 0 and 1 (no transition sequences 2..46)
|
|
const restrictedDt1 = makeMockDt1([
|
|
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
|
|
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
|
])
|
|
|
|
const res = applyGroundTransitions(canvas, {
|
|
libraries: [restrictedDt1],
|
|
dt1Mask: 1,
|
|
})
|
|
|
|
// Since transitions 2..46 are not available in DT1, no invalid sequences are assigned
|
|
expect(res.roadTransitions).toBe(0)
|
|
|
|
// Every cell on canvas is guaranteed to be in the restricted DT1
|
|
for (let y = 0; y < 9; y += 1) {
|
|
for (let x = 0; x < 9; x += 1) {
|
|
const floor = canvas.cells[y]![x]!.floors[0]!
|
|
expect(floor.style === 0 && (floor.sequence === 0 || floor.sequence === 1)).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Canonical Ground Pools (Phase D3)', () => {
|
|
test('contains authentic pools for all wilderness level types', () => {
|
|
const expectedKeys = [
|
|
'Act 1 - Wilderness',
|
|
'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 key of expectedKeys) {
|
|
const pool = CANONICAL_GROUND_POOLS[key]
|
|
expect(pool).toBeDefined()
|
|
expect(pool!.length).toBeGreaterThanOrEqual(1)
|
|
for (const tile of pool!) {
|
|
expect(tile.weight).toBeGreaterThan(0)
|
|
expect(tile.style).toBeGreaterThanOrEqual(0)
|
|
expect(tile.sequence).toBeGreaterThanOrEqual(0)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('Act 3 Kurast canonical ground pool uses authentic style 1 from DarkGrass.dt1', () => {
|
|
expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).toBe(1)
|
|
const kurastPool = CANONICAL_GROUND_POOLS['Act 3 - Kurast']!
|
|
for (const tile of kurastPool) {
|
|
expect(tile.style).toBe(1)
|
|
}
|
|
})
|
|
|
|
test('Act 5 Barricade distinguishes dirt (style 0) from snow (style 6)', () => {
|
|
expect(CANONICAL_GROUND_TILES['Act 5 - Barricade'].style).toBe(0)
|
|
expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).toBe(6)
|
|
for (const tile of CANONICAL_GROUND_POOLS['Act 5 - Barricade']!) {
|
|
expect(tile.style).toBe(0)
|
|
}
|
|
for (const tile of CANONICAL_GROUND_POOLS['Act 5 - Barricade Snow']!) {
|
|
expect(tile.style).toBe(6)
|
|
}
|
|
})
|
|
|
|
test('generateWilderness resolves snow barricade ground tiles when isSnowBarricade is true', () => {
|
|
const pieces = makeTestPieces()
|
|
const result = generateWilderness({
|
|
levelId: 117,
|
|
levelName: 'Frozen Tundra',
|
|
levelTypeName: 'Act 5 - Barricade',
|
|
sizeX: 64,
|
|
sizeY: 64,
|
|
subType: 11,
|
|
subTheme: 0,
|
|
seed: 42,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
expect(result.stats.groundTile?.style).toBe(6)
|
|
const tiles = result.stats.groundTiles as readonly WeightedGroundTile[]
|
|
expect(tiles).toBeDefined()
|
|
expect(tiles.every(t => t.style === 6)).toBe(true)
|
|
})
|
|
|
|
test('generateWilderness resolves Kurast ground tiles with style 1', () => {
|
|
const pieces = makeTestPieces()
|
|
const result = generateWilderness({
|
|
levelId: 79,
|
|
levelName: 'Kurast Bazaar',
|
|
levelTypeName: 'Act 3 - Kurast',
|
|
sizeX: 64,
|
|
sizeY: 64,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 42,
|
|
pieces,
|
|
substitutions: [],
|
|
})
|
|
expect(result.stats.groundTile?.style).toBe(1)
|
|
const tiles = result.stats.groundTiles as readonly WeightedGroundTile[]
|
|
expect(tiles).toBeDefined()
|
|
expect(tiles.every(t => t.style === 1)).toBe(true)
|
|
})
|
|
})
|
|
})
|