fix(map-gen): fix Act 4 Chaos Sanctuary, Act 3 Jungles/Travincal/Kurast, Act 2 Deserts & Act 5 Frozen Tundra layouts

This commit is contained in:
troytt 2026-09-16 03:30:40 +00:00
parent 25ae7289ee
commit 602565030f
3 changed files with 318 additions and 10 deletions

View File

@ -530,6 +530,8 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild',
'Act 1 - DOE Entrance',
'Act 1 - Cave Entrance',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill',
'Act 1 - River',
@ -549,10 +551,10 @@ const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Graveyard',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Jungle': ['Act 3 - Jungle', 'Act 3 - Clearing'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo', 'Act 4 - Bridge'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}

View File

@ -1189,6 +1189,70 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
41: [
'Act 2 - Desert Tomb 1',
'Act 2 - Desert Fill Wagon 1',
'Act 2 - Desert Fill Bone 1',
'Act 2 - Desert Fill Bone 2',
'Act 2 - Desert Fill Berms 1',
'Act 2 - Desert Fill Berms 2',
],
42: [
'Act 2 - Desert Tomb 1',
'Act 2 - Desert Ruins 16X16',
'Act 2 - Desert Fill Mesa 1',
'Act 2 - Desert Fill Head 1',
'Act 2 - Desert Fill Head 2',
],
43: [
'Act 2 - Desert Oasis 2',
'Act 2 - Desert Oasis 3',
'Act 2 - Desert Oasis 1',
'Act 2 - Desert Lair 1',
'Act 2 - Desert Fill Bone 1',
'Act 2 - Desert Fill Berms 3',
],
44: [
'Act 2 - Desert Ruins Sewer',
'Act 2 - Desert Ruins Elder',
'Act 2 - Desert Ruins 16X16',
'Act 2 - Desert Ruins 16X08',
'Act 2 - Desert Ruins 08X16',
],
45: [
'Act 2 - Desert Tomb 2',
'Act 2 - Desert Valley Ruin 1',
'Act 2 - Desert Valley Ruin 2',
],
46: [
'Act 2 - Desert Valley Warp',
'Act 2 - Desert Cliff Top King Tomb',
'Act 2 - Desert Cliff Left King Tomb',
'Act 2 - Desert Cliff Right King Tomb',
'Act 2 - Desert Fill Mesa 1',
],
79: [
'Act 3 - Slums 16x16',
'Act 3 - Slums 16x16',
'Act 3 - Slums 16x08',
'Act 3 - Slums 08x16',
'Act 3 - Slums 08x08',
],
80: [
'Act 3 - Burbs Temple',
'Act 3 - Burbs Temple',
'Act 3 - Burbs Sewer',
'Act 3 - Burbs Waypoint',
'Act 3 - Burbs 16x16',
'Act 3 - Burbs 16x08',
],
81: [
'Act 3 - MetroTemple',
'Act 3 - MetroTemple',
'Act 3 - Metro Sewer',
'Act 3 - Metro 16x16',
'Act 3 - Metro 16x08',
],
111: [
'Act 5 - Barricade Waypoint Dirt',
'Act 5 - Barricade Hell Portal N',
@ -1217,6 +1281,15 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
'Act 5 - Barricade Ruins N Treasure',
'Act 5 - Barricade Ruins W Treasure',
],
117: [
'Act 5 - Barricade To Cave 32x16 Snow',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Waypoint Snow',
'Act 5 - Barricade 1 Snow',
'Act 5 - Barricade 2 Snow',
'Act 5 - Barricade 3 Snow',
'Act 5 - Barricade Snow Lake 1',
],
}
/**
@ -1420,6 +1493,139 @@ function stampAct5SiegeStrips(
return placed
}
/**
* Assemble the 7 cross-wing presets for Act 4 Chaos Sanctuary (LevelId 108, 120x120).
*/
function stampAct4ChaosSanctuary(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const placements: Array<{ name: string; x: number; y: number }> = [
{ name: 'Act 4 - Diablo Arm N', x: 48, y: 0 },
{ name: 'Act 4 - Diablo Arm W', x: 24, y: 24 },
{ name: 'Act 4 - Diablo Heart', x: 48, y: 24 },
{ name: 'Act 4 - Diablo Arm E', x: 72, y: 24 },
{ name: 'Act 4 - Diablo Arm S', x: 48, y: 48 },
{ name: 'Act 4 - Diablo Entry', x: 48, y: 72 },
{ name: 'Act 4 - Bridge 2', x: 48, y: 96 },
]
const placed = new Set<string>()
for (const item of placements) {
const piece = pieces.find(p => p.name.trim() === item.name.trim() || p.name.trim().startsWith(item.name.trim()))
const ds1 = piece?.levels[0]
if (!ds1) continue
stampDs1(canvas, ds1, item.x, item.y)
placed.add(item.name)
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* Assemble the 6 presets for Act 3 Travincal (LevelId 83, 64x64).
*/
function stampAct3Travincal(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const placements: Array<{ name: string; x: number; y: number }> = [
{ name: 'Act 3 - Travincal NW', x: 0, y: 0 },
{ name: 'Act 3 - Travincal N', x: 16, y: 0 },
{ name: 'Act 3 - Travincal NE', x: 48, y: 0 },
{ name: 'Act 3 - Travincal SW', x: 0, y: 32 },
{ name: 'Act 3 - Travincal S', x: 16, y: 32 },
{ name: 'Act 3 - Travincal SE', x: 48, y: 32 },
]
const placed = new Set<string>()
for (const item of placements) {
const piece = pieces.find(p => p.name.trim() === item.name.trim())
const ds1 = piece?.levels[0]
if (!ds1) continue
stampDs1(canvas, ds1, item.x, item.y)
placed.add(item.name)
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* Stamp the 48x16 bridge preset for Act 3 Kurast Causeway (LevelId 82).
*/
function stampAct3KurastCauseway(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const piece = pieces.find(p => p.name === 'Act 3 - Bridge')
const ds1 = piece?.levels[0]
const placed = new Set<string>()
if (ds1) {
stampDs1(canvas, ds1, 0, 0)
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* Assemble the 64x192 (2x6 blocks of 32x32) river and clearing layout for Act 3 Jungles (LevelId 76, 77, 78).
*/
function stampAct3Jungle(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
): Set<string> {
const placed = new Set<string>()
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
}
if (headPiece?.levels[0]) {
stampDs1(canvas, headPiece.levels[0], 0, 160)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
}
const clearingPrefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
const clearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
const riverPieces = pieces.filter(
p =>
p.name.startsWith('Act 3 - Jungle ') &&
p.name !== 'Act 3 - Jungle Head' &&
p.name !== 'Act 3 - Jungle Tail' &&
p.levels.length > 0,
)
for (let row = 0; row < 4; row += 1) {
const y = 32 + row * 32
const riverOnLeft = (row + rng.int(0, 1)) % 2 === 0
for (let col = 0; col < 2; col += 1) {
const x = col * 32
const isRiverSlot = (col === 0 && riverOnLeft) || (col === 1 && !riverOnLeft)
const pool = isRiverSlot || clearingPieces.length === 0 ? riverPieces : clearingPieces
if (pool.length === 0) continue
const chosen = pool[rng.int(0, pool.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
stampDs1(canvas, ds1, x, y)
placed.add(chosen.name)
stats.substitutions.push({ name: chosen.name, role: 'object', enabled: true, clusters: 1 })
}
}
return placed
}
/**
* Lay a 2-tile-wide meandering dirt road network connecting the South entrance, East exit,
* and interior landmarks in Act 1 wilderness levels (LevelId 2..7).
@ -1696,10 +1902,26 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
}
stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height)
// Special case: Act 5 Bloody Foothills (LevelId 110 / Act 5 - Siege 1) is assembled
// from 15 sequential 16x48 siege strips rather than perimeter borders + random scatter.
if (request.levelId === 110 || request.levelTypeName === 'Act 5 - Siege') {
const placedSiege = stampAct5SiegeStrips(canvas, request.pieces, stats)
// Special case: Act 5 Bloody Foothills (110), Act 4 Chaos Sanctuary (108),
// Act 3 Travincal (83), and Act 3 Jungles (76, 77, 78) are assembled from
// deterministic multi-preset layouts rather than rectangular perimeter borders.
if (
request.levelId === 110 ||
request.levelTypeName === 'Act 5 - Siege' ||
request.levelId === 108 ||
request.levelId === 83 ||
(request.levelId >= 76 && request.levelId <= 78)
) {
let placedSpecial: Set<string>
if (request.levelId === 108) {
placedSpecial = stampAct4ChaosSanctuary(canvas, request.pieces, stats)
} else if (request.levelId === 83) {
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats)
} else if (request.levelId >= 76 && request.levelId <= 78) {
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats)
} else {
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats)
}
let finalGroundCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
@ -1742,7 +1964,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
borderBlocks: 0,
borderUsage: {},
substitutions: stats.substitutions,
substitutedClusters: placedSiege.size,
substitutedClusters: placedSpecial.size,
objects: canvas.objects.length,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
@ -1752,11 +1974,20 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
}
}
// Filter pieces for Act 5 Barricade dirt (111) vs snow (112)
// Filter pieces for Act 3 Kurast districts (79 Slums, 80 Burbs, 81 Metro) and Act 5 dirt (111) vs snow (112, 117)
let levelPieces = request.pieces
if (request.levelId === 111) {
if (request.levelId === 79) {
const slumsOnly = request.pieces.filter(p => /slums/i.test(p.name))
if (slumsOnly.length > 0) levelPieces = slumsOnly
} else if (request.levelId === 80) {
const burbsOnly = request.pieces.filter(p => /burbs/i.test(p.name))
if (burbsOnly.length > 0) levelPieces = burbsOnly
} else if (request.levelId === 81) {
const metroOnly = request.pieces.filter(p => /metro/i.test(p.name))
if (metroOnly.length > 0) levelPieces = metroOnly
} else if (request.levelId === 111) {
levelPieces = request.pieces.filter(p => !/snow/i.test(p.name))
} else if (request.levelId === 112) {
} else if (request.levelId === 112 || request.levelId === 117) {
const snowOnly = request.pieces.filter(p => /snow/i.test(p.name) || /ruins/i.test(p.name))
if (snowOnly.length > 0) levelPieces = snowOnly
}

View File

@ -250,4 +250,79 @@ describe("Wilderness Features & Border Rules", () => {
expect(result.level.cells[0][0].floors[0].sequence).toBe(10)
expect(result.level.cells[0][224].floors[0].sequence).toBe(24)
})
test("Act 4 Chaos Sanctuary (Level 108) stamps 7 cross-wing presets on 120x120 canvas", () => {
const names = [
"Act 4 - Diablo Arm N",
"Act 4 - Diablo Arm W",
"Act 4 - Diablo Heart",
"Act 4 - Diablo Arm E",
"Act 4 - Diablo Arm S",
"Act 4 - Diablo Entry",
"Act 4 - Bridge 2",
]
const pieces: WildernessPiece[] = names.map((name, idx) => {
const baseDs1 = makeMockDs1(25, 25, false)
const ds1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 200 + idx, x: 5, y: 5, flags: 0 }],
}
return { name, border: false, levels: [ds1] }
})
const result = generateWilderness({
levelId: 108,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: 120,
sizeY: 120,
subType: -1,
subTheme: -1,
seed: 108,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(120)
expect(result.level.height).toBe(120)
expect(result.stats.substitutedClusters).toBe(7)
expect(result.level.objects.length).toBe(7)
})
test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => {
const names = [
"Act 3 - Travincal NW",
"Act 3 - Travincal N ", // trailing space as in LvlPrest.txt
"Act 3 - Travincal NE",
"Act 3 - Travincal SW",
"Act 3 - Travincal S ", // trailing space as in LvlPrest.txt
"Act 3 - Travincal SE",
]
const pieces: WildernessPiece[] = names.map((name, idx) => {
const baseDs1 = makeMockDs1(name.includes(" N ") || name.includes(" S ") ? 33 : 17, 33, false)
const ds1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 300 + idx, x: 5, y: 5, flags: 0 }],
}
return { name, border: false, levels: [ds1] }
})
const result = generateWilderness({
levelId: 83,
levelName: "Travincal",
levelTypeName: "Act 3 - Kurast",
sizeX: 64,
sizeY: 64,
subType: -1,
subTheme: -1,
seed: 83,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(64)
expect(result.level.height).toBe(64)
expect(result.stats.substitutedClusters).toBe(6)
expect(result.level.objects.length).toBe(6)
})
})