diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 282547c..6fc204f 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -530,6 +530,8 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu const WILDERNESS_PIECE_FAMILIES: Readonly> = { '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> = { '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'], } diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 0bff905..eb08a8f 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -1189,6 +1189,70 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly { + 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() + 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 { + 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() + 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 { + const piece = pieces.find(p => p.name === 'Act 3 - Bridge') + const ds1 = piece?.levels[0] + const placed = new Set() + 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 { + const placed = new Set() + 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 + 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 } diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts index 93ba3ee..d26236c 100644 --- a/tests/wilderness-features.test.ts +++ b/tests/wilderness-features.test.ts @@ -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) + }) })