diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 1ca34eb..0c49650 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -520,6 +520,9 @@ const WILDERNESS_PIECE_FAMILIES: Readonly> = { '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', diff --git a/scripts/publish-packs.ts b/scripts/publish-packs.ts index 3afa99e..8ae8fc6 100644 --- a/scripts/publish-packs.ts +++ b/scripts/publish-packs.ts @@ -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 }) diff --git a/scripts/verify-generators.ts b/scripts/verify-generators.ts index 9dae4ce..e1876c0 100644 --- a/scripts/verify-generators.ts +++ b/scripts/verify-generators.ts @@ -400,6 +400,9 @@ const WILDERNESS_PIECE_FAMILIES: Readonly> = { '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') } diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index ce714e2..72450f2 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -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 { + const openings = new Set() + 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> = { 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 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))) diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts new file mode 100644 index 0000000..9e4e2d3 --- /dev/null +++ b/tests/wilderness-features.test.ts @@ -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) + }) +})