feat(tilesub): 严格对齐 1.13c LvlSub.txt 试验次数与全排列候选位地块替换算法 (Issue #76)

- 实现 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
This commit is contained in:
troytt 2026-09-18 08:06:50 +00:00
parent ef59646631
commit f36d1b4260
2 changed files with 398 additions and 47 deletions

View File

@ -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)

186
tests/tilesub-113c.test.ts Normal file
View File

@ -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<string>()
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)
})
})