diablo2-web/scripts/publish-packs.ts

409 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 <git-remote-url> # 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<typeof measure>, 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 = 25 * 1024 * 1024
/** How many times to retry one push before giving up. */
const PUSH_ATTEMPTS = 5
/** The order pack directories are published in, smallest first so the remote is usable early. */
const PACK_CATEGORIES = [
'anim',
'missiles',
'overlays',
'act4',
'act3',
'act5',
'act2',
'act1',
'entities',
] as const
/** One push batch: a set of relative paths inside one top-level pack directory. */
interface Batch {
readonly act: string
/** Paths relative to {@link PACKS_DIR}, e.g. `act1/1-act-1-town-townn1` or `entities/char-so.r8`. */
readonly paths: readonly string[]
readonly bytes: number
}
/**
* Total size of a file or directory tree, in bytes.
*
* @param target - absolute path.
* @returns bytes, `.git` excluded.
*/
function entrySize(target: string): number {
const st = statSync(target)
if (!st.isDirectory()) return st.size
let bytes = 0
for (const entry of readdirSync(target, { withFileTypes: true })) {
if (entry.name === '.git') continue
bytes += entrySize(join(target, entry.name))
}
return bytes
}
/**
* Split every top-level pack directory into batches of at most {@link MAX_BATCH_BYTES}.
*
* Direct child entries (map directories, overlay subdirectories, or individual asset files)
* are never split, so a single oversized entry becomes a batch of its own. The order within
* a category is the sorted directory listing order, so the plan is stable across runs —
* which is what makes resuming safe.
*
* @returns the batches, category by category in {@link PACK_CATEGORIES}.
*/
function planBatches(): Batch[] {
const batches: Batch[] = []
for (const category of PACK_CATEGORIES) {
const categoryDir = join(PACKS_DIR, category)
if (!existsSync(categoryDir)) continue
const entries = readdirSync(categoryDir, { withFileTypes: true })
.filter(entry => entry.name !== '.git')
.map(entry => entry.name)
.sort()
let current: string[] = []
let currentBytes = 0
const flush = (): void => {
if (current.length === 0) return
batches.push({ act: category, paths: current, bytes: currentBytes })
current = []
currentBytes = 0
}
for (const name of entries) {
const bytes = entrySize(join(categoryDir, name))
if (currentBytes > 0 && currentBytes + bytes > MAX_BATCH_BYTES) flush()
current.push(`${category}/${name}`)
currentBytes += bytes
}
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*.r8 binary -diff\n*.json text eol=lf\n')
cpSync(join(PACKS_DIR, 'index.json'), join(work, 'index.json'))
if (existsSync(join(PACKS_DIR, 'world-graph.json'))) {
cpSync(join(PACKS_DIR, 'world-graph.json'), join(work, 'world-graph.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} 续传)`)
}