/** * Publish `samples/d2-packs` to the asset repository. * * The baked atlas packs are ~66 MB of indexed PNG. PNG is already deflate * compressed, so git's delta compression buys almost nothing: every re-bake changes * nearly every file, and a normal commit would grow the repository by another ~60 MB * each time (measured: the code repository went 63 MB → 117 MB over two bakes). * * So the asset repository deliberately keeps **exactly one commit**: every publish * creates an orphan history (a brand new repository in a temp directory), commits the * current packs, and force-pushes it over the remote. The repository therefore stays * at "one snapshot" (~66 MB) no matter how often the packs are re-baked, and the code * repository — which no longer contains the packs — clones in a couple of megabytes. * * Usage: * ``` * node scripts/publish-packs.ts # default remote * node scripts/publish-packs.ts # explicit remote * ``` * * The remote needs credentials; either pass a URL with them, or run * `git config --global credential.helper store` once so `git push` does not prompt. * Nothing here writes a password to disk. * * @module */ import { execFileSync } from 'node:child_process' import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' /** Where the packs live inside the code repository. */ const PACKS_DIR = resolve(import.meta.dirname, '..', 'samples', 'd2-packs') /** The asset repository, without credentials. */ const DEFAULT_REMOTE = 'https://git.projectdiablo2.cn/troytt/diablo2-web-assets.git' /** The code repository, linked from the generated README. */ const CODE_REPO = 'https://git.projectdiablo2.cn/troytt/diablo2-web' /** The archive mirror served next to the deployment. */ const ARCHIVE_BASE = 'https://www.laiseek.xyz/diablo2/assets' /** Raised when the packs are missing or git fails. */ class PublishError extends Error {} /** * Run one git command in `cwd`, inheriting stdio so progress is visible. * * @param args - git arguments. * @param cwd - directory to run in. */ function git(args: readonly string[], cwd: string): void { try { execFileSync('git', args, { cwd, stdio: ['ignore', 'inherit', 'inherit'] }) } catch { throw new PublishError(`git ${args.join(' ')} failed`) } } /** * Collect the statistics that go into the generated README. * * @returns file count, total bytes, map-block count and the `index.json` hash. */ function measure(): { files: number; bytes: number; maps: number; indexSha: string } { const indexPath = join(PACKS_DIR, 'index.json') if (!existsSync(indexPath)) { throw new PublishError(`no packs at ${PACKS_DIR} — run: npm run pack:data`) } const index = JSON.parse(readFileSync(indexPath, 'utf8')) as { entries?: unknown[]; levels?: unknown[] } const indexSha = createHash('sha256').update(readFileSync(indexPath)).digest('hex') let files = 0 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 { files += 1 bytes += statSync(path).size } } } walk(PACKS_DIR) return { files, bytes, maps: index.levels?.length ?? index.entries?.length ?? 0, indexSha } } /** * Build the README that ships inside the asset repository. * * @param stats - output of {@link measure}. * @param stamp - `YYYY-MM-DD` bake date. * @returns the markdown text. */ function readme(stats: ReturnType, stamp: string): string { const mb = (stats.bytes / 1048576).toFixed(1) return `# d2web 烘焙图集包(${stamp}) 这是 [\`troytt/diablo2-web\`](${CODE_REPO}) 的**烘焙产物**:把原版暗黑破坏神 II(1.13c)的地图 解包成 web 原生格式——索引色 PNG 图集页 + \`scene.json\`(画家序绘制列表、碰撞 RLE、出生点、 对象元数据)+ \`index.json\`(含各 act 调色板)。 - 地图块 **${stats.maps} 个**(覆盖全 5 幕 136 个关卡预置与随机变体),文件 **${stats.files} 个**,合计 **${mb} MB** - \`index.json\` sha256:\`${stats.indexSha}\` - 生成时间:${stamp} ## 本仓库为什么只有一个提交 图集页是索引色 PNG(deflate 已压缩),git 的 delta 压缩对它几乎无收益:每次重烘几乎全部文件 都会变。若按普通方式提交,每烘焙一次仓库就再长 ~60 MB(实测代码仓库因此从 63 MB 涨到 117 MB)。 所以这里刻意**只保留一个提交**:每次重烘用 orphan 提交 + force push 覆盖,仓库体积恒定在 「一份快照」≈${Math.round(stats.bytes / 1048576)} MB。历史没有价值——需要旧版请从代码仓库 对应提交重新烘焙。 ## 怎么用 \`\`\`bash # 放进代码仓库的 samples/d2-packs/,页面默认就吃这个目录 git clone ${DEFAULT_REMOTE} samples/d2-packs \`\`\` 只要一份压缩包(不需要 git): \`\`\`bash curl -O ${ARCHIVE_BASE}/d2-packs-${stamp}.tar.xz tar xJf d2-packs-${stamp}.tar.xz \`\`\` ## 怎么重新生成并发布 在代码仓库里(需要自备原版 MPQ,放在 \`samples/d2/\`): \`\`\`bash npm run pack:data # scripts/pack-act-assets.ts → samples/d2-packs(约 2 分钟) npm run verify:packs # 与"现读现解"逐项比对:绘制序、碰撞栅格逐字节、每帧像素哈希 npm run publish:packs # orphan 提交 + force push 覆盖本仓库 \`\`\` ## 许可 内容为原版游戏美术的解码产物,仅供**持有正版游戏数据**的人在本项目内使用;请勿再分发。 原版 MPQ 与第三方参考源码都不在本仓库内。 ` } /* ------------------------------------------------------------------------- * * Batch planning * ------------------------------------------------------------------------- */ /** * Target size of one push batch, in bytes. * * The asset remote sits behind a high-loss cross-border link: measured 13.7% * TCP retransmission, MSS clamped to 324 bytes by a middlebox, and a congestion * window pinned at 3–4 segments, which caps a single connection at roughly * 20 KB/s. A 100 MB push therefore needs ~1.5 h of uninterrupted connection and * loses everything on the first reset. Batching at ~10 MB keeps each push to a * few minutes and makes a retry cheap. */ const MAX_BATCH_BYTES = 10 * 1024 * 1024 /** How many times to retry one push before giving up. */ const PUSH_ATTEMPTS = 5 /** The order acts are published in, smallest first so the remote is usable early. */ const ACT_ORDER = ['act4', 'act3', 'act5', 'act2', 'act1'] as const /** One push batch: a set of map directories inside one act. */ interface Batch { readonly act: string /** Paths relative to {@link PACKS_DIR}, e.g. `act1/1-act-1-town-townn1`. */ readonly paths: readonly string[] readonly bytes: number } /** * Total size of a directory tree, in bytes. * * @param dir - absolute path. * @returns bytes, `.git` excluded. */ function dirSize(dir: string): number { let bytes = 0 for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === '.git') continue const path = join(dir, entry.name) if (entry.isDirectory()) bytes += dirSize(path) else bytes += statSync(path).size } return bytes } /** * Split every act into batches of at most {@link MAX_BATCH_BYTES}. * * Map directories are never split, so a single oversized map becomes a batch of * its own. The order within an act is the directory listing order, sorted, so * the plan is stable across runs — which is what makes resuming safe. * * @returns the batches, act by act in {@link ACT_ORDER}. */ function planBatches(): Batch[] { const batches: Batch[] = [] for (const act of ACT_ORDER) { const actDir = join(PACKS_DIR, act) if (!existsSync(actDir)) continue const maps = readdirSync(actDir, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => entry.name) .sort() let current: string[] = [] let currentBytes = 0 const flush = (): void => { if (current.length === 0) return batches.push({ act, paths: current, bytes: currentBytes }) current = [] currentBytes = 0 } for (const map of maps) { const bytes = dirSize(join(actDir, map)) if (currentBytes > 0 && currentBytes + bytes > MAX_BATCH_BYTES) flush() current.push(`${act}/${map}`) currentBytes += bytes } // Loose files directly under the act directory ride along in the last batch. const loose = readdirSync(actDir, { withFileTypes: true }) .filter(entry => !entry.isDirectory()) .map(entry => `${act}/${entry.name}`) for (const file of loose) current.push(file) flush() } return batches } /* ------------------------------------------------------------------------- * * Git helpers that tolerate a lossy link * ------------------------------------------------------------------------- */ /** * Run one git command and capture stdout instead of inheriting it. * * @param args - git arguments. * @param cwd - directory to run in. * @returns trimmed stdout, or null when the command failed. */ function gitQuery(args: readonly string[], cwd: string): string | null { try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null } } /** * Push `main`, retrying with linear backoff. * * `http.lowSpeedLimit`/`http.lowSpeedTime` are what stop a dead connection from * hanging for hours: git aborts once throughput stays under 1 KB/s for two * minutes, and the retry opens a fresh connection — which on this link usually * comes back with a healthy congestion window. * * @param remoteUrl - the push target. * @param cwd - the work repository. * @param label - what is being pushed, for the log. * @throws when every attempt fails. */ function pushWithRetry(remoteUrl: string, cwd: string, label: string): void { for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt += 1) { try { execFileSync('git', ['push', remoteUrl, 'main'], { cwd, stdio: ['ignore', 'inherit', 'inherit'] }) return } catch { if (attempt === PUSH_ATTEMPTS) { throw new PublishError(`推送「${label}」失败:已重试 ${String(PUSH_ATTEMPTS)} 次`) } const waitSeconds = attempt * 15 console.log(` ↻ 推送「${label}」第 ${String(attempt)} 次失败,${String(waitSeconds)} 秒后重试…`) execFileSync('sleep', [String(waitSeconds)], { stdio: 'ignore' }) } } } /** * Is `path` already committed in the work repository's `HEAD`? * * @param path - path relative to the repository root. * @param cwd - the work repository. * @returns true when `HEAD` already carries it. */ function committed(path: string, cwd: string): boolean { const listed = gitQuery(['ls-tree', '--name-only', 'HEAD', path], cwd) return listed !== null && listed !== '' } /* ------------------------------------------------------------------------- * * Driver * ------------------------------------------------------------------------- */ const args = process.argv.slice(2) const workFlagIndex = args.indexOf('--work') const reuseWork = workFlagIndex >= 0 ? args[workFlagIndex + 1] : undefined const positional = args.filter((value, index) => value !== '--work' && index !== workFlagIndex + 1) const remote = positional[0] ?? process.env['D2_ASSETS_REMOTE'] ?? DEFAULT_REMOTE const stats = measure() const stamp = new Date().toISOString().slice(0, 10) const keepWork = reuseWork !== undefined const work = reuseWork ?? mkdtempSync(join(tmpdir(), 'd2-assets-')) try { const batches = planBatches() const totalBytes = batches.reduce((sum, batch) => sum + batch.bytes, 0) console.log(`图集包:${String(stats.maps)} 块地图 / ${String(stats.files)} 个文件 / ${(stats.bytes / 1048576).toFixed(1)} MB`) console.log(`index.json sha256:${stats.indexSha}`) console.log(`临时仓库:${work}${keepWork ? '(复用,断点续传)' : ''}`) console.log(`推送计划:${String(batches.length)} 个批次,单批上限 ${String(MAX_BATCH_BYTES / 1048576)} MB,合计 ${(totalBytes / 1048576).toFixed(1)} MB`) if (!keepWork) git(['init', '-q', '-b', 'main'], work) // A 500 MB post buffer keeps git from chunking the upload; HTTP/1.1 avoids the // remote's HTTP/2 stream resets; the low-speed guard turns a stalled // connection into a fast failure that `pushWithRetry` can recover from. git(['config', 'http.postBuffer', '524288000'], work) git(['config', 'http.version', 'HTTP/1.1'], work) git(['config', 'http.lowSpeedLimit', '1000'], work) git(['config', 'http.lowSpeedTime', '120'], work) // PNG atlas pages are already deflate compressed, so delta search is pure cost. git(['config', 'pack.window', '0'], work) git(['config', 'core.compression', '1'], work) // Resume: rewind the work tree to whatever the remote already has, so a // half-finished oversized commit from an earlier run is discarded rather than // re-uploaded. if (keepWork) { const remoteSha = gitQuery(['ls-remote', remote, 'main'], work)?.split(/\s+/)[0] if (remoteSha !== undefined && gitQuery(['cat-file', '-e', `${remoteSha}^{commit}`], work) !== null) { console.log(`远端 main 位于 ${remoteSha.slice(0, 7)},回卷工作区以对齐…`) git(['reset', '--hard', '-q', remoteSha], work) git(['clean', '-qfd'], work) } } // Step 0: metadata and index. if (!committed('index.json', work)) { 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} [0]:索引与元数据\n\nindex.json sha256: ${stats.indexSha}\n\n由 troytt/diablo2-web 的 npm run pack:data 生成。`, ], work) console.log(`推送到 ${remote}(force,覆盖历史,初始化基线)…`) git(['push', '--force', remote, 'main'], work) } else { console.log('索引与元数据已在远端,跳过。') } // Steps 1..N: one small batch per push. let sentBytes = 0 for (let i = 0; i < batches.length; i += 1) { const batch = batches[i]! const label = `${String(i + 1)}/${String(batches.length)} ${batch.act} (${(batch.bytes / 1048576).toFixed(1)} MB)` sentBytes += batch.bytes if (batch.paths.every(path => committed(path, work))) { console.log(`[${label}] 已在远端,跳过。`) continue } for (const path of batch.paths) { cpSync(join(PACKS_DIR, path), join(work, path), { recursive: true }) } git(['add', ...batch.paths], 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(i + 1)}/${String(batches.length)}]:${batch.act} ${String(batch.paths.length)} 项`, ], work) console.log(`[${label}] 推送中…(累计 ${(sentBytes / 1048576).toFixed(1)}/${(totalBytes / 1048576).toFixed(1)} MB)`) pushWithRetry(remote, work, label) } console.log('完成。使用方:git clone ' + remote + ' samples/d2-packs') } finally { if (!keepWork) rmSync(work, { recursive: true, force: true }) else console.log(`工作区保留在 ${work}(下次可用 --work ${work} 续传)`) }