/** * 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 })) { 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 与第三方参考源码都不在本仓库内。 ` } const remote = process.argv[2] ?? process.env['D2_ASSETS_REMOTE'] ?? DEFAULT_REMOTE const stats = measure() const stamp = new Date().toISOString().slice(0, 10) const work = mkdtempSync(join(tmpdir(), 'd2-assets-')) try { console.log(`图集包:${String(stats.maps)} 块地图 / ${String(stats.files)} 个文件 / ${(stats.bytes / 1048576).toFixed(1)} MB`) console.log(`index.json sha256:${stats.indexSha}`) console.log(`临时仓库:${work}`) git(['init', '-q', '-b', 'main'], work) cpSync(PACKS_DIR, work, { recursive: true }) writeFileSync(join(work, 'README.md'), readme(stats, stamp)) writeFileSync(join(work, '.gitattributes'), '*.png binary -diff\n*.json text eol=lf\n') 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 生成。`, ], work) console.log(`推送到 ${remote}(force,覆盖历史)…`) git(['push', '--force', remote, 'main'], work) console.log('完成。使用方:git clone ' + remote + ' samples/d2-packs') } finally { rmSync(work, { recursive: true, force: true }) }