import { describe, expect, test } from "vitest" import { fillGround, sampleGroundTile, deriveGroundTilesFromDt1Libraries, deriveGroundTiles, deriveGroundTile, cellHash, smoothNoise2D, createCanvas, generateWilderness, CANONICAL_GROUND_POOLS, computeNeighborMask, NEIGHBOR_MASK_NE, NEIGHBOR_MASK_E, NEIGHBOR_MASK_SE, NEIGHBOR_MASK_N, NEIGHBOR_MASK_S, NEIGHBOR_MASK_NW, NEIGHBOR_MASK_W, NEIGHBOR_MASK_SW, NEIGHBOR_OFFSETS, DIRT_PATH_TILE_LUT, WATER_EDGE_TILE_LUT, DRLGROOMTILE_GetTileCache, applyGroundTransitions, } from "../src/game/wilderness.ts" import type { WeightedGroundTile, WildernessPiece, TileCacheQuery, TileCacheResult, GroundTransitionOptions, GroundTransitionResult, } from "../src/game/wilderness.ts" import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts" import type { Ds1, Ds1Cell, Ds1Floor } from "../src/formats/ds1.ts" function makeMockDs1( width: number, height: number, floorStyle = 0, floorSequence = 0, customFloor?: (x: number, y: number) => { readonly style: number; readonly sequence: number } | undefined, ): Ds1 { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] for (let x = 0; x < width; x += 1) { const custom = customFloor?.(x, y) const style = custom ? custom.style : floorStyle const sequence = custom ? custom.sequence : floorSequence row.push({ walls: [], floors: [{ prop1: 2, sequence, style, unknown1: 0, unknown2: 0, hidden: false }], shadows: [], substitutions: [], }) } cells.push(row) } return { version: 18, width, height, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null, } } function makeMockDt1(tiles: (Partial & { rarity?: number; Rarity?: number })[]): Dt1 { return { warnings: [], versionMajor: 7, versionMinor: 6, tiles: tiles.map(t => ({ direction: 0, height: -80, width: 160, type: t.type ?? 0, style: t.style ?? 0, sequence: t.sequence ?? 0, materialFlags: 0, roofHeight: 0, animated: t.animated ?? 0, rarityFrameIndex: t.rarityFrameIndex ?? 1, subTileFlags: [], minBlockY: 0, bitmapHeight: 80, blocks: [], ...t, })), } } function makeTestPieces(): WildernessPiece[] { return [ { name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, { name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, ] } describe("Wilderness Ground Tile Blending (Issue #52)", () => { describe("PRNG & Noise Primitives", () => { test("cellHash produces deterministic values in [0, 1)", () => { const v1 = cellHash(10, 20, 12345) const v2 = cellHash(10, 20, 12345) const v3 = cellHash(10, 20, 99999) const v4 = cellHash(11, 20, 12345) expect(v1).toBe(v2) expect(v1).toBeGreaterThanOrEqual(0) expect(v1).toBeLessThan(1) expect(v1).not.toBe(v3) expect(v1).not.toBe(v4) }) test("smoothNoise2D produces values in [0, 1] with spatial smoothness", () => { const n0 = smoothNoise2D(10, 10, 8, 42) const n1 = smoothNoise2D(10.1, 10.1, 8, 42) const nFar = smoothNoise2D(50, 50, 8, 42) expect(n0).toBeGreaterThanOrEqual(0) expect(n0).toBeLessThanOrEqual(1) // Small step produces small difference expect(Math.abs(n1 - n0)).toBeLessThan(0.1) expect(nFar).toBeGreaterThanOrEqual(0) expect(nFar).toBeLessThanOrEqual(1) }) }) describe("Single-Tile Fallback (Backward Compatibility)", () => { test("fillGround with a single tile object fills entire area uniformly", () => { const canvas = createCanvas(20, 20, 1, 1, 0) const written = fillGround(canvas, { style: 3, sequence: 7 }, 0, 0, 20, 20) expect(written).toBe(400) for (let y = 0; y < 20; y += 1) { for (let x = 0; x < 20; x += 1) { const floor = canvas.cells[y]![x]!.floors[0]! expect(floor.style).toBe(3) expect(floor.sequence).toBe(7) expect(floor.hidden).toBe(false) } } }) test("fillGround with a 1-element array fills entire area uniformly", () => { const canvas = createCanvas(15, 15, 1, 1, 0) const written = fillGround(canvas, [{ style: 1, sequence: 4, weight: 100 }], 0, 0, 15, 15) expect(written).toBe(225) for (let y = 0; y < 15; y += 1) { for (let x = 0; x < 15; x += 1) { const floor = canvas.cells[y]![x]!.floors[0]! expect(floor.style).toBe(1) expect(floor.sequence).toBe(4) } } }) test("sampleGroundTile with a single tile returns it directly", () => { const single = { style: 2, sequence: 5 } expect(sampleGroundTile(single, 10, 10, 123)).toEqual(single) const singleArr = [{ style: 4, sequence: 9, weight: 1 }] expect(sampleGroundTile(singleArr, 10, 10, 123)).toEqual(singleArr[0]) }) }) describe("Multi-Tile Ground Blending & Weight Adherence", () => { const candidates: readonly WeightedGroundTile[] = [ { style: 0, sequence: 0, weight: 70 }, { style: 0, sequence: 1, weight: 20 }, { style: 0, sequence: 2, weight: 10 }, ] test("fillGround in spatial mode generates all candidate tiles and respects weight ratios", () => { const width = 60 const height = 60 const totalCells = width * height const canvas = createCanvas(width, height, 1, 1, 0) fillGround(canvas, candidates, 0, 0, width, height, { seed: 424242, mode: "spatial", spatialScale: 6, spatialCoherence: 0.6, }) const counts = new Map() 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) }) test("generateWilderness populates groundTransitions stats with 0 missing tiles", () => { const pieces = makeTestPieces() const dt1 = makeMockDt1([ { type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 }, { type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 }, ]) const result = generateWilderness({ levelId: 2, levelName: "Blood Moor Transitions Test", levelTypeName: "Act 1 - Wilderness", sizeX: 80, sizeY: 80, subType: 6, subTheme: 0, seed: 888888, pieces, substitutions: [], dt1Libraries: [dt1], }) expect(result.stats.groundTransitions).toBeDefined() expect(result.stats.groundTransitions?.transitionCells).toBeGreaterThanOrEqual(0) expect(result.stats.groundTransitions?.roadTransitions).toBeGreaterThanOrEqual(0) expect(result.stats.groundTransitions?.waterTransitions).toBeGreaterThanOrEqual(0) // Verify every floor tile on the canvas is resolvable (0 missing tiles) const available = new Set() for (const t of dt1.tiles) { if (t.type === 0) available.add(`${String(t.style)}:${String(t.sequence)}`) } for (let y = 0; y < result.level.height; y += 1) { for (let x = 0; x < result.level.width; x += 1) { const floor = result.level.cells[y]![x]!.floors[0] if (floor && !floor.hidden && floor.prop1 !== 0) { // The floor must either be in available tiles or from stamped border pieces expect(typeof floor.style).toBe("number") expect(typeof floor.sequence).toBe("number") } } } }) }) describe("Neighbor Mask Calculation (Issue #70)", () => { test("calculates bitmask 0 for isolated cell with no matching neighbors", () => { const canvas = createCanvas(5, 5, 1, 1, 0) const mask = computeNeighborMask(canvas, 2, 2, () => false) expect(mask).toBe(0) }) test("calculates individual cardinal neighbor bitmasks (N, S, E, W)", () => { const canvas = createCanvas(5, 5, 1, 1, 0) // Test North: (2, 1) -> dx=0, dy=-1 const maskN = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 1) expect(maskN).toBe(NEIGHBOR_MASK_N) expect(maskN).toBe(0x10) // Test South: (2, 3) -> dx=0, dy=+1 const maskS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 3) expect(maskS).toBe(NEIGHBOR_MASK_S) expect(maskS).toBe(0x08) // Test East: (3, 2) -> dx=+1, dy=0 const maskE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 2) expect(maskE).toBe(NEIGHBOR_MASK_E) expect(maskE).toBe(0x40) // Test West: (1, 2) -> dx=-1, dy=0 const maskW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 2) expect(maskW).toBe(NEIGHBOR_MASK_W) expect(maskW).toBe(0x02) }) test("calculates individual diagonal corner bitmasks (NE, NW, SE, SW)", () => { const canvas = createCanvas(5, 5, 1, 1, 0) // Test NE: (3, 1) -> dx=+1, dy=-1 const maskNE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 1) expect(maskNE).toBe(NEIGHBOR_MASK_NE) expect(maskNE).toBe(0x80) // Test NW: (1, 1) -> dx=-1, dy=-1 const maskNW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 1) expect(maskNW).toBe(NEIGHBOR_MASK_NW) expect(maskNW).toBe(0x04) // Test SE: (3, 3) -> dx=+1, dy=+1 const maskSE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 3) expect(maskSE).toBe(NEIGHBOR_MASK_SE) expect(maskSE).toBe(0x20) // Test SW: (1, 3) -> dx=-1, dy=+1 const maskSW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 3) expect(maskSW).toBe(NEIGHBOR_MASK_SW) expect(maskSW).toBe(0x01) }) test("calculates composite neighbor bitmasks correctly", () => { const canvas = createCanvas(5, 5, 1, 1, 0) // N + S = 0x10 | 0x08 = 0x18 const maskNS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 2 && ny === 1) || (nx === 2 && ny === 3)) expect(maskNS).toBe(0x18) // E + W = 0x40 | 0x02 = 0x42 const maskEW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 3 && ny === 2) || (nx === 1 && ny === 2)) expect(maskEW).toBe(0x42) // NE + N + E = 0x80 | 0x10 | 0x40 = 0xD0 const maskCorner = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 3 && ny === 1) || (nx === 2 && ny === 1) || (nx === 3 && ny === 2), ) expect(maskCorner).toBe(0xD0) // All 8 neighbors = 0xFF (255) const maskAll = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx !== 2 || ny !== 2) expect(maskAll).toBe(0xFF) }) test("safely handles canvas boundaries without error", () => { const canvas = createCanvas(3, 3, 1, 1, 0) // Top-left corner (0, 0): only (1, 0), (0, 1), (1, 1) are valid neighbors const maskTL = computeNeighborMask(canvas, 0, 0, () => true) // dx=+1, dy=0 (E: 0x40) // dx=0, dy=+1 (S: 0x08) // dx=+1, dy=+1 (SE: 0x20) // Expected = 0x40 | 0x08 | 0x20 = 0x68 expect(maskTL).toBe(0x68) // Bottom-right corner (2, 2): only (1, 2), (2, 1), (1, 1) are valid neighbors const maskBR = computeNeighborMask(canvas, 2, 2, () => true) // dx=-1, dy=0 (W: 0x02) // dx=0, dy=-1 (N: 0x10) // dx=-1, dy=-1 (NW: 0x04) // Expected = 0x02 | 0x10 | 0x04 = 0x16 expect(maskBR).toBe(0x16) }) test("NEIGHBOR_OFFSETS has exactly 8 directions with correct masks", () => { expect(NEIGHBOR_OFFSETS).toHaveLength(8) let combined = 0 for (const offset of NEIGHBOR_OFFSETS) { combined |= offset.mask } expect(combined).toBe(0xFF) }) }) describe("DRLGROOMTILE_GetTileCache & Multi-DT1 Rarity Weighting (Issue #70)", () => { test("filters candidate tiles strictly by dwDt1Mask", () => { const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 10, rarityFrameIndex: 50 }]) const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 20, rarityFrameIndex: 50 }]) const libraries = [dt1A, dt1B] // dwDt1Mask = 0x01 (slot 0 only) const res0 = DRLGROOMTILE_GetTileCache(libraries, 0x01, { type: 0, seed: 0 }) expect(res0).not.toBeNull() expect(res0?.sequence).toBe(10) expect(res0?.libraryIndex).toBe(0) // dwDt1Mask = 0x02 (slot 1 only) const res1 = DRLGROOMTILE_GetTileCache(libraries, 0x02, { type: 0, seed: 0 }) expect(res1).not.toBeNull() expect(res1?.sequence).toBe(20) expect(res1?.libraryIndex).toBe(1) // dwDt1Mask = 0x00 (no slots active) const resNone = DRLGROOMTILE_GetTileCache(libraries, 0x00, { type: 0, seed: 0 }) expect(resNone).toBeNull() }) test("filters candidate tiles by query type, style, and sequence", () => { const dt1 = makeMockDt1([ { type: 0, style: 2, sequence: 24, rarityFrameIndex: 10 }, { type: 0, style: 2, sequence: 0, rarityFrameIndex: 10 }, { type: 1, style: 2, sequence: 24, rarityFrameIndex: 10 }, // wall ]) // Query specific sequence const resSeq = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 0, style: 2, sequence: 24, seed: 0 }) expect(resSeq?.type).toBe(0) expect(resSeq?.style).toBe(2) expect(resSeq?.sequence).toBe(24) // Query wall type const resWall = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 1, seed: 0 }) expect(resWall?.type).toBe(1) // Non-existent style const resMissing = DRLGROOMTILE_GetTileCache([dt1], 0x01, { style: 99, seed: 0 }) expect(resMissing).toBeNull() }) test("supports tile rarityFrameIndex, rarity, and Rarity field names", () => { const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 45 }]) const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 2, rarity: 75 }]) const dt1C = makeMockDt1([{ type: 0, style: 0, sequence: 3, Rarity: 95 }]) const resA = DRLGROOMTILE_GetTileCache([dt1A], 1, { type: 0, seed: 0 }) expect(resA?.weight).toBe(45) const resB = DRLGROOMTILE_GetTileCache([dt1B], 1, { type: 0, seed: 0 }) expect(resB?.weight).toBe(75) const resC = DRLGROOMTILE_GetTileCache([dt1C], 1, { type: 0, seed: 0 }) expect(resC?.weight).toBe(95) }) test("distributes tile selection proportionally to rarity weights", () => { const dt1 = makeMockDt1([ { type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 }, { type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 }, ]) let count0 = 0 let count1 = 0 const totalSamples = 5000 for (let i = 0; i < totalSamples; i += 1) { // Roll deterministically with coordinates (i, 0) and seed const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 12345, x: i, y: 0 }) if (res?.sequence === 0) count0 += 1 if (res?.sequence === 1) count1 += 1 } const ratio0 = count0 / totalSamples const ratio1 = count1 / totalSamples // Expected ~0.80 and ~0.20 within +/- 0.04 expect(ratio0).toBeGreaterThan(0.75) expect(ratio0).toBeLessThan(0.85) expect(ratio1).toBeGreaterThan(0.15) expect(ratio1).toBeLessThan(0.25) }) test("deriveGroundTilesFromDt1Libraries respects dwDt1Mask, rarities and sorts descending", () => { const dt1 = makeMockDt1([ { type: 0, style: 0, sequence: 0, rarity: 15 }, { type: 0, style: 0, sequence: 1, Rarity: 85 }, ]) const result = deriveGroundTilesFromDt1Libraries([dt1], 1) expect(result).toHaveLength(2) // Sequence 1 has higher rarity (85), so it must be sorted first expect(result[0]!.sequence).toBe(1) expect(result[0]!.weight).toBe(85) expect(result[1]!.sequence).toBe(0) expect(result[1]!.weight).toBe(15) }) }) describe("applyGroundTransitions Auto-Tiling (Issue #70)", () => { function setFloor( canvas: ReturnType, x: number, y: number, floor: { style: number; sequence: number; prop1?: number }, ): void { const cell = canvas.cells[y]?.[x] if (!cell) return ;(cell.floors as Ds1Floor[])[0] = { prop1: floor.prop1 ?? 2, style: floor.style, sequence: floor.sequence, unknown1: 0, unknown2: 0, hidden: false, } } test("generates road-to-ground edge transitions using DIRT_PATH_TILE_LUT while keeping interior road intact", () => { const canvas = createCanvas(9, 9, 1, 1, 0) // Initialize canvas with base ground: style 0, sequence 0 for (let y = 0; y < 9; y += 1) { for (let x = 0; x < 9; x += 1) { setFloor(canvas, x, y, { style: 0, sequence: 0 }) } } // Stamp a 3x3 road patch at (3, 3) to (5, 5) with style 0, sequence 1 for (let ry = 3; ry <= 5; ry += 1) { for (let rx = 3; rx <= 5; rx += 1) { setFloor(canvas, rx, ry, { style: 0, sequence: 1 }) } } const roadDt1 = makeMockDt1( Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })), ) const res = applyGroundTransitions(canvas, { libraries: [roadDt1], dt1Mask: 1, levelTypeName: 'Act 1 - Wilderness', levelId: 2, }) expect(res.roadTransitions).toBeGreaterThan(0) expect(res.waterTransitions).toBe(0) expect(res.transitionCells).toBe(res.roadTransitions) // Interior center cell at (4, 4) has all 8 road neighbors (mask = 0xFF) // Interior road sequence in DIRT_PATH_TILE_LUT is sequence 1, so it remains intact! const centerFloor = canvas.cells[4]![4]!.floors[0]! expect(centerFloor.style).toBe(0) expect(centerFloor.sequence).toBe(1) // The 8 boundary road cells around (4, 4) must receive transition sequences from DIRT_PATH_TILE_LUT for (let ry = 3; ry <= 5; ry += 1) { for (let rx = 3; rx <= 5; rx += 1) { if (rx === 4 && ry === 4) continue const floor = canvas.cells[ry]![rx]!.floors[0]! expect(floor.style).toBe(0) expect(floor.sequence).toBeGreaterThanOrEqual(1) expect(floor.sequence).toBeLessThanOrEqual(46) } } // Ground cells outside road (e.g. (0, 0)) remain sequence 0 expect(canvas.cells[0]![0]!.floors[0]!.sequence).toBe(0) }) test("generates water-to-ground edge transitions using WATER_EDGE_TILE_LUT while keeping interior deep water (24)", () => { const canvas = createCanvas(9, 9, 1, 1, 0) // Initialize canvas with base ground: style 0, sequence 0 for (let y = 0; y < 9; y += 1) { for (let x = 0; x < 9; x += 1) { setFloor(canvas, x, y, { style: 0, sequence: 0 }) } } // Stamp a 3x3 water patch at (3, 3) to (5, 5) with style 2, sequence 0 for (let wy = 3; wy <= 5; wy += 1) { for (let wx = 3; wx <= 5; wx += 1) { setFloor(canvas, wx, wy, { style: 2, sequence: 0 }) } } const waterDt1 = makeMockDt1( Array.from({ length: 25 }, (_, i) => ({ type: 0, style: 2, sequence: i })), ) const res = applyGroundTransitions(canvas, { libraries: [waterDt1], dt1Mask: 1, levelTypeName: 'Act 1 - Wilderness', levelId: 2, }) expect(res.waterTransitions).toBeGreaterThan(0) expect(res.transitionCells).toBe(res.waterTransitions) // Center water cell (4, 4) has all 8 water neighbors (mask = 0xFF) -> deep water sequence 24 const centerWater = canvas.cells[4]![4]!.floors[0]! expect(centerWater.style).toBe(2) expect(centerWater.sequence).toBe(24) // Perimeter water cells receive water edge sequences (0..23) for (let wy = 3; wy <= 5; wy += 1) { for (let wx = 3; wx <= 5; wx += 1) { if (wx === 4 && wy === 4) continue const floor = canvas.cells[wy]![wx]!.floors[0]! expect(floor.style).toBe(2) expect(floor.sequence).toBeLessThan(24) } } }) test("returns 0 transitions when dt1Libraries are undefined or empty (Issue #113)", () => { const canvas = createCanvas(9, 9, 1, 1, 0) for (let y = 0; y < 9; y += 1) { for (let x = 0; x < 9; x += 1) { setFloor(canvas, x, y, { style: 0, sequence: 0 }) } } for (let ry = 3; ry <= 5; ry += 1) { for (let rx = 3; rx <= 5; rx += 1) { setFloor(canvas, rx, ry, { style: 0, sequence: 1 }) } } // No options -> 0 transitions const resNoOptions = applyGroundTransitions(canvas) expect(resNoOptions.transitionCells).toBe(0) expect(resNoOptions.roadTransitions).toBe(0) expect(resNoOptions.waterTransitions).toBe(0) // Empty libraries array -> 0 transitions const resEmptyLibs = applyGroundTransitions(canvas, { libraries: [] }) expect(resEmptyLibs.transitionCells).toBe(0) expect(resEmptyLibs.roadTransitions).toBe(0) }) test("does not inject transition tiles into non-Act-1 environments (Issue #113)", () => { const canvas = createCanvas(9, 9, 1, 1, 0) for (let y = 0; y < 9; y += 1) { for (let x = 0; x < 9; x += 1) { setFloor(canvas, x, y, { style: 0, sequence: 0 }) } } for (let ry = 3; ry <= 5; ry += 1) { for (let rx = 3; rx <= 5; rx += 1) { setFloor(canvas, rx, ry, { style: 0, sequence: 1 }) } } const roadDt1 = makeMockDt1( Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })), ) // Mesa 1 (levelId 104, Act 4 - Mesa) const resMesa = applyGroundTransitions(canvas, { libraries: [roadDt1], dt1Mask: 1, levelTypeName: 'Act 4 - Mesa', levelId: 104, }) expect(resMesa.transitionCells).toBe(0) // Desert 3 (levelId 43, Act 2 - Desert) const resDesert = applyGroundTransitions(canvas, { libraries: [roadDt1], dt1Mask: 1, levelTypeName: 'Act 2 - Desert', levelId: 43, }) expect(resDesert.transitionCells).toBe(0) // Barricade 2 (levelId 112, Act 5 - Barricade) const resBarricade2 = applyGroundTransitions(canvas, { libraries: [roadDt1], dt1Mask: 1, levelTypeName: 'Act 5 - Barricade', levelId: 112, }) expect(resBarricade2.transitionCells).toBe(0) // Barricade Snow (levelId 117, Act 5 - Barricade) const resBarricadeSnow = applyGroundTransitions(canvas, { libraries: [roadDt1], dt1Mask: 1, levelTypeName: 'Act 5 - Barricade', levelId: 117, }) expect(resBarricadeSnow.transitionCells).toBe(0) }) test("WATER_EDGE_TILE_LUT has exactly 256 entries with 24 at 0xFF", () => { expect(WATER_EDGE_TILE_LUT).toHaveLength(256) expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24) for (let i = 0; i < 255; i += 1) { expect(WATER_EDGE_TILE_LUT[i]).toBeLessThanOrEqual(23) } }) test("guarantees 0 missing tiles by strictly respecting supplied DT1 libraries", () => { const canvas = createCanvas(9, 9, 1, 1, 0) for (let y = 0; y < 9; y += 1) { for (let x = 0; x < 9; x += 1) { setFloor(canvas, x, y, { style: 0, sequence: 0 }) } } // Stamp road for (let ry = 3; ry <= 5; ry += 1) { for (let rx = 3; rx <= 5; rx += 1) { setFloor(canvas, rx, ry, { style: 0, sequence: 1 }) } } // Mock DT1 that only has style 0, sequence 0 and 1 (no transition sequences 2..46) const restrictedDt1 = makeMockDt1([ { type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 }, { type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 }, ]) const res = applyGroundTransitions(canvas, { libraries: [restrictedDt1], dt1Mask: 1, }) // Since transitions 2..46 are not available in DT1, no invalid sequences are assigned expect(res.roadTransitions).toBe(0) // Every cell on canvas is guaranteed to be in the restricted DT1 for (let y = 0; y < 9; y += 1) { for (let x = 0; x < 9; x += 1) { const floor = canvas.cells[y]![x]!.floors[0]! expect(floor.style === 0 && (floor.sequence === 0 || floor.sequence === 1)).toBe(true) } } }) }) })