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 /** * 사용자 취소 신호. true 가 되면 가능한 시점에 build 를 중단한다. * - 단계 사이 (extract → meta → 음악 복사 → painting 복사 → zip) 폴링. * - zip 생성 중에도 폴링해서 archive.abort() 로 끊는다. * 호출자는 후속 처리에서 임시 폴더와 부분 zip 파일을 정리해야 한다. */ cancelChecker?: () => boolean } /** cancelChecker 가 true 를 반환하면 던지는 에러. main 쪽 에러 처리와 동일한 메시지를 쓰지 않고, * 명시적인 클래스 마커로 식별하기 쉽게 한다. 메시지는 i18n 의 errors.cancelledByUser 와 1:1. */ class CancelledError extends Error { constructor() { super(t('errors.cancelledByUser')) this.name = 'CancelledError' } } function throwIfCancelled(checker?: () => boolean): void { if (checker && checker()) throw new CancelledError() } /** * 임시 폴더에 리소스팩 트리를 펼치고, 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 cancel = opts.cancelChecker throwIfCancelled(cancel) 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 }) throwIfCancelled(cancel) } 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 생성/병합 // 핵심 정책: 베이스 리소스팩에 이미 있는 자산은 절대 덮어쓰지 않는다. // - 베이스 sounds.json 의 엔트리는 그대로 보존하고, 우리 트랙은 그 위에 "추가" 만 한다. // - 베이스 sounds/track_NN.ogg 가 이미 있으면 덮어쓰지 않고 건너뛴다. // - 키나 파일명이 충돌하면 우리 트랙을 스킵하고 로그로 알린다. 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) { throwIfCancelled(cancel) // NN.ogg → track_NN.ogg 로 리네임해 패키지. const stem = path.basename(fname, path.extname(fname)) // "01" const trackId = `track_${stem}` const destFile = path.join(soundsDir, `${trackId}.ogg`) // 베이스에 같은 trackId 의 엔트리/파일이 있으면 두 선택지 다 깨진다: // (a) 덮어쓰면 베이스의 기존 곡이 사라지고, // (b) 새 곡을 스킵하면 데이터팩이 가리키는 곡이 빠진 채로 설치된다. // 안전하게 설치를 즉시 실패시키고 어떤 키가 충돌했는지 알린다. let collides = soundsJson[trackId] !== undefined if (!collides) { try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ } } if (collides) { throw new Error(t('errors.baseTrackCollision', { trackId })) } await fs.copyFile(path.join(opts.musicDir, fname), destFile) soundsJson[trackId] = { sounds: [ { name: `${NAMESPACE}:${trackId}`, stream: true } ] } } await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n') opts.log?.(t('log.tracksAdded', { count: musicFiles.length })) throwIfCancelled(cancel) // 3) painting 텍스처 복사 (이미 cover_NN.png 형태). // 음악과 동일한 정책: 베이스에 같은 파일명이 이미 있으면 설치를 실패시킨다. const paintingFiles = (await fs.readdir(opts.paintingDir)) .filter((n) => n.toLowerCase().endsWith('.png')) .sort() for (const fname of paintingFiles) { throwIfCancelled(cancel) const destFile = path.join(paintingOutDir, fname) let collides = false try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ } if (collides) { throw new Error(t('errors.basePaintingCollision', { name: fname })) } await fs.copyFile(path.join(opts.paintingDir, fname), destFile) } opts.log?.(t('log.paintingsAdded', { count: paintingFiles.length })) throwIfCancelled(cancel) // 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다. await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true }) await zipDirectory(root, opts.outZipPath, cancel) // zip 빌드가 끝난 직후에도 한 번 더 확인: 마지막 순간 취소가 들어왔을 수 있다. if (cancel && cancel()) { // 부분 zip 파일이 디스크에 남아있을 수 있으니 삭제. await fs.rm(opts.outZipPath, { force: true }) throw new CancelledError() } // 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요. } function zipDirectory(srcDir: string, outZipPath: string, cancelChecker?: () => boolean): Promise { return new Promise((resolve, reject) => { const output = createWriteStream(outZipPath) const archive = archiver('zip', { zlib: { level: 9 } }) // 취소 폴링: archiver 자체는 abort() 후 'error' 이벤트로 ABORT 코드를 던진다. // 200ms 간격이면 사용자 체감으로는 즉각적이면서 CPU 부담은 없다. let interval: NodeJS.Timeout | null = null let aborted = false if (cancelChecker) { interval = setInterval(() => { if (cancelChecker() && !aborted) { aborted = true try { archive.abort() } catch { /* 이미 끝났거나 abort 불가 상태 */ } } }, 200) } function cleanup() { if (interval) { clearInterval(interval); interval = null } } output.on('close', () => { cleanup(); if (aborted) reject(new CancelledError()); else resolve() }) output.on('error', (err) => { cleanup(); reject(err) }) archive.on('warning', (err: Error & { code?: string }) => { // ENOENT 정도면 무시, 그 외는 reject. if (err.code === 'ENOENT') return cleanup(); reject(err) }) archive.on('error', (err: Error & { code?: string }) => { cleanup() if (err.code === 'ABORT' || aborted) reject(new CancelledError()) else reject(err) }) archive.pipe(output) archive.directory(srcDir, false) archive.finalize().catch((err) => { cleanup(); reject(err) }) }) }