rp-pack: fail-fast on base track/painting collision (was: silent skip)

Reviewer correctly flagged that the previous skip-on-collision
behavior silently drops new quiz tracks when the base resourcepack
already has the same track_NN key. That makes the install LOOK
successful but breaks the quiz at runtime (datapack references the
missing track).

The new behavior throws a clear error explaining which key collided
and what the user must do (remove the conflicting base entry, or
use a different base). The base assets are still preserved (we
never overwrite); we just refuse to build a broken pack.

Removed the now-unused skip-summary log keys.
This commit is contained in:
2026-05-23 17:26:41 +09:00
parent 9efd4a696a
commit 5c13648f63
2 changed files with 12 additions and 27 deletions

View File

@@ -151,23 +151,22 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
} catch {
// 없으면 새로 생성.
}
let musicAdded = 0
const musicSkipped: string[] = []
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 의 엔트리/파일이 있으면 보존 (덮어쓰지 않음).
// 베이스에 같은 trackId 의 엔트리/파일이 있으면 두 선택지 다 깨진다:
// (a) 덮어쓰면 베이스의 기존 곡이 사라지고,
// (b) 새 곡을 스킵하면 데이터팩이 가리키는 곡이 빠진 채로 설치된다.
// 안전하게 설치를 즉시 실패시키고 어떤 키가 충돌했는지 알린다.
let collides = soundsJson[trackId] !== undefined
if (!collides) {
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
}
if (collides) {
musicSkipped.push(trackId)
opts.log?.(t('log.trackCollisionSkipped', { trackId }))
continue
throw new Error(t('errors.baseTrackCollision', { trackId }))
}
await fs.copyFile(path.join(opts.musicDir, fname), destFile)
soundsJson[trackId] = {
@@ -175,39 +174,27 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
{ name: `${NAMESPACE}:${trackId}`, stream: true }
]
}
musicAdded++
}
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
opts.log?.(t('log.tracksAdded', { count: musicAdded }))
if (musicSkipped.length > 0) {
opts.log?.(t('log.tracksCollisionSummary', { count: musicSkipped.length, list: musicSkipped.join(', ') }))
}
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()
let paintingAdded = 0
const paintingSkipped: string[] = []
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) {
paintingSkipped.push(fname)
opts.log?.(t('log.paintingCollisionSkipped', { name: fname }))
continue
throw new Error(t('errors.basePaintingCollision', { name: fname }))
}
await fs.copyFile(path.join(opts.paintingDir, fname), destFile)
paintingAdded++
}
opts.log?.(t('log.paintingsAdded', { count: paintingAdded }))
if (paintingSkipped.length > 0) {
opts.log?.(t('log.paintingsCollisionSummary', { count: paintingSkipped.length, list: paintingSkipped.join(', ') }))
}
opts.log?.(t('log.paintingsAdded', { count: paintingFiles.length }))
throwIfCancelled(cancel)
// 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.