Merge branch 'fix/issue-52'
This commit is contained in:
commit
3ab024d12f
|
|
@ -70,11 +70,12 @@
|
||||||
* set to the packed value `0x40002` (`bIsFloor` plus a wall-layer bit) and the
|
* 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
|
* 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
|
* `DRLGROOMTILE_GetTileCache`, against the libraries selected by the level type's
|
||||||
* hardcoded `dwDt1Mask`. That roll cannot be reproduced from the shipped tables,
|
* hardcoded `dwDt1Mask`. This port supports multi-DT1 weighted ground tile blending
|
||||||
* so this port takes the ground tile from the level's own border pieces instead
|
* via `fillGround` using `dwDt1Mask` / `RarityFrameIndex` or candidate ground tiles,
|
||||||
* — see {@link WildernessRequest.groundTile}.
|
* 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 { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from '../formats/ds1.ts'
|
||||||
|
import type { Dt1 } from '../formats/dt1.ts'
|
||||||
import { Rng } from './rng.ts'
|
import { Rng } from './rng.ts'
|
||||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||||
import type { Side } from './world-graph.ts'
|
import type { Side } from './world-graph.ts'
|
||||||
|
|
@ -217,15 +218,24 @@ export interface WildernessRequest {
|
||||||
readonly specialPresets?: readonly string[]
|
readonly specialPresets?: readonly string[]
|
||||||
/**
|
/**
|
||||||
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
|
* 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 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,
|
* 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
|
* {@link generateWilderness} derives it from the level's own pieces or DT1 libraries,
|
||||||
* common floor a border piece uses outside the piece's own wall ring — the
|
* recording the choice in `stats.groundTile` (and `stats.groundTiles`).
|
||||||
* outdoor ground those pieces sit on — and records the choice in
|
|
||||||
* `stats.groundTile`.
|
|
||||||
*/
|
*/
|
||||||
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.
|
* Explicit block-grid size, for the levels `Levels.txt` leaves unset.
|
||||||
*
|
*
|
||||||
|
|
@ -296,6 +306,18 @@ export const CANONICAL_GROUND_TILES: Readonly<Record<string, { readonly style: n
|
||||||
'Act 5 - Barricade': { style: 0, sequence: 0 },
|
'Act 5 - Barricade': { style: 0, sequence: 0 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Canonical default open ground floor tile pools per wilderness level type. */
|
||||||
|
export const CANONICAL_GROUND_POOLS: Readonly<Record<string, readonly WeightedGroundTile[]>> = {
|
||||||
|
'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
|
* Classification
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
@ -475,30 +497,317 @@ export function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY:
|
||||||
return written
|
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
|
* Supports:
|
||||||
* resolves against the level type's libraries exactly as a decoded DS1's floor
|
* 1. Single tile fallback (backward compatibility): All cells receive the single tile.
|
||||||
* would; the wall layers are left empty, which is what makes the ground walkable.
|
* 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 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 x - left edge in cells.
|
||||||
* @param y - top edge in cells.
|
* @param y - top edge in cells.
|
||||||
* @param width - cells.
|
* @param width - cells.
|
||||||
* @param height - cells.
|
* @param height - cells.
|
||||||
|
* @param optionsOrSeed - PRNG seed, Rng instance, or blending options.
|
||||||
* @returns the number of cells written.
|
* @returns the number of cells written.
|
||||||
*/
|
*/
|
||||||
export function fillGround(
|
export function fillGround(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
tile: { style: number; sequence: number },
|
tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[],
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
|
optionsOrSeed?: number | Rng | FillGroundOptions,
|
||||||
): number {
|
): 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) {
|
for (let cy = y; cy < y + height; cy += 1) {
|
||||||
if (cy < 0 || cy >= canvas.height) continue
|
if (cy < 0 || cy >= canvas.height) continue
|
||||||
const row = canvas.cells[cy]!
|
const row = canvas.cells[cy]!
|
||||||
|
|
@ -507,13 +816,34 @@ export function fillGround(
|
||||||
const cell = row[cx]!
|
const cell = row[cx]!
|
||||||
const floor = cell.floors[0]
|
const floor = cell.floors[0]
|
||||||
if (floor === undefined) continue
|
if (floor === undefined) continue
|
||||||
// `prop1` 2 is the ordinary walkable floor property that the shipped
|
const smooth = smoothNoise2D(cx, cy, spatialScale, seed)
|
||||||
// outdoor pieces use; walls stay empty so the sub-tiles are open.
|
const jitter = cellHash(cx, cy, seed ^ 0x9e3779b9)
|
||||||
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
|
const v = spatialCoherence * smooth + (1 - spatialCoherence) * jitter
|
||||||
written += 1
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------- *
|
/* ------------------------------------------------------------------------- *
|
||||||
|
|
@ -521,24 +851,74 @@ export 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
|
* In Diablo II outdoor levels, DRLGROOMTILE_GetTileCache scans the libraries
|
||||||
* that the level type's hardcoded mask selects (`DRLGROOMTILE_GetTileCache`),
|
* activated by the level type's dwDt1Mask (switch on nLevelType in DRLGOUTDOORS_GenerateLevel).
|
||||||
* and no table records which tile that lands on. The observable proxy is the
|
* Floor tiles (tile.type === 0) with positive RarityFrameIndex are accumulated
|
||||||
* pieces themselves: a border or fill DS1 is authored as a chunk of scenery
|
* into candidate ground tiles weighted by their rarity.
|
||||||
* 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
|
* @param libraries - All DT1 libraries resolved for the level (in slot order).
|
||||||
* transition tiles live. Cells with `prop1 == 0` are excluded because they are
|
* @param dt1Mask - The bitmask selecting which DT1 slots are active for ground.
|
||||||
* unused slots, not tiles. The most frequent survivor wins, ties going to the
|
* @returns Array of weighted ground tiles.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null {
|
export function deriveGroundTilesFromDt1Libraries(
|
||||||
|
libraries: readonly Dt1[],
|
||||||
|
dt1Mask: number,
|
||||||
|
): WeightedGroundTile[] {
|
||||||
|
const counts = new Map<string, { style: number; sequence: number; weight: number }>()
|
||||||
|
|
||||||
|
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<string, { style: number; sequence: number; count: number }>()
|
const counts = new Map<string, { style: number; sequence: number; count: number }>()
|
||||||
const tally = (style: number, sequence: number): void => {
|
const tally = (style: number, sequence: number): void => {
|
||||||
const key = `${String(style)}:${String(sequence)}`
|
const key = `${String(style)}:${String(sequence)}`
|
||||||
|
|
@ -563,11 +943,37 @@ function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let best: { style: number; sequence: number; count: number } | null = null
|
const result = Array.from(counts.values()).map(e => ({
|
||||||
for (const entry of counts.values()) {
|
style: e.style,
|
||||||
if (best === null || entry.count > best.count) best = entry
|
sequence: e.sequence,
|
||||||
}
|
weight: e.count,
|
||||||
return best === null ? null : { style: best.style, sequence: best.sequence }
|
}))
|
||||||
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------- *
|
/* ------------------------------------------------------------------------- *
|
||||||
|
|
@ -628,6 +1034,7 @@ export interface WildernessStats {
|
||||||
borderStamped: number
|
borderStamped: number
|
||||||
groundCells: number
|
groundCells: number
|
||||||
groundTile: { style: number; sequence: number } | null
|
groundTile: { style: number; sequence: number } | null
|
||||||
|
groundTiles?: readonly WeightedGroundTile[] | undefined
|
||||||
sizeSource: string
|
sizeSource: string
|
||||||
roadCells: number
|
roadCells: number
|
||||||
roadSegments: number
|
roadSegments: number
|
||||||
|
|
@ -3261,14 +3668,49 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
const gridWidth = Math.floor(sizeX / TILES_PER_BLOCK)
|
const gridWidth = Math.floor(sizeX / TILES_PER_BLOCK)
|
||||||
const gridHeight = Math.floor(sizeY / 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) && request.levelId !== 82) {
|
if ((gridWidth < 3 || gridHeight < 3) && request.levelId !== 82) {
|
||||||
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]
|
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]) {
|
if (bridgePiece && bridgePiece.levels[0]) {
|
||||||
const bridgeDs1 = 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)
|
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)
|
stampDs1(canvas, bridgeDs1, 0, 0)
|
||||||
return {
|
return {
|
||||||
level: {
|
level: {
|
||||||
|
|
@ -3296,7 +3738,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
||||||
subType: request.subType,
|
subType: request.subType,
|
||||||
subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))),
|
subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))),
|
||||||
groundTile,
|
groundTile: primaryGroundTile,
|
||||||
|
groundTiles: groundTiles ?? undefined,
|
||||||
groundCells: canvas.width * canvas.height,
|
groundCells: canvas.width * canvas.height,
|
||||||
borderPiecesAvailable: 0,
|
borderPiecesAvailable: 0,
|
||||||
borderBlocks: 0,
|
borderBlocks: 0,
|
||||||
|
|
@ -3321,7 +3764,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
|
|
||||||
const stats: WildernessStats = {
|
const stats: WildernessStats = {
|
||||||
substitutions: [], borderPieces: {}, unresolved: [], notes: [],
|
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: [],
|
roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3347,11 +3790,12 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers)
|
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers)
|
||||||
const rng = new Rng(request.seed)
|
const rng = new Rng(request.seed)
|
||||||
|
|
||||||
stats.groundTile = groundTile
|
stats.groundTile = primaryGroundTile
|
||||||
if (groundTile === null) {
|
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`)
|
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),
|
// Special case: Act 5 Bloody Foothills (110),
|
||||||
// Act 3 Travincal (83), Act 3 Kurast Causeway (82), and Act 3 Jungles (76, 77, 78) are assembled from
|
// Act 3 Travincal (83), Act 3 Kurast Causeway (82), and Act 3 Jungles (76, 77, 78) are assembled from
|
||||||
|
|
@ -3409,7 +3853,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
||||||
subType: request.subType,
|
subType: request.subType,
|
||||||
subTheme: 0,
|
subTheme: 0,
|
||||||
groundTile,
|
groundTile: primaryGroundTile,
|
||||||
|
groundTiles: groundTiles ?? undefined,
|
||||||
groundCells: stats.groundCells,
|
groundCells: stats.groundCells,
|
||||||
borderPiecesAvailable: 0,
|
borderPiecesAvailable: 0,
|
||||||
borderBlocks: 0,
|
borderBlocks: 0,
|
||||||
|
|
@ -3659,7 +4104,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
if (!c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
|
if (!c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
|
||||||
const floor = c.floors[0]
|
const floor = c.floors[0]
|
||||||
if (floor !== undefined) {
|
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
|
finalGroundCells += 1
|
||||||
|
|
@ -3698,7 +4146,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
||||||
subType: request.subType,
|
subType: request.subType,
|
||||||
subTheme: themeIndex,
|
subTheme: themeIndex,
|
||||||
groundTile,
|
groundTile: primaryGroundTile,
|
||||||
|
groundTiles: groundTiles ?? undefined,
|
||||||
groundCells: stats.groundCells,
|
groundCells: stats.groundCells,
|
||||||
borderPiecesAvailable: borderPieces.length,
|
borderPiecesAvailable: borderPieces.length,
|
||||||
borderBlocks: stats.borderStamped,
|
borderBlocks: stats.borderStamped,
|
||||||
|
|
|
||||||
|
|
@ -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<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,
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue