From ea61823e60ef973ec0103cdb0f172b60c11dd98d Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 04:05:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(wilderness):=20=E5=AE=8C=E5=96=84=E5=9C=B0?= =?UTF-8?q?=E8=A1=A8=E8=BE=B9=E7=BC=98=E8=BF=87=E6=B8=A1=E7=93=A6=E7=89=87?= =?UTF-8?q?=EF=BC=88Auto-Tiling=EF=BC=89=E4=B8=8E=20DRLGROOMTILE=5FGetTile?= =?UTF-8?q?Cache=20=E6=9D=83=E9=87=8D=E6=B7=B7=E5=90=88=20(Issue=20#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 DRLGROOMTILE_GetTileCache:基于 dwDt1Mask、稀有度字段(rarityFrameIndex/rarity/Rarity)与权重累加的瓦片筛选和轮盘赌采样 - 增强 deriveGroundTilesFromDt1Libraries:按权重降序排列并完整提取 DT1 瓦片稀有度及索引元数据 - 导出 8 邻域位掩码常量(NEIGHBOR_MASK_NE/E/SE/N/S/NW/W/SW)与 NEIGHBOR_OFFSETS - 实现 computeNeighborMask(DRLGROOMTILE_ComputeNeighborMask 别名):对野外地块进行安全邻域状态位掩码计算 - 导出 WATER_EDGE_TILE_LUT(256 元素表):深水 24 位于 0xFF,边缘过渡序列 0..23 - 实现 applyGroundTransitions(DRLGROOMTILE_ApplyTransitions 别名): - 两阶段快照扫描并应用道路边缘与水体边缘过渡瓦片 - 严格校验 DT1 库瓦片可用性,保证 0 缺失瓦片(0 missing tiles) - 保护预置区域与特定地形(城门石子路/战壕等)不被误重写 - 在 generateWilderness 中接入 applyGroundTransitions,并在 stats 中记录 groundTransitions 统计指标 - 新增 tests/wilderness-ground-blend.test.ts 18 项测试用例,全方位验证邻域掩码、缓存采样、过渡拼接与全关卡无缺失 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/wilderness.ts | 499 +++++++++++++++++++++++++- tests/wilderness-ground-blend.test.ts | 432 +++++++++++++++++++++- 2 files changed, 920 insertions(+), 11 deletions(-) diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 378b26f..98469dc 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -360,6 +360,7 @@ export interface WildernessResult { readonly notes?: string[] readonly unresolved?: string[] readonly gateOpenings?: readonly GateOpening[] + readonly groundTransitions?: GroundTransitionResult } } @@ -571,14 +572,28 @@ 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. + * Relative selection weight (e.g. from `RarityFrameIndex`, `Rarity`, or piece counts). + * Defaults to 1 (or `rarityFrameIndex` / `rarity` / `Rarity`) if omitted or <= 0. */ readonly weight?: number /** * Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile. */ readonly rarityFrameIndex?: number + /** + * Direct DT1 `rarity` (alternative field name used in some loaders/representations). + */ + readonly rarity?: number + /** + * Direct DT1 `Rarity` (uppercase field name used in native structs). + */ + readonly Rarity?: number + /** Optional library slot index where the tile originated. */ + readonly libraryIndex?: number + /** Optional tile index within the DT1 library. */ + readonly tileIndex?: number + /** Optional tile type (0 for floor). */ + readonly type?: number } /** Configuration options for ground tile blending. */ @@ -935,20 +950,28 @@ export function deriveGroundTilesFromDt1Libraries( libraries: readonly Dt1[], dt1Mask: number, ): WeightedGroundTile[] { - const counts = new Map() + const counts = new Map< + string, + { style: number; sequence: number; weight: number; libraryIndex: number; tileIndex: 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) { + for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) { + const tile = lib.tiles[tIndex]! if (tile.type !== 0) continue - const rarity = Math.max(0, tile.rarityFrameIndex | 0) + const rawRarity = + (tile as unknown as { rarity?: number; Rarity?: number }).rarity ?? + (tile as unknown as { rarity?: number; Rarity?: number }).Rarity ?? + tile.rarityFrameIndex + const rarity = Math.max(0, rawRarity | 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 }) + counts.set(key, { style: tile.style, sequence: tile.sequence, weight: rarity, libraryIndex: libIndex, tileIndex: tIndex }) } else { existing.weight += rarity } @@ -960,22 +983,30 @@ export function deriveGroundTilesFromDt1Libraries( if ((dt1Mask & (1 << libIndex)) === 0) continue const lib = libraries[libIndex] if (!lib) continue - for (const tile of lib.tiles) { + for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) { + const tile = lib.tiles[tIndex]! 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 }) + counts.set(key, { style: tile.style, sequence: tile.sequence, weight: 1, libraryIndex: libIndex, tileIndex: tIndex }) } } } } - return Array.from(counts.values()).map(e => ({ + const result: WeightedGroundTile[] = Array.from(counts.values()).map(e => ({ style: e.style, sequence: e.sequence, weight: e.weight, rarityFrameIndex: e.weight, + rarity: e.weight, + Rarity: e.weight, + libraryIndex: e.libraryIndex, + tileIndex: e.tileIndex, + type: 0, })) + result.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)) + return result } /** @@ -1147,6 +1178,7 @@ export interface WildernessStats { drlgVertices?: readonly DrlgVertex[] | undefined roadPolylines?: readonly (readonly { readonly x: number; readonly y: number }[])[] | undefined gateOpenings?: GateOpening[] + groundTransitions?: GroundTransitionResult | undefined } /** @@ -1185,6 +1217,431 @@ export const DIRT_PATH_TILE_LUT: Readonly = new Uint8Array([ 0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x18, 0x18, 0x19, 0x20, 0x18, 0x18, 0x1E, 0x01, ]) +/* ------------------------------------------------------------------------- * + * 8-Neighbor Bitmask Auto-Tiling & Ground Transitions (Issue #70) + * ------------------------------------------------------------------------- */ + +export const NEIGHBOR_MASK_NE = 0x80 +export const NEIGHBOR_MASK_E = 0x40 +export const NEIGHBOR_MASK_SE = 0x20 +export const NEIGHBOR_MASK_N = 0x10 +export const NEIGHBOR_MASK_S = 0x08 +export const NEIGHBOR_MASK_NW = 0x04 +export const NEIGHBOR_MASK_W = 0x02 +export const NEIGHBOR_MASK_SW = 0x01 + +export interface NeighborOffset { + readonly dx: number + readonly dy: number + readonly mask: number + readonly name: 'NE' | 'E' | 'SE' | 'N' | 'S' | 'NW' | 'W' | 'SW' +} + +export const NEIGHBOR_OFFSETS: readonly NeighborOffset[] = [ + { dx: 1, dy: -1, mask: NEIGHBOR_MASK_NE, name: 'NE' }, + { dx: 1, dy: 0, mask: NEIGHBOR_MASK_E, name: 'E' }, + { dx: 1, dy: 1, mask: NEIGHBOR_MASK_SE, name: 'SE' }, + { dx: 0, dy: -1, mask: NEIGHBOR_MASK_N, name: 'N' }, + { dx: 0, dy: 1, mask: NEIGHBOR_MASK_S, name: 'S' }, + { dx: -1, dy: -1, mask: NEIGHBOR_MASK_NW, name: 'NW' }, + { dx: -1, dy: 0, mask: NEIGHBOR_MASK_W, name: 'W' }, + { dx: -1, dy: 1, mask: NEIGHBOR_MASK_SW, name: 'SW' }, +] as const + +/** + * Computes the 8-neighbor bitmask (0..255) for cell (x, y) on a canvas + * using the given predicate to test whether each neighbor matches. + * + * Coordinates outside the canvas bounds safely evaluate to false. + */ +export function computeNeighborMask( + canvas: Canvas, + x: number, + y: number, + predicate: (cell: Canvas['cells'][number][number] | undefined, nx: number, ny: number) => boolean, +): number { + let mask = 0 + const width = canvas.width + const height = canvas.height + + for (let i = 0; i < NEIGHBOR_OFFSETS.length; i += 1) { + const { dx, dy, mask: bit } = NEIGHBOR_OFFSETS[i]! + const nx = x + dx + const ny = y + dy + if (nx >= 0 && nx < width && ny >= 0 && ny < height) { + const cell = canvas.cells[ny]?.[nx] + if (predicate(cell, nx, ny)) { + mask |= bit + } + } + } + + return mask +} + +export const DRLGROOMTILE_ComputeNeighborMask = computeNeighborMask + +/** + * 256-entry 8-neighbor bitmask lookup table for water edge/shore transition tiles. + * + * Maps neighbor bitmask of water cells: + * - 0xFF (all 8 neighbors are water): interior deep water (sequence 24 in River.dt1 style 2). + * - Other bitmasks: boundary water tiles (sequences 0..23 in River.dt1 style 2). + */ +function createWaterEdgeTileLut(): Readonly { + const lut = new Uint8Array(256) + for (let mask = 0; mask < 256; mask += 1) { + if (mask === 0xFF) { + lut[mask] = 24 + continue + } + const hasN = (mask & NEIGHBOR_MASK_N) !== 0 + const hasS = (mask & NEIGHBOR_MASK_S) !== 0 + const hasE = (mask & NEIGHBOR_MASK_E) !== 0 + const hasW = (mask & NEIGHBOR_MASK_W) !== 0 + const hasNE = (mask & NEIGHBOR_MASK_NE) !== 0 + const hasNW = (mask & NEIGHBOR_MASK_NW) !== 0 + const hasSE = (mask & NEIGHBOR_MASK_SE) !== 0 + const hasSW = (mask & NEIGHBOR_MASK_SW) !== 0 + + // Inner corners (concave diagonal inlets where all 4 cardinals are water, but a diagonal corner is land) + if (hasN && hasS && hasE && hasW) { + if (!hasNW) { lut[mask] = 16; continue } + if (!hasNE) { lut[mask] = 17; continue } + if (!hasSW) { lut[mask] = 18; continue } + if (!hasSE) { lut[mask] = 19; continue } + lut[mask] = 24 + continue + } + + // Outer corners (convex corners) + if (hasS && hasE && !hasN && !hasW) { lut[mask] = 1; continue } // NW outer corner + if (hasS && hasW && !hasN && !hasE) { lut[mask] = 5; continue } // NE outer corner + if (hasN && hasE && !hasS && !hasW) { lut[mask] = 9; continue } // SW outer corner + if (hasN && hasW && !hasS && !hasE) { lut[mask] = 13; continue } // SE outer corner + + // Cardinal straight shores + if (hasE && !hasW) { lut[mask] = 0; continue } // West shore (water to East) + if (hasW && !hasE) { lut[mask] = 4; continue } // East shore (water to West) + if (hasS && !hasN) { lut[mask] = 8; continue } // North shore (water to South) + if (hasN && !hasS) { lut[mask] = 12; continue } // South shore (water to North) + + // Channels / peninsulas + if (hasN && hasS) { lut[mask] = 20; continue } // Vertical channel + if (hasE && hasW) { lut[mask] = 21; continue } // Horizontal channel + + // Caps / isolated + if (hasS) { lut[mask] = 22; continue } + if (hasN) { lut[mask] = 23; continue } + if (hasE) { lut[mask] = 2; continue } + if (hasW) { lut[mask] = 6; continue } + + // Fallback + lut[mask] = 0 + } + return lut +} + +export const WATER_EDGE_TILE_LUT: Readonly = createWaterEdgeTileLut() + +/** Query parameters for DRLGROOMTILE_GetTileCache. */ +export interface TileCacheQuery { + /** Tile type (0 = floor, 1 = left wall, etc.). Defaults to 0 (floor). */ + readonly type?: number | undefined + /** Filter by specific style if provided. */ + readonly style?: number | undefined + /** Filter by specific sequence if provided. */ + readonly sequence?: number | undefined + /** Seed or PRNG roll in [0, 1) or integer seed for deterministic weighted selection. */ + readonly seed?: number | Rng | undefined + /** Optional cell coordinates (x, y) to derive a deterministic hash if seed is provided. */ + readonly x?: number | undefined + readonly y?: number | undefined +} + +/** Result returned by DRLGROOMTILE_GetTileCache. */ +export interface TileCacheResult { + readonly style: number + readonly sequence: number + readonly type: number + readonly libraryIndex: number + readonly tileIndex: number + readonly weight: number + readonly rarityFrameIndex: number +} + +/** + * Blizzard DRLGROOMTILE_GetTileCache reimplementation. + * + * Scans active DT1 libraries according to dwDt1Mask and retrieves candidate + * tiles matching the query, performing a weighted selection proportional to tile + * rarity (`rarityFrameIndex` / `rarity` / `Rarity`). + */ +export function DRLGROOMTILE_GetTileCache( + libraries: readonly Dt1[], + dt1Mask: number, + query?: TileCacheQuery, +): TileCacheResult | null { + const targetType = query?.type ?? 0 + const targetStyle = query?.style + const targetSequence = query?.sequence + + const candidates: TileCacheResult[] = [] + let totalWeight = 0 + + for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) { + if ((dt1Mask & (1 << libIndex)) === 0) continue + const lib = libraries[libIndex] + if (!lib) continue + + for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) { + const tile = lib.tiles[tIndex]! + if (tile.type !== targetType) continue + if (targetStyle !== undefined && tile.style !== targetStyle) continue + if (targetSequence !== undefined && tile.sequence !== targetSequence) continue + + const rawRarity = + (tile as unknown as { rarity?: number; Rarity?: number }).rarity ?? + (tile as unknown as { rarity?: number; Rarity?: number }).Rarity ?? + tile.rarityFrameIndex + const weight = Math.max(1, rawRarity | 0) + + candidates.push({ + style: tile.style, + sequence: tile.sequence, + type: tile.type, + libraryIndex: libIndex, + tileIndex: tIndex, + weight, + rarityFrameIndex: weight, + }) + totalWeight += weight + } + } + + if (candidates.length === 0) return null + + // Determine roll in [0, 1) + let roll: number + if (query?.seed !== undefined) { + if (typeof query.seed === 'number') { + if (query.seed >= 0 && query.seed < 1 && !Number.isInteger(query.seed)) { + roll = query.seed + } else if (query.x !== undefined && query.y !== undefined) { + roll = cellHash(query.x, query.y, query.seed) + } else { + roll = cellHash(candidates.length, totalWeight, query.seed) + } + } else { + roll = query.seed.next() + } + } else { + roll = Math.random() + } + + let target = roll * totalWeight + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]! + if (target < candidate.weight) { + return candidate + } + target -= candidate.weight + } + + return candidates[candidates.length - 1] ?? null +} + +export interface GroundTransitionOptions { + readonly libraries?: readonly Dt1[] | undefined + readonly dt1Mask?: number | undefined + readonly levelTypeName?: string | undefined + readonly levelId?: number | undefined + readonly rng?: Rng | undefined + readonly placedRects?: readonly PlacedPresetRect[] | undefined +} + +export interface GroundTransitionResult { + readonly transitionCells: number + readonly roadTransitions: number + readonly waterTransitions: number +} + +function isRoadCell(floor: Ds1Floor | undefined): boolean { + if (!floor || floor.hidden || floor.prop1 === 0) return false + return ( + floor.prop1 === 194 || + floor.style === 5 || + (floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) || + (floor.style === 1 && floor.prop1 === 194) + ) +} + +function isWaterCell(floor: Ds1Floor | undefined): boolean { + if (!floor || floor.hidden || floor.prop1 === 0) return false + return ( + floor.style === 2 || + floor.style === 3 || + floor.style === 16 || + floor.style === 29 || + floor.style === 38 || + floor.style === 39 || + floor.style === 40 || + (floor.style === 20 && floor.sequence === 3) + ) +} + +/** + * Analyzes adjacency between contrasting floor styles (road vs grass/ground, water vs shore) + * across the canvas, computes 8-neighbor bitmasks, and applies transition edge tiles to smooth + * boundaries. + * + * Employs two-phase snapshot execution (collect updates first, then apply) to avoid order-dependent + * mutation cascade. Guarantees 0 missing tiles when DT1 libraries are supplied by strictly verifying + * tile availability against active DT1 libraries. + */ +export function applyGroundTransitions( + canvas: Canvas, + options?: GroundTransitionOptions, +): GroundTransitionResult { + // Build set of available floor tiles if libraries are provided + const availableTiles = new Set() + if (options?.libraries && options.libraries.length > 0) { + const mask = options.dt1Mask ?? 0xFFFFFFFF + for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) { + if ((mask & (1 << libIndex)) === 0) continue + const lib = options.libraries[libIndex] + if (!lib) continue + for (const tile of lib.tiles) { + if (tile.type === 0) { + availableTiles.add(`${String(tile.style)}:${String(tile.sequence)}`) + } + } + } + } + + const isTileAvailable = (style: number, sequence: number): boolean => { + if (availableTiles.size === 0) return true + return availableTiles.has(`${String(style)}:${String(sequence)}`) + } + + const isProtected = (x: number, y: number): boolean => { + if (!options?.placedRects || options.placedRects.length === 0) return false + for (const r of options.placedRects) { + // River and Bridge presets are intended to have water/road blended + if (/River|Bridge/i.test(r.name)) continue + if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) { + return true + } + } + return false + } + + interface PendingUpdate { + readonly x: number + readonly y: number + readonly style: number + readonly sequence: number + readonly prop1?: number + readonly isRoad?: boolean + readonly isWater?: boolean + } + const pendingUpdates: PendingUpdate[] = [] + + const width = canvas.width + const height = canvas.height + + // Phase 1: Snapshot scan + for (let y = 0; y < height; y += 1) { + const row = canvas.cells[y] + if (!row) continue + for (let x = 0; x < width; x += 1) { + if (isProtected(x, y)) continue + const cell = row[x] + const floor = cell?.floors[0] + if (!floor || floor.hidden || floor.prop1 === 0) continue + + if (isRoadCell(floor)) { + if (floor.style === 0) { + const roadMask = computeNeighborMask(canvas, x, y, (nCell) => { + return isRoadCell(nCell?.floors[0]) + }) + + if (roadMask === 0xFF) { + // Interior road cell + if (floor.sequence !== 1) { + if (isTileAvailable(0, 1)) { + pendingUpdates.push({ x, y, style: 0, sequence: 1, prop1: 2, isRoad: true }) + } + } + } else { + // Boundary road cell + const targetSeq = DIRT_PATH_TILE_LUT[roadMask] ?? 0 + if (targetSeq !== 0 && targetSeq !== floor.sequence) { + if (isTileAvailable(0, targetSeq)) { + pendingUpdates.push({ x, y, style: 0, sequence: targetSeq, prop1: 2, isRoad: true }) + } + } + } + } + } else if (isWaterCell(floor)) { + const waterMask = computeNeighborMask(canvas, x, y, (nCell) => { + return isWaterCell(nCell?.floors[0]) + }) + + if (waterMask === 0xFF) { + // Interior deep water + if (floor.style === 2 && floor.sequence !== 24) { + if (isTileAvailable(2, 24)) { + pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true }) + } + } + } else { + // Boundary water cell + if (floor.style === 2) { + const targetSeq = WATER_EDGE_TILE_LUT[waterMask] ?? 0 + if (targetSeq !== floor.sequence) { + if (isTileAvailable(2, targetSeq)) { + pendingUpdates.push({ x, y, style: 2, sequence: targetSeq, prop1: 2, isWater: true }) + } + } + } + } + } + } + } + + // Phase 2: Apply updates + let roadTransitions = 0 + let waterTransitions = 0 + + for (const update of pendingUpdates) { + const cell = canvas.cells[update.y]?.[update.x] + if (!cell) continue + const floor = cell.floors[0] + if (!floor) continue + + if (floor.style === update.style && floor.sequence === update.sequence) continue + + Object.assign(floor, { + style: update.style, + sequence: update.sequence, + prop1: update.prop1 ?? floor.prop1, + hidden: false, + }) + + if (update.isRoad) roadTransitions += 1 + if (update.isWater) waterTransitions += 1 + } + + return { + transitionCells: roadTransitions + waterTransitions, + roadTransitions, + waterTransitions, + } +} + +export const DRLGROOMTILE_ApplyTransitions = applyGroundTransitions + /** * The hardcoded passes D2MOO runs for an outdoor level that this port does not * reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so @@ -5909,6 +6366,16 @@ export function generateWilderness(request: WildernessRequest): WildernessResult stats, landmarkDefs, ) + const groundTransitions = applyGroundTransitions(canvas, { + libraries: request.dt1Libraries, + dt1Mask: wildernessDt1Mask(request.levelTypeName), + levelTypeName: request.levelTypeName, + levelId: request.levelId, + rng, + placedRects: specialPlacedRects, + }) + stats.groundTransitions = groundTransitions + let finalGroundCells = 0 for (let y = 0; y < canvas.height; y += 1) { const row = canvas.cells[y] @@ -5951,6 +6418,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult groundTile: primaryGroundTile, groundTiles: groundTiles ?? undefined, groundCells: stats.groundCells, + groundTransitions: stats.groundTransitions, borderPiecesAvailable: 0, borderBlocks: 0, borderUsage: {}, @@ -6202,6 +6670,18 @@ export function generateWilderness(request: WildernessRequest): WildernessResult initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) } + // Step 4D: Smooth boundaries between contrasting ground types (roads, water, shore) + // using DRLGROOMTILE auto-tiling bitmasks. + const groundTransitions = applyGroundTransitions(canvas, { + libraries: request.dt1Libraries, + dt1Mask: wildernessDt1Mask(request.levelTypeName), + levelTypeName: request.levelTypeName, + levelId: request.levelId, + rng, + placedRects: allPlacedRects, + }) + stats.groundTransitions = groundTransitions + // Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie) const populationRng = new Rng(request.seed ^ 0x6c07) spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName, request.levelsTable) @@ -6269,6 +6749,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult groundTile: primaryGroundTile, groundTiles: groundTiles ?? undefined, groundCells: stats.groundCells, + groundTransitions: stats.groundTransitions, borderPiecesAvailable: borderPieces.length, borderBlocks: stats.borderStamped, borderUsage: stats.borderPieces, diff --git a/tests/wilderness-ground-blend.test.ts b/tests/wilderness-ground-blend.test.ts index 1fbffc8..4c570aa 100644 --- a/tests/wilderness-ground-blend.test.ts +++ b/tests/wilderness-ground-blend.test.ts @@ -10,13 +10,33 @@ import { createCanvas, generateWilderness, CANONICAL_GROUND_POOLS, + computeNeighborMask, + DRLGROOMTILE_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, + DRLGROOMTILE_ApplyTransitions, } 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 } from "../src/formats/ds1.ts" +import type { Ds1, Ds1Cell, Ds1Floor } from "../src/formats/ds1.ts" function makeMockDs1( width: number, @@ -55,7 +75,7 @@ function makeMockDs1( } } -function makeMockDt1(tiles: Partial[]): Dt1 { +function makeMockDt1(tiles: (Partial & { rarity?: number; Rarity?: number })[]): Dt1 { return { warnings: [], versionMajor: 7, @@ -508,5 +528,413 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => { 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("DRLGROOMTILE_ComputeNeighborMask alias equals computeNeighborMask", () => { + expect(DRLGROOMTILE_ComputeNeighborMask).toBe(computeNeighborMask) + }) + + 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 }) + 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 }) + 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 }) + 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 }) + 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 }) + expect(resWall?.type).toBe(1) + + // Non-existent style + const resMissing = DRLGROOMTILE_GetTileCache([dt1], 0x01, { style: 99 }) + 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 }) + expect(resA?.weight).toBe(45) + + const resB = DRLGROOMTILE_GetTileCache([dt1B], 1, { type: 0 }) + expect(resB?.weight).toBe(75) + + const resC = DRLGROOMTILE_GetTileCache([dt1C], 1, { type: 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 res = applyGroundTransitions(canvas) + 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 res = applyGroundTransitions(canvas) + 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("DRLGROOMTILE_ApplyTransitions alias equals applyGroundTransitions", () => { + expect(DRLGROOMTILE_ApplyTransitions).toBe(applyGroundTransitions) + }) + + 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) + } + } + }) }) })