From 12588ea9488f6336402084e872c517ad81c4c568 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 07:08:24 +0000 Subject: [PATCH] feat(map-gen/wilderness): support multi-DT1 weighted ground tile blending (fixes #52) --- src/game/wilderness.ts | 555 +++++++++++++++++++++++--- tests/wilderness-ground-blend.test.ts | 511 ++++++++++++++++++++++++ 2 files changed, 1013 insertions(+), 53 deletions(-) create mode 100644 tests/wilderness-ground-blend.test.ts diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 5ff9582..6f1e299 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -70,11 +70,12 @@ * set to the packed value `0x40002` (`bIsFloor` plus a wall-layer bit) and the * actual DT1 style/sequence is then chosen at runtime by a rarity roll inside * `DRLGROOMTILE_GetTileCache`, against the libraries selected by the level type's - * hardcoded `dwDt1Mask`. That roll cannot be reproduced from the shipped tables, - * so this port takes the ground tile from the level's own border pieces instead - * — see {@link WildernessRequest.groundTile}. + * hardcoded `dwDt1Mask`. This port supports multi-DT1 weighted ground tile blending + * via `fillGround` using `dwDt1Mask` / `RarityFrameIndex` or candidate ground tiles, + * with single-tile fallback for backward compatibility — see {@link WildernessRequest.groundTile}. */ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from '../formats/ds1.ts' +import type { Dt1 } from '../formats/dt1.ts' import { Rng } from './rng.ts' import { SUB_TILES_PER_TILE } from './map.ts' import type { Side } from './world-graph.ts' @@ -205,15 +206,24 @@ export interface WildernessRequest { readonly specialPresets?: readonly string[] /** * The ground tile to lay under everything, as a DT1 `style`/`sequence` pair. + * Can also accept an array of {@link WeightedGroundTile} candidates for multi-tile blending. * * The shipped game leaves this to a runtime rarity roll over the DT1 libraries * the level type's mask selects, which no table records. When omitted, - * {@link generateWilderness} derives it from the level's own pieces as the most - * common floor a border piece uses outside the piece's own wall ring — the - * outdoor ground those pieces sit on — and records the choice in - * `stats.groundTile`. + * {@link generateWilderness} derives it from the level's own pieces or DT1 libraries, + * recording the choice in `stats.groundTile` (and `stats.groundTiles`). */ - readonly groundTile?: { readonly style: number; readonly sequence: number } + readonly groundTile?: { readonly style: number; readonly sequence: number } | readonly WeightedGroundTile[] + /** + * Array of candidate ground tiles with relative weights for multi-tile ground blending. + * When provided, `fillGround` blends these tiles across the canvas. + */ + readonly groundTiles?: readonly WeightedGroundTile[] + /** + * DT1 libraries loaded for the level. When supplied without explicit groundTile(s), + * candidate ground tiles are derived using dwDt1Mask and RarityFrameIndex. + */ + readonly dt1Libraries?: readonly Dt1[] /** * Explicit block-grid size, for the levels `Levels.txt` leaves unset. * @@ -284,6 +294,18 @@ export const CANONICAL_GROUND_TILES: Readonly> = { + 'Act 1 - Wilderness': [{ style: 0, sequence: 0, weight: 1 }], + 'Act 2 - Desert': [{ style: 0, sequence: 1, weight: 1 }], + 'Act 3 - Jungle': [{ style: 0, sequence: 0, weight: 1 }], + 'Act 3 - Kurast': [{ style: 0, sequence: 0, weight: 1 }], + 'Act 4 - Mesa': [{ style: 10, sequence: 0, weight: 1 }], + 'Act 4 - Lava': [{ style: 20, sequence: 3, weight: 1 }], + 'Act 5 - Siege': [{ style: 0, sequence: 0, weight: 1 }], + 'Act 5 - Barricade': [{ style: 0, sequence: 0, weight: 1 }], +} + /* ------------------------------------------------------------------------- * * Classification * ------------------------------------------------------------------------- */ @@ -324,7 +346,7 @@ function emptyFloor(): Ds1Floor { } /** The mutable map under construction. */ -interface Canvas { +export interface Canvas { readonly width: number readonly height: number readonly cells: Ds1Cell[][] @@ -344,7 +366,7 @@ interface Canvas { * @param substitutionLayers - substitution layers every cell carries. * @returns the canvas. */ -function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas { +export function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] @@ -463,30 +485,317 @@ function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number) return written } +/** A candidate ground tile with an optional weighting/rarity. */ +export interface WeightedGroundTile { + readonly style: number + readonly sequence: number + /** + * Relative selection weight (e.g. from `RarityFrameIndex` or piece counts). + * Defaults to 1 (or `rarityFrameIndex`) if omitted or <= 0. + */ + readonly weight?: number + /** + * Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile. + */ + readonly rarityFrameIndex?: number +} + +/** Configuration options for ground tile blending. */ +export interface FillGroundOptions { + /** PRNG seed or Rng instance for deterministic tile generation. */ + readonly seed?: number | Rng + /** Blending mode: 'cdf' (cell-by-cell weighted draw) or 'spatial' (coherent noise patches). Default: 'spatial'. */ + readonly mode?: 'cdf' | 'spatial' + /** Spatial noise scale in cells (default: 6). Higher values produce broader patches. */ + readonly spatialScale?: number + /** Weight of spatial coherence vs cell jitter in [0, 1] (default: 0.6). */ + readonly spatialCoherence?: number +} + /** - * Write one floor tile into every cell of a rectangle. + * Fast, high-quality 32-bit integer hash for 2D coordinates and a seed. + * Returns a uniform pseudo-random number in [0, 1). + */ +export function cellHash(x: number, y: number, seed: number): number { + let h = (seed ^ Math.imul(x, 0x1b873593) ^ Math.imul(y, 0x85ebca6b)) >>> 0 + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0 + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0 + h = (h ^ (h >>> 16)) >>> 0 + return h / 4294967296 +} + +/** + * 2D smooth value noise with cubic Hermite interpolation. + * Returns a value in [0, 1). + */ +export function smoothNoise2D(x: number, y: number, scale: number, seed: number): number { + const invScale = 1 / Math.max(1, scale) + const gx = Math.floor(x * invScale) + const gy = Math.floor(y * invScale) + const fx = x * invScale - gx + const fy = y * invScale - gy + + // Cubic Hermite smoothstep: 3t^2 - 2t^3 + const sx = fx * fx * (3 - 2 * fx) + const sy = fy * fy * (3 - 2 * fy) + + const n00 = cellHash(gx, gy, seed) + const n10 = cellHash(gx + 1, gy, seed) + const n01 = cellHash(gx, gy + 1, seed) + const n11 = cellHash(gx + 1, gy + 1, seed) + + const nx0 = n00 + sx * (n10 - n00) + const nx1 = n01 + sx * (n11 - n01) + return nx0 + sy * (nx1 - nx0) +} + +function isTileArray( + val: unknown, +): val is readonly unknown[] { + return Array.isArray(val) +} + +/** + * Sample a single ground tile at (x, y) from weighted candidate tiles. + */ +export function sampleGroundTile( + tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[], + x: number, + y: number, + optionsOrSeed?: number | Rng | FillGroundOptions, +): WeightedGroundTile { + if (!isTileArray(tileOrTiles)) return tileOrTiles + if (tileOrTiles.length === 0) return { style: 0, sequence: 0 } + const tiles = tileOrTiles as readonly WeightedGroundTile[] + if (tiles.length === 1) return tiles[0]! + + let seed = 0 + let mode: 'cdf' | 'spatial' = 'spatial' + let spatialScale = 6 + let spatialCoherence = 0.6 + + if (typeof optionsOrSeed === 'number') { + seed = optionsOrSeed >>> 0 + } else if (optionsOrSeed instanceof Rng) { + seed = optionsOrSeed.seed >>> 0 + } else if (optionsOrSeed && typeof optionsOrSeed === 'object') { + if (typeof optionsOrSeed.seed === 'number') seed = optionsOrSeed.seed >>> 0 + else if (optionsOrSeed.seed instanceof Rng) seed = optionsOrSeed.seed.seed >>> 0 + if (optionsOrSeed.mode !== undefined) mode = optionsOrSeed.mode + if (typeof optionsOrSeed.spatialScale === 'number' && optionsOrSeed.spatialScale > 0) { + spatialScale = optionsOrSeed.spatialScale + } + if (typeof optionsOrSeed.spatialCoherence === 'number') { + spatialCoherence = Math.max(0, Math.min(1, optionsOrSeed.spatialCoherence)) + } + } + + const valid = tileOrTiles.filter(t => { + const w = t.weight !== undefined ? t.weight : t.rarityFrameIndex !== undefined ? t.rarityFrameIndex : 1 + return w > 0 + }) + const active = valid.length > 0 ? valid : tileOrTiles + if (active.length === 1) return active[0]! + + let totalWeight = 0 + for (const c of active) { + const w = c.weight !== undefined ? c.weight : c.rarityFrameIndex !== undefined ? c.rarityFrameIndex : 1 + totalWeight += Math.max(0, w) + } + if (totalWeight <= 0) return active[0]! + + const cdfs: number[] = [] + let running = 0 + for (let i = 0; i < active.length; i += 1) { + const w = active[i]!.weight !== undefined + ? active[i]!.weight! + : active[i]!.rarityFrameIndex !== undefined + ? active[i]!.rarityFrameIndex! + : 1 + running += Math.max(0, w) + cdfs.push(running / totalWeight) + } + cdfs[cdfs.length - 1] = 1.0 + + let u: number + if (mode === 'cdf') { + u = cellHash(x, y, seed) + } else { + const smooth = smoothNoise2D(x, y, spatialScale, seed) + const jitter = cellHash(x, y, seed ^ 0x9e3779b9) + u = spatialCoherence * smooth + (1 - spatialCoherence) * jitter + } + + for (let i = 0; i < cdfs.length; i += 1) { + if (u < cdfs[i]!) return active[i]! + } + return active[active.length - 1]! +} + +/** + * Write one or more floor tiles into every cell of a rectangle with weighted random blending. * - * Used for the ground. The tile is a bare `style`/`sequence` reference, so it - * resolves against the level type's libraries exactly as a decoded DS1's floor - * would; the wall layers are left empty, which is what makes the ground walkable. + * Supports: + * 1. Single tile fallback (backward compatibility): All cells receive the single tile. + * 2. Multi-tile weighted distribution: Tiles are sampled via CDF sampling or spatial noise, + * naturally blending variations (e.g. pure grass, pebbled dirt, weeds) across the ground + * without jarring discontinuities. * * @param canvas - the canvas. - * @param tile - the floor reference. + * @param tileOrTiles - single tile or array of weighted candidate tiles. * @param x - left edge in cells. * @param y - top edge in cells. * @param width - cells. * @param height - cells. + * @param optionsOrSeed - PRNG seed, Rng instance, or blending options. * @returns the number of cells written. */ -function fillGround( +export function fillGround( canvas: Canvas, - tile: { style: number; sequence: number }, + tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[], x: number, y: number, width: number, height: number, + optionsOrSeed?: number | Rng | FillGroundOptions, ): number { - let written = 0 + if (!isTileArray(tileOrTiles)) { + const tile = tileOrTiles + let written = 0 + for (let cy = y; cy < y + height; cy += 1) { + if (cy < 0 || cy >= canvas.height) continue + const row = canvas.cells[cy]! + for (let cx = x; cx < x + width; cx += 1) { + if (cx < 0 || cx >= canvas.width) continue + const cell = row[cx]! + const floor = cell.floors[0] + if (floor === undefined) continue + Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) + written += 1 + } + } + return written + } + + if (tileOrTiles.length === 0) return 0 + const tiles = tileOrTiles as readonly WeightedGroundTile[] + + if (tiles.length === 1) { + const tile = tiles[0]! + let written = 0 + for (let cy = y; cy < y + height; cy += 1) { + if (cy < 0 || cy >= canvas.height) continue + const row = canvas.cells[cy]! + for (let cx = x; cx < x + width; cx += 1) { + if (cx < 0 || cx >= canvas.width) continue + const cell = row[cx]! + const floor = cell.floors[0] + if (floor === undefined) continue + Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) + written += 1 + } + } + return written + } + + const validCandidates: { style: number; sequence: number; weight: number }[] = [] + for (const t of tileOrTiles) { + const w = t.weight !== undefined + ? Math.max(0, t.weight) + : t.rarityFrameIndex !== undefined + ? Math.max(0, t.rarityFrameIndex) + : 1 + if (w > 0) { + validCandidates.push({ style: t.style, sequence: t.sequence, weight: w }) + } + } + + const active = validCandidates.length > 0 + ? validCandidates + : tileOrTiles.map(t => ({ style: t.style, sequence: t.sequence, weight: 1 })) + + if (active.length === 1) { + const tile = active[0]! + let written = 0 + for (let cy = y; cy < y + height; cy += 1) { + if (cy < 0 || cy >= canvas.height) continue + const row = canvas.cells[cy]! + for (let cx = x; cx < x + width; cx += 1) { + if (cx < 0 || cx >= canvas.width) continue + const cell = row[cx]! + const floor = cell.floors[0] + if (floor === undefined) continue + Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) + written += 1 + } + } + return written + } + + let totalWeight = 0 + for (const c of active) totalWeight += c.weight + const cdfThresholds: number[] = [] + let running = 0 + for (let i = 0; i < active.length; i += 1) { + running += active[i]!.weight + cdfThresholds.push(running / totalWeight) + } + cdfThresholds[cdfThresholds.length - 1] = 1.0 + + const pickCandidate = (u: number): { style: number; sequence: number } => { + for (let i = 0; i < cdfThresholds.length; i += 1) { + if (u < cdfThresholds[i]!) return active[i]! + } + return active[active.length - 1]! + } + + let seed = 0 + let mode: 'cdf' | 'spatial' = 'spatial' + let spatialScale = 6 + let spatialCoherence = 0.6 + + if (typeof optionsOrSeed === 'number') { + seed = optionsOrSeed >>> 0 + } else if (optionsOrSeed instanceof Rng) { + seed = optionsOrSeed.seed >>> 0 + } else if (optionsOrSeed && typeof optionsOrSeed === 'object') { + if (typeof optionsOrSeed.seed === 'number') seed = optionsOrSeed.seed >>> 0 + else if (optionsOrSeed.seed instanceof Rng) seed = optionsOrSeed.seed.seed >>> 0 + if (optionsOrSeed.mode !== undefined) mode = optionsOrSeed.mode + if (typeof optionsOrSeed.spatialScale === 'number' && optionsOrSeed.spatialScale > 0) { + spatialScale = optionsOrSeed.spatialScale + } + if (typeof optionsOrSeed.spatialCoherence === 'number') { + spatialCoherence = Math.max(0, Math.min(1, optionsOrSeed.spatialCoherence)) + } + } + + if (mode === 'cdf') { + let written = 0 + for (let cy = y; cy < y + height; cy += 1) { + if (cy < 0 || cy >= canvas.height) continue + const row = canvas.cells[cy]! + for (let cx = x; cx < x + width; cx += 1) { + if (cx < 0 || cx >= canvas.width) continue + const cell = row[cx]! + const floor = cell.floors[0] + if (floor === undefined) continue + const u = cellHash(cx, cy, seed) + const picked = pickCandidate(u) + Object.assign(floor, { prop1: 2, style: picked.style, sequence: picked.sequence, hidden: false }) + written += 1 + } + } + return written + } + + interface CellEntry { + floor: Ds1Floor + v: number + index: number + } + const cellsToWrite: CellEntry[] = [] + for (let cy = y; cy < y + height; cy += 1) { if (cy < 0 || cy >= canvas.height) continue const row = canvas.cells[cy]! @@ -495,13 +804,34 @@ function fillGround( const cell = row[cx]! const floor = cell.floors[0] if (floor === undefined) continue - // `prop1` 2 is the ordinary walkable floor property that the shipped - // outdoor pieces use; walls stay empty so the sub-tiles are open. - Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) - written += 1 + const smooth = smoothNoise2D(cx, cy, spatialScale, seed) + const jitter = cellHash(cx, cy, seed ^ 0x9e3779b9) + const v = spatialCoherence * smooth + (1 - spatialCoherence) * jitter + cellsToWrite.push({ + floor, + v, + index: cy * canvas.width + cx, + }) } } - return written + + if (cellsToWrite.length === 0) return 0 + + cellsToWrite.sort((a, b) => (a.v !== b.v ? a.v - b.v : a.index - b.index)) + + const totalCells = cellsToWrite.length + for (let r = 0; r < totalCells; r += 1) { + const u = (r + 0.5) / totalCells + const picked = pickCandidate(u) + Object.assign(cellsToWrite[r]!.floor, { + prop1: 2, + style: picked.style, + sequence: picked.sequence, + hidden: false, + }) + } + + return totalCells } /* ------------------------------------------------------------------------- * @@ -509,24 +839,74 @@ function fillGround( * ------------------------------------------------------------------------- */ /** - * Pick the tile the level's own pieces stand on. + * Extract candidate ground floor tiles from DT1 libraries filtered by dwDt1Mask. * - * The shipped game picks the ground with a rarity roll over the DT1 libraries - * that the level type's hardcoded mask selects (`DRLGROOMTILE_GetTileCache`), - * and no table records which tile that lands on. The observable proxy is the - * pieces themselves: a border or fill DS1 is authored as a chunk of scenery - * sitting *on* the outdoor ground, so the floor it uses away from its own outer - * ring is that ground. + * In Diablo II outdoor levels, DRLGROOMTILE_GetTileCache scans the libraries + * activated by the level type's dwDt1Mask (switch on nLevelType in DRLGOUTDOORS_GenerateLevel). + * Floor tiles (tile.type === 0) with positive RarityFrameIndex are accumulated + * into candidate ground tiles weighted by their rarity. * - * The outer ring is excluded because that is where the cliff face and its - * transition tiles live. Cells with `prop1 == 0` are excluded because they are - * unused slots, not tiles. The most frequent survivor wins, ties going to the - * first seen so the choice is deterministic. - * - * @param pieces - the level's border and fill pieces. - * @returns the tile, or null when no piece carries any floor. + * @param libraries - All DT1 libraries resolved for the level (in slot order). + * @param dt1Mask - The bitmask selecting which DT1 slots are active for ground. + * @returns Array of weighted ground tiles. */ -function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null { +export function deriveGroundTilesFromDt1Libraries( + libraries: readonly Dt1[], + dt1Mask: number, +): WeightedGroundTile[] { + const counts = new Map() + + for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) { + if ((dt1Mask & (1 << libIndex)) === 0) continue + const lib = libraries[libIndex] + if (!lib) continue + for (const tile of lib.tiles) { + if (tile.type !== 0) continue + const rarity = Math.max(0, tile.rarityFrameIndex | 0) + if (rarity <= 0) continue + const key = `${String(tile.style)}:${String(tile.sequence)}` + const existing = counts.get(key) + if (existing === undefined) { + counts.set(key, { style: tile.style, sequence: tile.sequence, weight: rarity }) + } else { + existing.weight += rarity + } + } + } + + if (counts.size === 0) { + for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) { + if ((dt1Mask & (1 << libIndex)) === 0) continue + const lib = libraries[libIndex] + if (!lib) continue + for (const tile of lib.tiles) { + if (tile.type !== 0) continue + const key = `${String(tile.style)}:${String(tile.sequence)}` + if (!counts.has(key)) { + counts.set(key, { style: tile.style, sequence: tile.sequence, weight: 1 }) + } + } + } + } + + return Array.from(counts.values()).map(e => ({ + style: e.style, + sequence: e.sequence, + weight: e.weight, + rarityFrameIndex: e.weight, + })) +} + +/** + * Derive candidate ground tiles from the level's own border and fill pieces. + * + * Scans interior cells of each piece variant for visible floor tiles, tallying + * their occurrences to use as relative weights. + * + * @param pieces - Border and fill pieces. + * @returns Array of weighted ground tiles sorted by weight descending. + */ +export function deriveGroundTiles(pieces: readonly WildernessPiece[]): WeightedGroundTile[] { const counts = new Map() const tally = (style: number, sequence: number): void => { const key = `${String(style)}:${String(sequence)}` @@ -551,11 +931,37 @@ function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; } } } - let best: { style: number; sequence: number; count: number } | null = null - for (const entry of counts.values()) { - if (best === null || entry.count > best.count) best = entry - } - return best === null ? null : { style: best.style, sequence: best.sequence } + const result = Array.from(counts.values()).map(e => ({ + style: e.style, + sequence: e.sequence, + weight: e.count, + })) + result.sort((a, b) => b.weight - a.weight) + return result +} + +/** + * Pick the tile the level's own pieces stand on. + * + * The shipped game picks the ground with a rarity roll over the DT1 libraries + * that the level type's hardcoded mask selects (`DRLGROOMTILE_GetTileCache`), + * and no table records which tile that lands on. The observable proxy is the + * pieces themselves: a border or fill DS1 is authored as a chunk of scenery + * sitting *on* the outdoor ground, so the floor it uses away from its own outer + * ring is that ground. + * + * The outer ring is excluded because that is where the cliff face and its + * transition tiles live. Cells with `prop1 == 0` are excluded because they are + * unused slots, not tiles. The most frequent survivor wins, ties going to the + * first seen so the choice is deterministic. + * + * @param pieces - the level's border and fill pieces. + * @returns the tile, or null when no piece carries any floor. + */ +export function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null { + const tiles = deriveGroundTiles(pieces) + if (tiles.length === 0) return null + return { style: tiles[0]!.style, sequence: tiles[0]!.sequence } } /* ------------------------------------------------------------------------- * @@ -616,6 +1022,7 @@ interface WildernessStats { borderStamped: number groundCells: number groundTile: { style: number; sequence: number } | null + groundTiles?: readonly WeightedGroundTile[] | undefined sizeSource: string roadCells: number roadSegments: number @@ -3371,14 +3778,49 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const gridWidth = Math.floor(sizeX / TILES_PER_BLOCK) const gridHeight = Math.floor(sizeY / TILES_PER_BLOCK) - const groundTile = request.groundTile ?? CANONICAL_GROUND_TILES[request.levelTypeName] ?? deriveGroundTile(request.pieces) + let groundTiles: WeightedGroundTile[] | null = null + + if (request.groundTiles && request.groundTiles.length > 0) { + groundTiles = [...request.groundTiles] + } else if (request.groundTile) { + if (isTileArray(request.groundTile)) { + groundTiles = [...(request.groundTile as readonly WeightedGroundTile[])] + } else { + const single = request.groundTile as { readonly style: number; readonly sequence: number } + groundTiles = [{ style: single.style, sequence: single.sequence, weight: 1 }] + } + } else if (request.dt1Libraries && request.dt1Libraries.length > 0) { + const mask = wildernessDt1Mask(request.levelTypeName) + const fromDt1 = deriveGroundTilesFromDt1Libraries(request.dt1Libraries, mask) + if (fromDt1.length > 0) groundTiles = fromDt1 + } + + if (groundTiles === null || groundTiles.length === 0) { + const canonicalPool = CANONICAL_GROUND_POOLS[request.levelTypeName] + if (canonicalPool && canonicalPool.length > 0) { + groundTiles = [...canonicalPool] + } else { + const canonical = CANONICAL_GROUND_TILES[request.levelTypeName] + if (canonical !== undefined) { + groundTiles = [{ style: canonical.style, sequence: canonical.sequence, weight: 1 }] + } else { + const fromPieces = deriveGroundTiles(request.pieces) + if (fromPieces.length > 0) groundTiles = fromPieces + } + } + } + + const primaryGroundTile = groundTiles && groundTiles.length > 0 + ? { style: groundTiles[0]!.style, sequence: groundTiles[0]!.sequence } + : null + const groundTile = primaryGroundTile if (gridWidth < 3 || gridHeight < 3) { const bridgePiece = request.pieces.find(p => p.levels.some(l => l.width >= gridWidth * TILES_PER_BLOCK && l.height >= gridHeight * TILES_PER_BLOCK)) ?? request.pieces[0] if (bridgePiece && bridgePiece.levels[0]) { const bridgeDs1 = bridgePiece.levels[0] const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, bridgeDs1.wallLayers, bridgeDs1.floorLayers, bridgeDs1.substitutionType === 1 || bridgeDs1.substitutionType === 2 ? 1 : 0) - if (groundTile !== null) fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height) + if (groundTiles !== null) fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed }) stampDs1(canvas, bridgeDs1, 0, 0) return { level: { @@ -3406,7 +3848,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult dt1Mask: wildernessDt1Mask(request.levelTypeName), subType: request.subType, subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))), - groundTile, + groundTile: primaryGroundTile, + groundTiles: groundTiles ?? undefined, groundCells: canvas.width * canvas.height, borderPiecesAvailable: 0, borderBlocks: 0, @@ -3431,7 +3874,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const stats: WildernessStats = { substitutions: [], borderPieces: {}, unresolved: [], notes: [], - borderStamped: 0, groundCells: 0, groundTile: null, sizeSource, + borderStamped: 0, groundCells: 0, groundTile: null, groundTiles: undefined, sizeSource, roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [], } @@ -3457,11 +3900,12 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers) const rng = new Rng(request.seed) - stats.groundTile = groundTile - if (groundTile === null) { + stats.groundTile = primaryGroundTile + stats.groundTiles = groundTiles ?? undefined + if (primaryGroundTile === null || groundTiles === null || groundTiles.length === 0) { throw new Error(`${where}: no piece carries a floor tile to use as ground`) } - stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height) + stats.groundCells = fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed }) // Special case: Act 5 Bloody Foothills (110), Act 4 Chaos Sanctuary (108), // Act 3 Travincal (83), and Act 3 Jungles (76, 77, 78) are assembled from @@ -3519,7 +3963,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult dt1Mask: wildernessDt1Mask(request.levelTypeName), subType: request.subType, subTheme: 0, - groundTile, + groundTile: primaryGroundTile, + groundTiles: groundTiles ?? undefined, groundCells: stats.groundCells, borderPiecesAvailable: 0, borderBlocks: 0, @@ -3732,7 +4177,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult if (!c.floors.some(f => !f.hidden && f.prop1 !== 0)) { const floor = c.floors[0] if (floor !== undefined) { - Object.assign(floor, { prop1: 2, style: groundTile.style, sequence: groundTile.sequence, hidden: false }) + const tile = groundTiles && groundTiles.length > 1 + ? sampleGroundTile(groundTiles, x, y, { seed: request.seed }) + : primaryGroundTile! + Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) } } finalGroundCells += 1 @@ -3771,7 +4219,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult dt1Mask: wildernessDt1Mask(request.levelTypeName), subType: request.subType, subTheme: themeIndex, - groundTile, + groundTile: primaryGroundTile, + groundTiles: groundTiles ?? undefined, groundCells: stats.groundCells, borderPiecesAvailable: borderPieces.length, borderBlocks: stats.borderStamped, diff --git a/tests/wilderness-ground-blend.test.ts b/tests/wilderness-ground-blend.test.ts new file mode 100644 index 0000000..edd0b7f --- /dev/null +++ b/tests/wilderness-ground-blend.test.ts @@ -0,0 +1,511 @@ +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[]): 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, + 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() + 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() + 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) + }) + }) +})