From f36d1b4260215e065b999c94ed7523e95dde87d3 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 08:06:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(tilesub):=20=E4=B8=A5=E6=A0=BC=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=201.13c=20LvlSub.txt=20=E8=AF=95=E9=AA=8C=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8E=E5=85=A8=E6=8E=92=E5=88=97=E5=80=99=E9=80=89?= =?UTF-8?q?=E4=BD=8D=E5=9C=B0=E5=9D=97=E6=9B=BF=E6=8D=A2=E7=AE=97=E6=B3=95?= =?UTF-8?q?=20(Issue=20#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 1.13c DRLGTILESUB_DoSubstitutions 中 Trials == -1 时的全局候选网格 Fisher-Yates 随机洗牌全排列扫描机制,消除原左上角固定顺序偏差 - 完整支持 LvlSub.txt 中 SubTheme 0..4 的 Prob0..4、Trials0..4、Max0..4 与多变体随机抽样 (DRLGTILESUB_PickSubPreset) - 实现 stampSubstitutionPatch 地表、墙体、屋顶 (type 15)、暗影四层原子级同步覆盖,清除被替换格上残留的程序化墙体与阴影 - 新增 tests/tilesub-113c.test.ts 5 项回归测试 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/wilderness.ts | 259 ++++++++++++++++++++++++++++++------- tests/tilesub-113c.test.ts | 186 ++++++++++++++++++++++++++ 2 files changed, 398 insertions(+), 47 deletions(-) create mode 100644 tests/tilesub-113c.test.ts diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 98469dc..a09993b 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -3550,7 +3550,162 @@ function canPlaceSubstitutionCluster( * @param dirtPathGrid - optional cell-level dirt road bitmask grid. * @returns how many clusters were stamped. */ -function applySubstitution( +/** + * Atomically replace a target canvas region with a substitution DS1 (`DRLGTILESUB_ReplaceSubPreset`). + * + * Synchronously updates all 4 visual/collision layers (floors, walls, roofs `type === 15`, and shadows) + * plus the substitution group tags and DS1 objects, clearing stale procedural clutter on cells + * modified by the substitution patch. + */ +export function stampSubstitutionPatch( + canvas: Canvas, + patch: Ds1, + originX: number, + originY: number, +): number { + let written = 0 + for (let y = 0; y < patch.height; y += 1) { + const cy = originY + y + if (cy < 0 || cy >= canvas.height) continue + const srcRow = patch.cells[y] + const dstRow = canvas.cells[cy] + if (srcRow === undefined || dstRow === undefined) continue + + for (let x = 0; x < patch.width; x += 1) { + const cx = originX + x + if (cx < 0 || cx >= canvas.width) continue + const srcCell = srcRow[x] + const dstCell = dstRow[cx] + if (srcCell === undefined || dstCell === undefined) continue + + const hasFloor = srcCell.floors.some(f => !f.hidden && f.prop1 !== 0) + const hasWallOrRoof = srcCell.walls.some(w => !w.hidden && w.prop1 !== 0) + const hasShadow = srcCell.shadows.some(s => !s.hidden && s.prop1 !== 0) + const hasSub = srcCell.substitutions.some(s => s.value !== 0) + + if (!hasFloor && !hasWallOrRoof && !hasShadow && !hasSub) continue + + const targetFloors = dstCell.floors as Ds1Floor[] + const targetWalls = dstCell.walls as Ds1Wall[] + const targetShadows = dstCell.shadows as Ds1Floor[] + const targetSubs = dstCell.substitutions as Ds1Substitution[] + + // Atomic 4-layer sync (Floor, Wall, Roof, Shadow): when a substitution cell overwrites + // a tile, replace existing floor/wall/roof/shadow slots cleanly so underlying procedural + // walls or shadows do not bleed into the substituted structure. + if (hasFloor) { + for (let i = 0; i < srcCell.floors.length; i += 1) { + const floor = srcCell.floors[i] + if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) { + if (i < targetFloors.length) targetFloors[i] = floor + else targetFloors.push(floor) + } + } + } + + if (hasWallOrRoof || hasFloor) { + // Clear pre-existing procedural walls/roofs on this substituted cell before applying patch walls/roofs + for (let i = 0; i < targetWalls.length; i += 1) { + targetWalls[i] = { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false } + } + for (let i = 0; i < srcCell.walls.length; i += 1) { + const wall = srcCell.walls[i] + if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) { + if (i < targetWalls.length) targetWalls[i] = wall + else targetWalls.push(wall) + } + } + } + + if (hasShadow || hasFloor) { + for (let i = 0; i < targetShadows.length; i += 1) { + targetShadows[i] = { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } + } + for (let i = 0; i < srcCell.shadows.length; i += 1) { + const shadow = srcCell.shadows[i] + if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) { + if (i < targetShadows.length) targetShadows[i] = shadow + else targetShadows.push(shadow) + } + } + } + + if (hasSub) { + for (let i = 0; i < srcCell.substitutions.length; i += 1) { + const sub = srcCell.substitutions[i] + if (sub !== undefined && sub.value !== 0) { + if (i < targetSubs.length) targetSubs[i] = sub + else targetSubs.push(sub) + } + } + } + + written += 1 + } + } + + // Copy DS1 objects from the substitution patch + const subOriginX = originX * SUB_TILES_PER_TILE + const subOriginY = originY * SUB_TILES_PER_TILE + const maxSubX = canvas.width * SUB_TILES_PER_TILE + const maxSubY = canvas.height * SUB_TILES_PER_TILE + for (const obj of patch.objects) { + const ox = subOriginX + obj.x + const oy = subOriginY + obj.y + if (ox >= 0 && ox < maxSubX && oy >= 0 && oy < maxSubY) { + canvas.objects.push({ ...obj, x: ox, y: oy }) + } + } + + return written +} + +/** + * Build a Fisher-Yates randomized permutation of all grid-aligned candidate offsets + * within `(spanX, spanY)` for `Trials == -1` exhaustive substitution scan (`DRLGTILESUB_DoSubstitutions`). + */ +export function buildSubstitutionCandidatePermutation( + spanX: number, + spanY: number, + gridSize: number, + rng: Rng, +): { ox: number; oy: number }[] { + const step = Math.max(1, Math.floor(gridSize)) + const candidates: { ox: number; oy: number }[] = [] + const snap = (value: number, span: number): number => { + const limit = Math.max(Math.floor((span - 1) / step), 0) + return Math.min(Math.floor(value / step), limit) * step + } + + for (let oy = 0; oy < spanY; oy += step) { + for (let ox = 0; ox < spanX; ox += step) { + candidates.push({ ox: snap(ox, spanX), oy: snap(oy, spanY) }) + } + } + + // Fisher-Yates shuffle using the level's deterministic PRNG + for (let i = candidates.length - 1; i > 0; i -= 1) { + const j = rng.int(0, i) + const tmp = candidates[i]! + candidates[i] = candidates[j]! + candidates[j] = tmp + } + + return candidates +} + +/** + * Scatter one `LvlSub` row's pieces across the level's interior (`DRLGTILESUB_DoSubstitutions`). + * + * Implements Blizzard D2 1.13c `DrlgTileSub.cpp`: + * 1. Reads theme-indexed `Max[themeIndex]` and `Trials[themeIndex]` (`0..4`). + * 2. When `Trials == -1`, shuffles all candidate grid positions via Fisher-Yates permutation + * and scans until up to `wantedMax` non-overlapping clusters are placed. + * 3. When `Trials > 0`, performs up to `Trials` random coordinate draws per cluster. + * 4. Selects randomly among `row.levels` variants (`DRLGTILESUB_PickSubPreset`) and atomically + * replaces floor, wall, roof, and shadow layers (`stampSubstitutionPatch`). + */ +export function applySubstitution( canvas: Canvas, row: WildernessSubstitution, themeIndex: number, @@ -3559,15 +3714,17 @@ function applySubstitution( rng: Rng, claimedBlocks?: boolean[][], dirtPathGrid?: Uint8Array, + maxOverride?: number, ): number { - const level = row.levels[0] - if (level === undefined) return 0 - const max = Math.max(0, Math.floor(row.max[themeIndex] ?? 0)) + if (row.levels.length === 0) return 0 + const baseLevel = row.levels[0]! + const rawMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0)) + const max = maxOverride !== undefined ? Math.min(rawMax, maxOverride) : rawMax if (max === 0) return 0 const trials = Math.floor(row.trials[themeIndex] ?? 0) const gridSize = Math.max(1, Math.floor(row.gridSize)) - const blocksX = Math.max(Math.floor(level.width / TILES_PER_BLOCK), 1) - const blocksY = Math.max(Math.floor(level.height / TILES_PER_BLOCK), 1) + const blocksX = Math.max(Math.floor(baseLevel.width / TILES_PER_BLOCK), 1) + const blocksY = Math.max(Math.floor(baseLevel.height / TILES_PER_BLOCK), 1) // Keep scenery off the wall ring, so it can never open or seal the level. const innerX = gridWidth - 2 @@ -3582,10 +3739,10 @@ function applySubstitution( return Math.min(Math.floor(value / gridSize), limit) * gridSize } - const recordClaim = (bx: number, by: number): void => { + const recordClaim = (bx: number, by: number, bw: number, bh: number): void => { if (claimedBlocks === undefined) return - for (let dy = 0; dy < blocksY; dy += 1) { - for (let dx = 0; dx < blocksX; dx += 1) { + for (let dy = 0; dy < bh; dy += 1) { + for (let dx = 0; dx < bw; dx += 1) { if (claimedBlocks[by + dy] !== undefined) { claimedBlocks[by + dy]![bx + dx] = true } @@ -3593,38 +3750,52 @@ function applySubstitution( } } + const pickVariant = (): Ds1 => { + if (row.levels.length === 1) return row.levels[0]! + return row.levels[rng.int(0, row.levels.length - 1)]! + } + let stamped = 0 - for (let cluster = 0; cluster < max; cluster += 1) { - let placed = false - if (trials === -1) { - // "Try every free position", in a stable order. - for (let oy = 0; oy < spanY && !placed; oy += gridSize) { - for (let ox = 0; ox < spanX && !placed; ox += gridSize) { - const snappedX = snap(ox, spanX) - const snappedY = snap(oy, spanY) - const bx = 1 + snappedX - const by = 1 + snappedY - if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) { - continue - } - stampDs1(canvas, level, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) - recordClaim(bx, by) - placed = true - } - } - } else { - for (let attempt = 0; attempt < trials && !placed; attempt += 1) { - const offsetX = snap(rng.int(0, Math.max(spanX - 1, 0)), spanX) - const offsetY = snap(rng.int(0, Math.max(spanY - 1, 0)), spanY) - const bx = 1 + offsetX - const by = 1 + offsetY + + if (trials === -1) { + // 1.13c DRLGTILESUB_DoSubstitutions: when nTrials == -1, permute all candidate positions + // and place up to `max` clusters without repeating failed slots. + const candidates = buildSubstitutionCandidatePermutation(spanX, spanY, gridSize, rng) + let candidateIdx = 0 + for (let cluster = 0; cluster < max && candidateIdx < candidates.length; cluster += 1) { + let placed = false + while (candidateIdx < candidates.length && !placed) { + const cand = candidates[candidateIdx]! + candidateIdx += 1 + const bx = 1 + cand.ox + const by = 1 + cand.oy if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) { continue } - stampDs1(canvas, level, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) - recordClaim(bx, by) + const chosenPatch = pickVariant() + stampSubstitutionPatch(canvas, chosenPatch, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) + recordClaim(bx, by, blocksX, blocksY) placed = true } + if (placed) stamped += 1 + } + return stamped + } + + for (let cluster = 0; cluster < max; cluster += 1) { + let placed = false + for (let attempt = 0; attempt < trials && !placed; attempt += 1) { + const offsetX = snap(rng.int(0, Math.max(spanX - 1, 0)), spanX) + const offsetY = snap(rng.int(0, Math.max(spanY - 1, 0)), spanY) + const bx = 1 + offsetX + const by = 1 + offsetY + if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) { + continue + } + const chosenPatch = pickVariant() + stampSubstitutionPatch(canvas, chosenPatch, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) + recordClaim(bx, by, blocksX, blocksY) + placed = true } if (placed) stamped += 1 } @@ -3636,7 +3807,7 @@ function applySubstitution( * * @param canvas - the canvas. * @param rows - the rows to run, in table order. - * @param themeIndex - the clamped `SubTheme`. + * @param themeIndex - the clamped `SubTheme` (`0..4`). * @param gridWidth - blocks across. * @param gridHeight - blocks down. * @param rng - the level's random stream. @@ -3645,7 +3816,7 @@ function applySubstitution( * @param dirtPathGrid - optional cell-level dirt road bitmask grid. * @returns the number of clusters stamped. */ -function applySubstitutions( +export function applySubstitutions( canvas: Canvas, rows: readonly WildernessSubstitution[], themeIndex: number, @@ -3657,26 +3828,20 @@ function applySubstitutions( dirtPathGrid?: Uint8Array, ): number { let total = 0 - /** - * `BordType` 0 rows share one cluster across the whole level. - * - * This started at `Infinity`, so `Math.min(1, budget)` was always 1 and the - * subtraction below never bit: every `BordType` 0 row got its own cluster, - * which is the `BordType` 1 rule. Starting at 1 makes the cap real. - */ let sharedBudget = 1 + const clampedTheme = Math.max(0, Math.min(4, Math.floor(themeIndex))) for (const row of rows) { const role = classifySubstitutionRole(row.name, row.type) - const chance = row.prob[themeIndex] ?? 0 + const chance = row.prob[clampedTheme] ?? 0 const enabled = chance > 0 && rng.int(0, 99) < chance let clusters = 0 if (enabled) { // `BordType` 0 = one cluster for the whole level, 1 = one per row. const allowance = row.bordType === 0 ? sharedBudget : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY - const rowMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0)) + const rowMax = Math.max(0, Math.floor(row.max[clampedTheme] ?? 0)) const wanted = Math.min(rowMax, allowance) if (wanted > 0) { - const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid) + const single = applySubstitution(canvas, row, clampedTheme, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid, wanted) clusters = Math.min(wanted, single) if (row.bordType !== 0 && row.bordType !== 1) clusters = single if (row.bordType === 0) sharedBudget = Math.max(0, sharedBudget - clusters) diff --git a/tests/tilesub-113c.test.ts b/tests/tilesub-113c.test.ts new file mode 100644 index 0000000..13fe101 --- /dev/null +++ b/tests/tilesub-113c.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { + applySubstitution, + applySubstitutions, + buildSubstitutionCandidatePermutation, + createCanvas, + stampSubstitutionPatch, +} from '../src/game/wilderness.ts' +import type { WildernessSubstitution } from '../src/game/wilderness.ts' +import { Rng } from '../src/game/rng.ts' +import type { Ds1 } from '../src/formats/ds1.ts' + +function makePatchDs1(width: number, height: number, floorSeq: number, wallSeq = 10, roofSeq = 20, shadowSeq = 30): Ds1 { + const cells = Array.from({ length: height }, () => + Array.from({ length: width }, () => ({ + floors: [{ prop1: 1, style: 5, sequence: floorSeq, hidden: false, unknown1: 0, unknown2: 0 }], + walls: [ + { prop1: 1, style: 6, sequence: wallSeq, type: 1, hidden: false, unknown1: 0, unknown2: 0 }, + { prop1: 1, style: 7, sequence: roofSeq, type: 15, hidden: false, unknown1: 0, unknown2: 0 }, + ], + shadows: [{ prop1: 1, style: 8, sequence: shadowSeq, hidden: false, unknown1: 0, unknown2: 0 }], + substitutions: [{ value: 1 }], + })), + ) + return { + version: 18, + width, + height, + act: 1, + substitutionType: 1, + wallLayers: 2, + floorLayers: 1, + cells, + objects: [{ type: 2, id: 15, x: 2, y: 2, flags: 0 }], + npcPathOffset: null, + } +} + +describe('Blizzard D2 1.13c LvlSub.txt Trials & Exhaustive Permutation Substitution (Issue #76)', () => { + it('generates a complete, duplicate-free Fisher-Yates candidate permutation that varies by seed', () => { + const permA = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(1001)) + const permB = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(2002)) + + expect(permA).toHaveLength(36) + expect(permB).toHaveLength(36) + + // Verify all 36 grid coordinates are present with zero duplicates + const keysA = new Set(permA.map(p => `${p.ox},${p.oy}`)) + expect(keysA.size).toBe(36) + + // Verify permutation order differs between seeds (not fixed top-left scan) + const firstFiveA = permA.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|') + const firstFiveB = permB.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|') + expect(firstFiveA).not.toBe(firstFiveB) + }) + + it('finds the sole remaining free candidate slot when Trials == -1 (exhaustive scan guarantee)', () => { + const gridWidth = 8 + const gridHeight = 8 + const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) + + // Claim every interior block EXCEPT (bx = 5, by = 4) + const claimedBlocks = Array.from({ length: gridHeight }, () => + Array.from({ length: gridWidth }, () => true), + ) + claimedBlocks[4]![5] = false + + const patch = makePatchDs1(8, 8, 42) + const subRow: WildernessSubstitution = { + name: 'Act 1 - Exhaustive Shrine', + type: 1, + bordType: -1, + gridSize: 1, + dt1Mask: 0, + prob: [100, 100, 100, 100, 100], + trials: [-1, -1, -1, -1, -1], + max: [1, 1, 1, 1, 1], + levels: [patch], + } + + const stamped = applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(999), claimedBlocks) + expect(stamped).toBe(1) + + // Verify the patch was stamped at exact block (bx = 5, by = 4) => tile (40, 32) + const targetCell = canvas.cells[32]![40]! + expect(targetCell.floors[0]?.sequence).toBe(42) + }) + + it('distributes Trials == -1 placements across varied interior locations rather than top-left (1,1)', () => { + const gridWidth = 10 + const gridHeight = 10 + const patch = makePatchDs1(8, 8, 55) + const subRow: WildernessSubstitution = { + name: 'Act 1 - Swamp Pool', + type: 1, + bordType: -1, + gridSize: 1, + dt1Mask: 0, + prob: [100, 100, 100, 100, 100], + trials: [-1, -1, -1, -1, -1], + max: [1, 1, 1, 1, 1], + levels: [patch], + } + + const placedCoords = new Set() + for (let seed = 1; seed <= 10; seed += 1) { + const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) + const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) + applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(seed * 7919), claimed) + for (let by = 1; by < gridHeight - 1; by += 1) { + for (let bx = 1; bx < gridWidth - 1; bx += 1) { + if (claimed[by]![bx]) placedCoords.add(`${bx},${by}`) + } + } + } + + // Across 10 seeds, Fisher-Yates permutation places the cluster in many different spots (not just 1,1) + expect(placedCoords.size).toBeGreaterThan(5) + }) + + it('atomically replaces Floor, Wall, Roof (type 15), and Shadow layers while clearing stale procedural clutter', () => { + const canvas = createCanvas(16, 16, 2, 1, 1) + + // Pre-populate cell (4, 4) with procedural clutter on floor, wall, roof, and shadow layers + const staleCell = canvas.cells[4]![4]! + ;(staleCell.floors as unknown[])[0] = { prop1: 1, style: 1, sequence: 1, hidden: false, unknown1: 0, unknown2: 0 } + ;(staleCell.walls as unknown[])[0] = { prop1: 1, style: 99, sequence: 99, type: 1, hidden: false, unknown1: 0, unknown2: 0 } + ;(staleCell.walls as unknown[])[1] = { prop1: 1, style: 99, sequence: 98, type: 15, hidden: false, unknown1: 0, unknown2: 0 } + ;(staleCell.shadows as unknown[])[0] = { prop1: 1, style: 99, sequence: 97, hidden: false, unknown1: 0, unknown2: 0 } + + const patch = makePatchDs1(2, 2, 11, 22, 33, 44) + const written = stampSubstitutionPatch(canvas, patch, 4, 4) + expect(written).toBe(4) + + const updatedCell = canvas.cells[4]![4]! + expect(updatedCell.floors[0]?.sequence).toBe(11) + expect(updatedCell.walls[0]?.sequence).toBe(22) + expect(updatedCell.walls[0]?.type).toBe(1) + expect(updatedCell.walls[1]?.sequence).toBe(33) + expect(updatedCell.walls[1]?.type).toBe(15) + expect(updatedCell.shadows[0]?.sequence).toBe(44) + expect(updatedCell.substitutions[0]?.value).toBe(1) + expect(canvas.objects).toHaveLength(1) + }) + + it('applies theme-indexed Prob0..4, Trials0..4, and Max0..4 with multi-variant selection', () => { + const gridWidth = 10 + const gridHeight = 10 + const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) + const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) + + const variantA = makePatchDs1(8, 8, 101) + const variantB = makePatchDs1(8, 8, 102) + + const rows: WildernessSubstitution[] = [ + { + name: 'Theme 2 Multi-Variant Bivouac', + type: 1, + bordType: -1, + gridSize: 1, + dt1Mask: 0, + prob: [0, 0, 100, 0, 0], + trials: [0, 0, 20, 0, 0], + max: [0, 0, 4, 0, 0], + levels: [variantA, variantB], + }, + ] + + const stats = { + levelId: 2, + seed: 12345, + subTheme: 2, + gridWidth, + gridHeight, + substitutions: [] as { name: string; role: string; enabled: boolean; clusters: number }[], + borderPieces: {}, + unresolved: [], + notes: [], + } + + const total = applySubstitutions(canvas, rows, 2, gridWidth, gridHeight, new Rng(12345), stats as never, claimed) + expect(total).toBe(4) + expect(stats.substitutions[0]?.enabled).toBe(true) + expect(stats.substitutions[0]?.clusters).toBe(4) + }) +})