fix(wilderness): add interior stone walls, probabilistic river, and perimeter opening control

- Implement isClosedBorder, selectBorderVariant, and getPerimeterOpenings
- Moo Moo Farm (Level 39): enforce 0 exits (100% closed perimeter), add interior corrals and stone fences
- Blood Moor (Level 2): enforce exactly 1 entrance (South) and 1 exit (East), add stone corrals/fences and probabilistic river/bridge presets
- Add 'Act 1 - Fence Fill', 'Act 1 - River', 'Act 1 - Bridge' to Act 1 Wilderness piece families
- Add comprehensive unit tests in tests/wilderness-features.test.ts
- Optimize publish-packs.ts with batch incremental pushes by Act
This commit is contained in:
troytt 2026-09-15 05:25:09 +00:00
parent 6b9c3d3430
commit 2d7f5f7a00
5 changed files with 319 additions and 20 deletions

View File

@ -520,6 +520,9 @@ const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill',
'Act 1 - River',
'Act 1 - Bridge',
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Swamp Fill',

View File

@ -78,6 +78,7 @@ function measure(): { files: number; bytes: number; maps: number; indexSha: stri
let bytes = 0
const walk = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === '.git') continue
const path = join(dir, entry.name)
if (entry.isDirectory()) walk(path)
else {
@ -159,26 +160,39 @@ try {
console.log(`临时仓库:${work}`)
git(['init', '-q', '-b', 'main'], work)
cpSync(PACKS_DIR, work, { recursive: true })
git(['config', 'http.postBuffer', '524288000'], work)
git(['config', 'http.version', 'HTTP/1.1'], work)
// Step 1: Base metadata and index
writeFileSync(join(work, 'README.md'), readme(stats, stamp))
writeFileSync(join(work, '.gitattributes'), '*.png binary -diff\n*.json text eol=lf\n')
cpSync(join(PACKS_DIR, 'index.json'), join(work, 'index.json'))
git(['add', '-A'], work)
git([
'-c', `user.name=${process.env['GIT_AUTHOR_NAME'] ?? 'Dao Tao'}`,
'-c', `user.email=${process.env['GIT_AUTHOR_EMAIL'] ?? 'taodao@google.com'}`,
'commit', '-q', '-m',
`烘焙图集包 ${stamp}:${String(stats.maps)} 个地图块(${(stats.bytes / 1048576).toFixed(1)} MB)
本仓库刻意只保留一个提交:图集页是索引色 PNG,git 的 delta 压缩对它几乎无收益,
每次重烘几乎全部文件都变;普通提交会让仓库每烘焙一次再长 ~60 MB。所以每次重烘都
用 orphan 提交 + force push 覆盖(见 README)。
index.json sha256: ${stats.indexSha}
由 troytt/diablo2-web 的 npm run pack:data 生成。`,
`烘焙图集包 ${stamp} [0/5]:索引与元数据\n\nindex.json sha256: ${stats.indexSha}\n\n由 troytt/diablo2-web 的 npm run pack:data 生成。`,
], work)
console.log(`推送到 ${remote}(force,覆盖历史)…`)
console.log(`推送到 ${remote}(force,覆盖历史,初始化基线)…`)
git(['push', '--force', remote, 'main'], work)
// Step 2..6: Push acts in small incremental batches to stay under HTTP timeout limits
const acts = ['act4', 'act3', 'act5', 'act2', 'act1'] as const
for (let i = 0; i < acts.length; i++) {
const act = acts[i]!
console.log(`添加并提交 ${act} [${i + 1}/${acts.length}]…`)
cpSync(join(PACKS_DIR, act), join(work, act), { recursive: true })
git(['add', act], work)
git([
'-c', `user.name=${process.env['GIT_AUTHOR_NAME'] ?? 'Dao Tao'}`,
'-c', `user.email=${process.env['GIT_AUTHOR_EMAIL'] ?? 'taodao@google.com'}`,
'commit', '-q', '-m',
`烘焙图集包 ${stamp} [${i + 1}/5]:${act} 图集`,
], work)
console.log(`推送到 ${remote}(增量推送 ${act})…`)
git(['push', remote, 'main'], work)
}
console.log('完成。使用方:git clone ' + remote + ' samples/d2-packs')
} finally {
rmSync(work, { recursive: true, force: true })

View File

@ -400,6 +400,9 @@ const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill',
'Act 1 - River',
'Act 1 - Bridge',
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Swamp Fill',
@ -684,6 +687,11 @@ for (const { id, name } of wildLevels) {
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
check(Number(first.stats.groundCells) === first.level.width * first.level.height, label, `ground floor coverage is incomplete: ${String(first.stats.groundCells)}/${String(first.level.width * first.level.height)}`)
if (id === 2) {
check(scene.walls.length > 500, label, `Blood Moor walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Corral') || s.name.includes('Fence')), label, 'Blood Moor missing Corral/Fence Fill special preset')
}
if (id === 3) {
check(scene.walls.length > 500, label, `Cold Plains walls (${String(scene.walls.length)}) indicates missing interior preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
@ -698,6 +706,7 @@ for (const { id, name } of wildLevels) {
check(scene.walls.length > 500, label, `Moo Moo Farm walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Corral')), label, 'Moo Moo Farm missing Corral Fill special preset')
check(substitutions.some(s => s.name.includes('Fence')), label, 'Moo Moo Farm missing Fence Fill special preset')
check(substitutions.some(s => s.name.includes('Bivouac')), label, 'Moo Moo Farm missing Bivouac special preset')
check(substitutions.some(s => s.name.includes('Pond')), label, 'Moo Moo Farm missing Pond special preset')
}

View File

@ -727,6 +727,97 @@ function selectBorderPiece(
return matches[rng.int(0, matches.length - 1)]!.piece
}
/**
* Check if a border DS1 variant has a continuous wall along the border orientation.
*/
export function isClosedBorder(level: Ds1, orientation: BorderOrientation): boolean {
if (orientation === 'north' || orientation === 'south') {
for (let y = 0; y < level.height; y += 1) {
let continuous = true
for (let x = 0; x < level.width; x += 1) {
if (!level.cells[y]?.[x]?.walls.some(w => !w.hidden && w.prop1 !== 0)) {
continuous = false
break
}
}
if (continuous) return true
}
return false
}
if (orientation === 'west' || orientation === 'east') {
for (let x = 0; x < level.width; x += 1) {
let continuous = true
for (let y = 0; y < level.height; y += 1) {
if (!level.cells[y]?.[x]?.walls.some(w => !w.hidden && w.prop1 !== 0)) {
continuous = false
break
}
}
if (continuous) return true
}
return false
}
return true
}
/**
* Select a specific border variant based on whether this perimeter position is an opening (exit/entrance).
*/
export function selectBorderVariant(
piece: WildernessPiece,
orientation: BorderOrientation,
isOpen: boolean,
rng: Rng,
): Ds1 {
const closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation))
const openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation))
if (isOpen && openLevels.length > 0) {
return openLevels[rng.int(0, openLevels.length - 1)]!
}
if (!isOpen && closedLevels.length > 0) {
return closedLevels[rng.int(0, closedLevels.length - 1)]!
}
return piece.levels[rng.int(0, piece.levels.length - 1)]!
}
/**
* Determine perimeter opening (exit/entrance) coordinates for a wilderness level.
*/
export function getPerimeterOpenings(
levelId: number,
gridWidth: number,
gridHeight: number,
): Set<string> {
const openings = new Set<string>()
const key = (x: number, y: number) => `${String(x)},${String(y)}`
if (levelId === 39) {
// Moo Moo Farm (Cow Level): 0 exits!
return openings
}
if (levelId === 2) {
// Blood Moor: exactly 1 entrance (South edge) and 1 exit (East edge)
const inX = Math.floor(gridWidth / 2)
openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2)
openings.add(key(gridWidth - 1, outY))
return openings
}
// Act 1 wilderness levels (3..7): entrance on South, exit on East
if (levelId >= 3 && levelId <= 7) {
const inX = Math.floor(gridWidth / 2)
openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2)
openings.add(key(gridWidth - 1, outY))
return openings
}
return openings
}
/**
* Lay the level's border pieces around the block grid's outer ring.
*
@ -736,12 +827,16 @@ function selectBorderPiece(
* prefer solid rock cliffs (`StnClf`), while South and East boundaries use fence borders,
* matching original Diablo II DRLG rules.
*
* Perimeter openings (exits/entrances) select open variants (e.g. `Bord1o`/`Bord4o`),
* while all other perimeter pieces use solid/closed wall variants (`isClosedBorder`).
*
* @param canvas - the canvas.
* @param pieces - the level's border pieces, already filtered to `border`.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - the level's random stream, for the variant rotation.
* @param stats - report collector.
* @param levelId - level id for perimeter opening rules.
* @returns the number of pieces stamped.
*/
function layBorder(
@ -751,6 +846,7 @@ function layBorder(
gridHeight: number,
rng: Rng,
stats: WildernessStats,
levelId: number,
): number {
if (pieces.length === 0) {
stats.unresolved.push('no border piece for this level type')
@ -793,10 +889,12 @@ function layBorder(
{ x: 0, y: gridHeight - 1 }, // SW
]
const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight)
let cursor = rng.int(0, ordered.length - 1)
let stamped = 0
const stampCell = (cell: { x: number; y: number }) => {
const stampCell = (cell: { x: number; y: number }, isCorner: boolean) => {
const orientation = getCellOrientation(cell.x, cell.y, gridWidth, gridHeight)
// North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries)
const preferCliff =
@ -813,8 +911,8 @@ function layBorder(
}
if (!piece || piece.levels.length === 0) return
const variant = rng.int(0, piece.levels.length - 1)
const level = piece.levels[variant % piece.levels.length]
const isOpen = !isCorner && openings.has(`${String(cell.x)},${String(cell.y)}`)
const level = selectBorderVariant(piece, orientation, isOpen, rng)
if (level === undefined) return
const maxOriginX = Math.max(canvas.width - level.width, 0)
@ -832,8 +930,8 @@ function layBorder(
stamped += 1
}
for (const cell of straightCells) stampCell(cell)
for (const cell of cornerCells) stampCell(cell)
for (const cell of straightCells) stampCell(cell, false)
for (const cell of cornerCells) stampCell(cell, true)
return stamped
}
@ -989,8 +1087,12 @@ function applySubstitutions(
*/
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
2: [
'Act 1 - Pond',
'Act 1 - River Upper',
'Act 1 - River Lower',
'Act 1 - Bridge',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
@ -998,6 +1100,8 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp Bishibosh',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
@ -1009,6 +1113,7 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
],
5: [
'Act 1 - Inifus',
@ -1034,14 +1139,18 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
'Act 1 - Cottages 2',
'Act 1 - Fallen Camp 2',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
],
17: [
'Act 1 - Graveyard',
],
39: [
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Corral Fill',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Pond',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Stone Fill 1',
@ -1314,9 +1423,36 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
const borderPieces = request.pieces.filter(piece => piece.border)
const fillPieces = request.pieces.filter(piece => !piece.border)
layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats)
layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats, request.levelId)
const specialPresetNames = request.specialPresets ?? SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? []
let specialPresetNames = request.specialPresets
if (specialPresetNames === undefined) {
if (request.levelId === 2) {
// Blood Moor has a chance (50%) of spawning river water + bridge, or pond
const hasRiver = rng.int(0, 1) === 0
specialPresetNames = hasRiver
? [
'Act 1 - River Upper',
'Act 1 - River Lower',
'Act 1 - Bridge',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
]
: [
'Act 1 - Pond',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
]
} else {
specialPresetNames = SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? []
}
}
const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats)
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))

View File

@ -0,0 +1,137 @@
import { describe, expect, test } from "vitest"
import {
isClosedBorder,
selectBorderVariant,
getPerimeterOpenings,
SPECIAL_PRESETS_BY_LEVEL,
generateWilderness,
WildernessPiece,
} from "../src/game/wilderness.ts"
import { Rng } from "../src/game/rng.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, hasGapInWalls: boolean): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 1, 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,
}
}
describe("Wilderness Features & Border Rules", () => {
test("isClosedBorder identifies continuous vs open wall presets", () => {
const closed = makeMockDs1(8, 8, false)
const open = makeMockDs1(8, 8, true)
expect(isClosedBorder(closed, "south")).toBe(true)
expect(isClosedBorder(closed, "north")).toBe(true)
expect(isClosedBorder(open, "south")).toBe(false)
expect(isClosedBorder(open, "north")).toBe(false)
})
test("getPerimeterOpenings enforces 0 exits for Cow Level and 1 entrance/1 exit for Blood Moor", () => {
const cowOpenings = getPerimeterOpenings(39, 10, 10)
expect(cowOpenings.size).toBe(0)
const bloodMoorOpenings = getPerimeterOpenings(2, 10, 10)
expect(bloodMoorOpenings.size).toBe(2)
expect(bloodMoorOpenings.has("5,9")).toBe(true)
expect(bloodMoorOpenings.has("9,5")).toBe(true)
})
test("selectBorderVariant picks open variant for openings and closed variant for closed walls", () => {
const closedDs1 = makeMockDs1(8, 8, false)
const openDs1 = makeMockDs1(8, 8, true)
const piece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [closedDs1, openDs1],
}
const rng = new Rng(12345)
const pickedOpen = selectBorderVariant(piece, "south", true, rng)
expect(isClosedBorder(pickedOpen, "south")).toBe(false)
const pickedClosed = selectBorderVariant(piece, "south", false, rng)
expect(isClosedBorder(pickedClosed, "south")).toBe(true)
})
test("SPECIAL_PRESETS_BY_LEVEL contains stone walls and river presets", () => {
const bloodMoorPresets = SPECIAL_PRESETS_BY_LEVEL[2]
expect(bloodMoorPresets).toBeDefined()
expect(bloodMoorPresets).toContain("Act 1 - Corral Fill")
expect(bloodMoorPresets).toContain("Act 1 - Fence Fill 4")
expect(bloodMoorPresets).toContain("Act 1 - River Upper")
expect(bloodMoorPresets).toContain("Act 1 - Bridge")
const cowPresets = SPECIAL_PRESETS_BY_LEVEL[39]
expect(cowPresets).toBeDefined()
expect(cowPresets).toContain("Act 1 - Corral Fill")
expect(cowPresets).toContain("Act 1 - Fence Fill 4")
expect(cowPresets).toContain("Act 1 - Fence Fill 5")
expect(cowPresets).toContain("Act 1 - Bivouac")
expect(cowPresets).toContain("Act 1 - Pond")
})
test("generateWilderness generates Level 2 with river or pond deterministically by seed", () => {
const closedDs1 = makeMockDs1(8, 8, false)
const piece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [closedDs1],
}
const riverPiece: WildernessPiece = {
name: "Act 1 - River Upper",
border: false,
levels: [closedDs1],
}
const pondPiece: WildernessPiece = {
name: "Act 1 - Pond",
border: false,
levels: [closedDs1],
}
const corralPiece: WildernessPiece = {
name: "Act 1 - Corral Fill",
border: false,
levels: [closedDs1],
}
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 12345,
pieces: [piece, riverPiece, pondPiece, corralPiece],
substitutions: [],
})
expect(result.stats.blockGrid).toEqual({ width: 10, height: 10 })
expect(result.level.width).toBe(80)
expect(result.level.height).toBe(80)
})
})