diff --git a/HANDOVER.md b/HANDOVER.md index 68dcb7d..cf92fab 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -42,9 +42,13 @@ npm run net-server # 等价于 node scripts/net-server.ts 8787 ```bash npm run pack:data # scripts/pack-act-assets.ts → samples/d2-packs(62 个地图块 / 35 关,约 67 MB) -# 图集包不在 Git 里(每次重烘会让仓库再长 ~60 MB,PNG 无法 delta 压缩)。发布归档: -# https://www.laiseek.xyz/diablo2/assets/d2-packs-<日期>.tar.xz (nginx → /var/www/d2assets/) -# Gitea 的 Release 附件在本实例(124.221.104.39)是坏的:1KB..59.5MB 全部 HTTP 500 +# 图集包不在代码仓库里(每次重烘会让仓库再长 ~60 MB,PNG 无法 delta 压缩)。发布方式: +# 1) 图库仓库 troytt/diablo2-web-assets:npm run publish:packs +# → 每次 orphan 提交 + force push,仓库恒定"一份快照"(~66 MB),代码仓库 clone 仅 ~2.7 MB +# 2) 归档镜像(可选):https://www.laiseek.xyz/diablo2/assets/d2-packs-<日期>.tar.xz +# (nginx location /diablo2/assets/ → /var/www/d2assets/) +# 注意:force push 后旧提交对象要服务端 git gc 才回收;Gitea 的 [cron.git_gc_repos] 默认关闭, +# 而 Gitea 的 Release 附件在本实例(124.221.104.39)是坏的:1KB..59.5MB 全部 HTTP 500。 npm run verify:packs # 与现读现解逐项比对:绘制序/坐标、碰撞栅格逐字节、每帧像素 FNV 哈希(992/992) npm run verify:listfile # 用社区 1.13c listfile 逐归档判定"这个成员到底存不存在" npm run verify:deploy # 线上 /diablo2/ 页面 + 资源包 + Range 206 + 旧入口 410(17/17) diff --git a/README.md b/README.md index d10cd93..b3cf512 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,16 @@ scripts/ 资源包烘焙、格式/数据/部署校验、无头浏览器检 ### 2. 资源包从哪来(不在 Git 里) -图集包(62 个地图块 / 35 关,索引色 PNG + `scene.json`)以归档形式发布,**不入 Git**: +图集包(62 个地图块 / 35 关,索引色 PNG + `scene.json`)**不在本仓库里**——它是 ~66 MB 的索引色 +PNG,每次重烘几乎全部文件都变,而 PNG 无法被 git 的 delta 压缩,随仓库分发会让每烘焙一次仓库再长 +~60 MB。它放在**图库仓库**,那里刻意**只保留一个提交**(每次重烘 orphan 提交 + force push), +体积恒定在「一份快照」: + +```bash +git clone https://git.projectdiablo2.cn/troytt/diablo2-web-assets.git samples/d2-packs +``` + +只想要一份压缩包(不需要 git): ```bash curl -O https://www.laiseek.xyz/diablo2/assets/d2-packs-2026-09-14.tar.xz @@ -54,8 +63,8 @@ sha256sum d2-packs-2026-09-14.tar.xz # 应为 9cdfd0c19ac68de842c6725070b65d05 tar xJf d2-packs-2026-09-14.tar.xz # 解出 samples/d2-packs/ ``` -同目录还有 `d2-packs-2026-09-14.sha256`。仓库里只保留 `.gitignore` 规则与本文档; -要自己生成包就照第 3 步 `npm run pack:data` 从原版 MPQ 烘焙。 +仓库里只保留 `.gitignore` 规则与本文档;要自己生成包就照第 3 步 `npm run pack:data` 从原版 MPQ +烘焙,再用 `npm run publish:packs` 发布到图库仓库(脚本会重建单提交历史)。 ### 3. 本地跑起来 diff --git a/package.json b/package.json index 8be1af4..6390ad4 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "verify:object-lookup": "node scripts/verify-object-lookup.ts", "port:object-lookup": "node scripts/port-object-lookup.ts", "verify:alignment": "node scripts/verify-tile-alignment.ts", - "verify:tiles": "node scripts/verify-tiles.ts" + "verify:tiles": "node scripts/verify-tiles.ts", + "publish:packs": "node scripts/publish-packs.ts" }, "devDependencies": { "@types/node": "^22.20.2", diff --git a/scripts/publish-packs.ts b/scripts/publish-packs.ts new file mode 100644 index 0000000..13e2279 --- /dev/null +++ b/scripts/publish-packs.ts @@ -0,0 +1,184 @@ +/** + * 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[] } + 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.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} 个**(35 个预置关卡,act1–act5),文件 **${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=d2web', '-c', 'user.email=d2web@localhost', + '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 }) +}