import { promises as fs, createWriteStream } from 'node:fs' import path from 'node:path' import archiver from 'archiver' import extract from 'extract-zip' import { resolveResourcePackFormat, MIN_SUPPORTED_FORMAT, LATEST_KNOWN_FORMAT } from './packFormat.js' import { loadComponentI18n } from '../shared/i18n.js' const { t } = loadComponentI18n('installer-rp') const NAMESPACE = 'musicquiz' export interface BuildResourcepackOptions { /** ogg 음악 파일들이 들어 있는 폴더 (01.ogg, 02.ogg, …). */ musicDir: string /** cover_NN.png 파일들이 들어 있는 폴더. */ paintingDir: string /** pack.mcmeta 의 description 에 들어갈 표시 이름. */ packName: string /** /manifest/.json 의 mcVersion. pack_format 결정용. */ mcVersion: string /** 작업 폴더(임시). 이 안에 트리를 펼친 뒤 zip 생성. */ workDir: string /** 최종 zip 출력 경로. */ outZipPath: string /** * 베이스 리소스팩 zip 경로 (선택). 지정하면 이 zip 의 내용을 먼저 풀고 * 그 위에 음악·사진·sounds.json·pack.mcmeta 를 덮어/병합한다. */ baseZipPath?: string /** 진단용 로그 콜백 (선택). */ log?: (line: string) => void } /** * 임시 폴더에 리소스팩 트리를 펼치고, archiver 로 zip 으로 묶어 outZipPath 에 저장. * * 트리 구조: * pack.mcmeta * assets/musicquiz/sounds.json * assets/musicquiz/sounds/track_NN.ogg ← musicDir/NN.ogg 에서 옮김 * assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김 */ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise { const root = path.join(opts.workDir, 'resourcepack') // 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다. await fs.rm(root, { recursive: true, force: true }) await fs.mkdir(root, { recursive: true }) // 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다. if (opts.baseZipPath) { opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) })) await extract(opts.baseZipPath, { dir: root }) } const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds') const paintingOutDir = path.join(root, 'assets', NAMESPACE, 'textures', 'painting') await fs.mkdir(soundsDir, { recursive: true }) await fs.mkdir(paintingOutDir, { recursive: true }) // 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니). const resolved = resolveResourcePackFormat(opts.mcVersion) if (resolved.matched) { opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched })) } else { opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion })) } // 호환 범위는 1.21.6 (=MIN_SUPPORTED_FORMAT) 부터 알려진 최신까지 선언한다. // 빌드 타깃이 LATEST_KNOWN_FORMAT 보다 높으면(테이블 갱신 전 신버전) 그 값까지 확장. // (셰이더 제거 판정에도 maxFmt 를 쓰므로 mcmeta 작성보다 먼저 계산해 둔다.) const minFmt = Math.min(MIN_SUPPORTED_FORMAT, resolved.format) const maxFmt = Math.max(LATEST_KNOWN_FORMAT, resolved.format) // 1-a) 선언 호환 범위의 max 가 64 를 넘으면(=1.21.9+ 클라이언트에서도 로드 가능) // 구버전 베이스팩의 assets/minecraft/shaders/* 가 새 GLSL API 와 충돌해 컴파일에 // 실패한다. 결과적으로 "리소스 새로고침 실패" 가 다시 뜨므로, 이 경우엔 해당 // 디렉터리를 결과 zip 에서 제거한다. 텍스처/모델 등 나머지 자산은 그대로 유지. if (opts.baseZipPath && maxFmt > 64) { const vanillaShaderDir = path.join(root, 'assets', 'minecraft', 'shaders') try { const stat = await fs.stat(vanillaShaderDir) if (stat.isDirectory()) { const entries = await fs.readdir(vanillaShaderDir) if (entries.length > 0) { await fs.rm(vanillaShaderDir, { recursive: true, force: true }) opts.log?.(t('log.baseShaderOverrideStripped', { path: entries.join(', '), mc: opts.mcVersion, format: maxFmt })) } } } catch { // 없으면 정상. 무시. } } // pack_format <= 64 인 MC 는 supported_formats 를, > 64 인 MC 는 min_format/max_format 을 // 읽는다. 어느 한쪽만 두면 반대편 클라이언트에서 거부되므로 양쪽 모두 기록한다. const packMeta: Record = { description: t('pack.description', { name: opts.packName }), pack_format: resolved.format, supported_formats: { min_inclusive: minFmt, max_inclusive: maxFmt }, min_format: minFmt, max_format: maxFmt } const mcmeta = { pack: packMeta } await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n') opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt })) // 2) 음악 파일 복사 + sounds.json 생성/병합 const musicFiles = (await fs.readdir(opts.musicDir)) .filter((n) => n.toLowerCase().endsWith('.ogg')) .sort() // 베이스의 sounds.json 이 있으면 읽어서 우리 트랙을 덧붙인다. const soundsJsonPath = path.join(root, 'assets', NAMESPACE, 'sounds.json') let soundsJson: Record = {} try { const existing = await fs.readFile(soundsJsonPath, 'utf8') const parsed = JSON.parse(existing) if (parsed && typeof parsed === 'object') { soundsJson = parsed as Record opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length })) } } catch { // 없으면 새로 생성. } for (const fname of musicFiles) { // NN.ogg → track_NN.ogg 로 리네임해 패키지. const stem = path.basename(fname, path.extname(fname)) // "01" const trackId = `track_${stem}` await fs.copyFile(path.join(opts.musicDir, fname), path.join(soundsDir, `${trackId}.ogg`)) soundsJson[trackId] = { sounds: [ { name: `${NAMESPACE}:${trackId}`, stream: true } ] } } await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n') // 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀. const paintingFiles = (await fs.readdir(opts.paintingDir)) .filter((n) => n.toLowerCase().endsWith('.png')) .sort() for (const fname of paintingFiles) { await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname)) } // 4) zip 으로 묶기 await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true }) await zipDirectory(root, opts.outZipPath) // 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요. } function zipDirectory(srcDir: string, outZipPath: string): Promise { return new Promise((resolve, reject) => { const output = createWriteStream(outZipPath) const archive = archiver('zip', { zlib: { level: 9 } }) output.on('close', () => resolve()) output.on('error', reject) archive.on('warning', (err: Error & { code?: string }) => { // ENOENT 정도면 무시, 그 외는 reject. if (err.code === 'ENOENT') return reject(err) }) archive.on('error', reject) archive.pipe(output) archive.directory(srcDir, false) archive.finalize().catch(reject) }) }