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:
parent
6b9c3d3430
commit
2d7f5f7a00
|
|
@ -520,6 +520,9 @@ const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||||
'Act 1 - Wilderness': [
|
'Act 1 - Wilderness': [
|
||||||
'Act 1 - Wild',
|
'Act 1 - Wild',
|
||||||
'Act 1 - Corral Fill',
|
'Act 1 - Corral Fill',
|
||||||
|
'Act 1 - Fence Fill',
|
||||||
|
'Act 1 - River',
|
||||||
|
'Act 1 - Bridge',
|
||||||
'Act 1 - Bivouac',
|
'Act 1 - Bivouac',
|
||||||
'Act 1 - Pond',
|
'Act 1 - Pond',
|
||||||
'Act 1 - Swamp Fill',
|
'Act 1 - Swamp Fill',
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ function measure(): { files: number; bytes: number; maps: number; indexSha: stri
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
const walk = (dir: string): void => {
|
const walk = (dir: string): void => {
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name === '.git') continue
|
||||||
const path = join(dir, entry.name)
|
const path = join(dir, entry.name)
|
||||||
if (entry.isDirectory()) walk(path)
|
if (entry.isDirectory()) walk(path)
|
||||||
else {
|
else {
|
||||||
|
|
@ -159,26 +160,39 @@ try {
|
||||||
console.log(`临时仓库:${work}`)
|
console.log(`临时仓库:${work}`)
|
||||||
|
|
||||||
git(['init', '-q', '-b', 'main'], 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, 'README.md'), readme(stats, stamp))
|
||||||
writeFileSync(join(work, '.gitattributes'), '*.png binary -diff\n*.json text eol=lf\n')
|
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(['add', '-A'], work)
|
||||||
git([
|
git([
|
||||||
'-c', `user.name=${process.env['GIT_AUTHOR_NAME'] ?? 'Dao Tao'}`,
|
'-c', `user.name=${process.env['GIT_AUTHOR_NAME'] ?? 'Dao Tao'}`,
|
||||||
'-c', `user.email=${process.env['GIT_AUTHOR_EMAIL'] ?? 'taodao@google.com'}`,
|
'-c', `user.email=${process.env['GIT_AUTHOR_EMAIL'] ?? 'taodao@google.com'}`,
|
||||||
'commit', '-q', '-m',
|
'commit', '-q', '-m',
|
||||||
`烘焙图集包 ${stamp}:${String(stats.maps)} 个地图块(${(stats.bytes / 1048576).toFixed(1)} MB)
|
`烘焙图集包 ${stamp} [0/5]:索引与元数据\n\nindex.json sha256: ${stats.indexSha}\n\n由 troytt/diablo2-web 的 npm run pack:data 生成。`,
|
||||||
|
|
||||||
本仓库刻意只保留一个提交:图集页是索引色 PNG,git 的 delta 压缩对它几乎无收益,
|
|
||||||
每次重烘几乎全部文件都变;普通提交会让仓库每烘焙一次再长 ~60 MB。所以每次重烘都
|
|
||||||
用 orphan 提交 + force push 覆盖(见 README)。
|
|
||||||
|
|
||||||
index.json sha256: ${stats.indexSha}
|
|
||||||
|
|
||||||
由 troytt/diablo2-web 的 npm run pack:data 生成。`,
|
|
||||||
], work)
|
], work)
|
||||||
console.log(`推送到 ${remote}(force,覆盖历史)…`)
|
console.log(`推送到 ${remote}(force,覆盖历史,初始化基线)…`)
|
||||||
git(['push', '--force', remote, 'main'], work)
|
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')
|
console.log('完成。使用方:git clone ' + remote + ' samples/d2-packs')
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(work, { recursive: true, force: true })
|
rmSync(work, { recursive: true, force: true })
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,9 @@ const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||||||
'Act 1 - Wilderness': [
|
'Act 1 - Wilderness': [
|
||||||
'Act 1 - Wild',
|
'Act 1 - Wild',
|
||||||
'Act 1 - Corral Fill',
|
'Act 1 - Corral Fill',
|
||||||
|
'Act 1 - Fence Fill',
|
||||||
|
'Act 1 - River',
|
||||||
|
'Act 1 - Bridge',
|
||||||
'Act 1 - Bivouac',
|
'Act 1 - Bivouac',
|
||||||
'Act 1 - Pond',
|
'Act 1 - Pond',
|
||||||
'Act 1 - Swamp Fill',
|
'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(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(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)}`)
|
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) {
|
if (id === 3) {
|
||||||
check(scene.walls.length > 500, label, `Cold Plains walls (${String(scene.walls.length)}) indicates missing interior preset structures`)
|
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 }[]
|
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`)
|
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 }[]
|
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('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('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')
|
check(substitutions.some(s => s.name.includes('Pond')), label, 'Moo Moo Farm missing Pond special preset')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -727,6 +727,97 @@ function selectBorderPiece(
|
||||||
return matches[rng.int(0, matches.length - 1)]!.piece
|
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.
|
* 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,
|
* prefer solid rock cliffs (`StnClf`), while South and East boundaries use fence borders,
|
||||||
* matching original Diablo II DRLG rules.
|
* 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 canvas - the canvas.
|
||||||
* @param pieces - the level's border pieces, already filtered to `border`.
|
* @param pieces - the level's border pieces, already filtered to `border`.
|
||||||
* @param gridWidth - blocks across.
|
* @param gridWidth - blocks across.
|
||||||
* @param gridHeight - blocks down.
|
* @param gridHeight - blocks down.
|
||||||
* @param rng - the level's random stream, for the variant rotation.
|
* @param rng - the level's random stream, for the variant rotation.
|
||||||
* @param stats - report collector.
|
* @param stats - report collector.
|
||||||
|
* @param levelId - level id for perimeter opening rules.
|
||||||
* @returns the number of pieces stamped.
|
* @returns the number of pieces stamped.
|
||||||
*/
|
*/
|
||||||
function layBorder(
|
function layBorder(
|
||||||
|
|
@ -751,6 +846,7 @@ function layBorder(
|
||||||
gridHeight: number,
|
gridHeight: number,
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
stats: WildernessStats,
|
stats: WildernessStats,
|
||||||
|
levelId: number,
|
||||||
): number {
|
): number {
|
||||||
if (pieces.length === 0) {
|
if (pieces.length === 0) {
|
||||||
stats.unresolved.push('no border piece for this level type')
|
stats.unresolved.push('no border piece for this level type')
|
||||||
|
|
@ -793,10 +889,12 @@ function layBorder(
|
||||||
{ x: 0, y: gridHeight - 1 }, // SW
|
{ x: 0, y: gridHeight - 1 }, // SW
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight)
|
||||||
|
|
||||||
let cursor = rng.int(0, ordered.length - 1)
|
let cursor = rng.int(0, ordered.length - 1)
|
||||||
let stamped = 0
|
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)
|
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)
|
// North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries)
|
||||||
const preferCliff =
|
const preferCliff =
|
||||||
|
|
@ -813,8 +911,8 @@ function layBorder(
|
||||||
}
|
}
|
||||||
if (!piece || piece.levels.length === 0) return
|
if (!piece || piece.levels.length === 0) return
|
||||||
|
|
||||||
const variant = rng.int(0, piece.levels.length - 1)
|
const isOpen = !isCorner && openings.has(`${String(cell.x)},${String(cell.y)}`)
|
||||||
const level = piece.levels[variant % piece.levels.length]
|
const level = selectBorderVariant(piece, orientation, isOpen, rng)
|
||||||
if (level === undefined) return
|
if (level === undefined) return
|
||||||
|
|
||||||
const maxOriginX = Math.max(canvas.width - level.width, 0)
|
const maxOriginX = Math.max(canvas.width - level.width, 0)
|
||||||
|
|
@ -832,8 +930,8 @@ function layBorder(
|
||||||
stamped += 1
|
stamped += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const cell of straightCells) stampCell(cell)
|
for (const cell of straightCells) stampCell(cell, false)
|
||||||
for (const cell of cornerCells) stampCell(cell)
|
for (const cell of cornerCells) stampCell(cell, true)
|
||||||
|
|
||||||
return stamped
|
return stamped
|
||||||
}
|
}
|
||||||
|
|
@ -989,8 +1087,12 @@ function applySubstitutions(
|
||||||
*/
|
*/
|
||||||
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
|
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
|
||||||
2: [
|
2: [
|
||||||
'Act 1 - Pond',
|
'Act 1 - River Upper',
|
||||||
|
'Act 1 - River Lower',
|
||||||
|
'Act 1 - Bridge',
|
||||||
'Act 1 - Cottages 1',
|
'Act 1 - Cottages 1',
|
||||||
|
'Act 1 - Corral Fill',
|
||||||
|
'Act 1 - Fence Fill 4',
|
||||||
'Act 1 - Stone Fill 1',
|
'Act 1 - Stone Fill 1',
|
||||||
'Act 1 - Stone Fill 2',
|
'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 2',
|
||||||
'Act 1 - Cottages 3',
|
'Act 1 - Cottages 3',
|
||||||
'Act 1 - Fallen Camp Bishibosh',
|
'Act 1 - Fallen Camp Bishibosh',
|
||||||
|
'Act 1 - Corral Fill',
|
||||||
|
'Act 1 - Fence Fill 4',
|
||||||
'Act 1 - Stone Fill 1',
|
'Act 1 - Stone Fill 1',
|
||||||
'Act 1 - Stone Fill 2',
|
'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 - Cottages 3',
|
||||||
'Act 1 - Fallen Camp 1',
|
'Act 1 - Fallen Camp 1',
|
||||||
'Act 1 - Corral Fill',
|
'Act 1 - Corral Fill',
|
||||||
|
'Act 1 - Fence Fill 4',
|
||||||
],
|
],
|
||||||
5: [
|
5: [
|
||||||
'Act 1 - Inifus',
|
'Act 1 - Inifus',
|
||||||
|
|
@ -1034,14 +1139,18 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
|
||||||
'Act 1 - Cottages 2',
|
'Act 1 - Cottages 2',
|
||||||
'Act 1 - Fallen Camp 2',
|
'Act 1 - Fallen Camp 2',
|
||||||
'Act 1 - Corral Fill',
|
'Act 1 - Corral Fill',
|
||||||
|
'Act 1 - Fence Fill 4',
|
||||||
],
|
],
|
||||||
17: [
|
17: [
|
||||||
'Act 1 - Graveyard',
|
'Act 1 - Graveyard',
|
||||||
],
|
],
|
||||||
39: [
|
39: [
|
||||||
'Act 1 - Bivouac',
|
'Act 1 - Bivouac',
|
||||||
'Act 1 - Pond',
|
|
||||||
'Act 1 - Corral Fill',
|
'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 1',
|
||||||
'Act 1 - Swamp Fill 2',
|
'Act 1 - Swamp Fill 2',
|
||||||
'Act 1 - Stone Fill 1',
|
'Act 1 - Stone Fill 1',
|
||||||
|
|
@ -1314,9 +1423,36 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
|
|
||||||
const borderPieces = request.pieces.filter(piece => piece.border)
|
const borderPieces = request.pieces.filter(piece => piece.border)
|
||||||
const fillPieces = 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 placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats)
|
||||||
|
|
||||||
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
|
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue