feat(catalog): store furniture bundles as .hab instead of .nitro
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Every bundle the CMS writes — upload, clone, sync, repair and the pet / effect / figure importers — now lands as `<classname>.hab`, the extension this deployment's renderer asks for. `.hab` and `.nitro` are the same container, so an upload of either extension is accepted. Resolution goes through one module, src/lib/furni/bundle-file.ts, so nothing has to know the extension twice. Every existence check probes `.hab` first and falls back to `.nitro`: the on-disk asset set is still predominantly `.nitro`, and without the fallback Studio would report every imported item as missing and the cleanup scan would classify 18k live bundles as fake leftovers. Downloads are unchanged — Habbo's CDN and every configured clone source still serve `.nitro`, so the conversion happens on write, not on request. Deliberately unchanged: the staged-attachment store in furni-attachment.ts keys on a UUID and never reaches the client, so renaming it would break in-flight recovery jobs. Adds scripts/migrate-nitro-to-hab.ts to rename the existing asset set. It refuses to run without --dry-run or --yes, never overwrites an existing .hab, never deletes, and is idempotent. Note: renderer-config.json lives outside this repo and was patched to .hab separately; that file is served with a 30-day max-age, so returning clients need a cms-client cache purge to pick the change up.
This commit is contained in:
1 parent
fdb7af7ef5
commit
adffac7360
46 files changed
+1001
-210
No files matched your search
@@ -4,7 +4,14 @@ import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { and, eq, type SQL, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db";
|
||||
import { isSafeAssetName } from "@/lib/furni/classname";
|
||||
import {
|
||||
bundleExistsInDir,
|
||||
bundleFileNames,
|
||||
bundleWritePathInDir,
|
||||
resolveBundleInDir,
|
||||
stripBundleExt,
|
||||
} from "@/lib/furni/bundle-file";
|
||||
import { isSafeAssetName, LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
|
||||
import { offerPurchasabilityProblems } from "@/lib/furni/offer-purchasability";
|
||||
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -449,8 +456,10 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
"bundled/furniture",
|
||||
);
|
||||
if (nitroDir !== nitroTarget) {
|
||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||
entry.endsWith(".nitro"),
|
||||
const copied = await syncDirMissingFiles(
|
||||
nitroDir,
|
||||
nitroTarget,
|
||||
(entry) => stripBundleExt(entry) !== null,
|
||||
);
|
||||
copiedNitros.push(...copied);
|
||||
}
|
||||
@@ -721,10 +730,17 @@ export async function importSingleFurni(params: {
|
||||
/*turbopackIgnore: true*/ swfDir,
|
||||
`${safeSwfName}.swf`,
|
||||
);
|
||||
const nitroPath = getRuntimePath(
|
||||
// Imports write `.hab`; a pre-existing legacy `.nitro` for the same
|
||||
// classname still counts as "already present" so a colour variant sharing a
|
||||
// base classname never overwrites a bundle that is already on disk.
|
||||
const nitroPath = bundleWritePathInDir(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${safeNitroName}.nitro`,
|
||||
safeNitroName,
|
||||
);
|
||||
const nitroExists = () =>
|
||||
bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
|
||||
const resolveNitro = () =>
|
||||
resolveBundleInDir(/*turbopackIgnore: true*/ nitroDir, safeNitroName);
|
||||
const iconPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
`${iconFileName}_icon.png`,
|
||||
@@ -733,7 +749,7 @@ export async function importSingleFurni(params: {
|
||||
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir);
|
||||
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir);
|
||||
|
||||
if (params.providedNitro && !existsSync(nitroPath)) {
|
||||
if (params.providedNitro && !nitroExists()) {
|
||||
await fs.writeFile(
|
||||
nitroPath,
|
||||
await toWebpLosslessOrKeep(params.providedNitro, warnings),
|
||||
@@ -801,9 +817,9 @@ export async function importSingleFurni(params: {
|
||||
// disk — this happens for colour variants that share the base classname's
|
||||
// nitro file. The SWF is only used for Nitro conversion (not needed) and
|
||||
// icon extraction (the nitro bundle is used as a fallback for that below).
|
||||
existsSync(/*turbopackIgnore: true*/ nitroPath) ||
|
||||
nitroExists() ||
|
||||
params.providedNitro ||
|
||||
(params.preserveAssets && existsSync(nitroPath))
|
||||
(params.preserveAssets && nitroExists())
|
||||
? Promise.resolve(false)
|
||||
: (preservingExisting || params.preserveAssets) && existsSync(swfPath)
|
||||
? Promise.resolve(true)
|
||||
@@ -817,11 +833,7 @@ export async function importSingleFurni(params: {
|
||||
// ── Nitro bundle fallback: try downloading pre-made .nitro ────────
|
||||
// Only relevant when the SWF download failed, so it runs after SWF.
|
||||
let nitroDownloadOk = false;
|
||||
if (
|
||||
!swfOk &&
|
||||
sourceNitroBaseUrl &&
|
||||
!existsSync(/*turbopackIgnore: true*/ nitroPath)
|
||||
) {
|
||||
if (!swfOk && sourceNitroBaseUrl && !nitroExists()) {
|
||||
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
|
||||
if (dl.ok) nitroDownloadOk = true;
|
||||
@@ -858,8 +870,8 @@ export async function importSingleFurni(params: {
|
||||
!swfOk &&
|
||||
!nitroDownloadOk &&
|
||||
!params.providedNitro &&
|
||||
!existsSync(/*turbopackIgnore: true*/ nitroPath) &&
|
||||
!(params.preserveAssets && existsSync(nitroPath))
|
||||
!nitroExists() &&
|
||||
!(params.preserveAssets && nitroExists())
|
||||
)
|
||||
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
|
||||
|
||||
@@ -867,8 +879,8 @@ export async function importSingleFurni(params: {
|
||||
await onProgress?.("converting");
|
||||
let conversionResult: ConversionResult | null = null;
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
warnings.push("Nitro file already exists (shared across color variants)");
|
||||
if (nitroExists()) {
|
||||
warnings.push("Bundle file already exists (shared across color variants)");
|
||||
} else {
|
||||
try {
|
||||
if (existsSync(/*turbopackIgnore: true*/ swfPath)) {
|
||||
@@ -897,7 +909,7 @@ export async function importSingleFurni(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(nitroPath) && params.resolveMissingNitro) {
|
||||
if (!nitroExists() && params.resolveMissingNitro) {
|
||||
await onProgress?.("checking_sources");
|
||||
const recovered = await params.resolveMissingNitro(rev);
|
||||
if (recovered)
|
||||
@@ -912,9 +924,11 @@ export async function importSingleFurni(params: {
|
||||
);
|
||||
}
|
||||
|
||||
if (!iconOk && existsSync(nitroPath)) {
|
||||
if (!iconOk && nitroExists()) {
|
||||
try {
|
||||
const icon = extractFurniIconPng(await fs.readFile(nitroPath));
|
||||
const icon = extractFurniIconPng(
|
||||
await fs.readFile(resolveNitro() ?? nitroPath),
|
||||
);
|
||||
if (icon) {
|
||||
await fs.writeFile(iconPath, icon);
|
||||
iconOk = true;
|
||||
@@ -929,7 +943,7 @@ export async function importSingleFurni(params: {
|
||||
// Also validate that the .nitro file is non-trivial (≥ 128 bytes — a bare
|
||||
// Nitro bundle header is larger than that, anything smaller is corrupt/empty).
|
||||
const nitroStat = await fs
|
||||
.stat(/*turbopackIgnore: true*/ nitroPath)
|
||||
.stat(resolveNitro() ?? nitroPath)
|
||||
.catch(() => null);
|
||||
const nitroValid = nitroStat && nitroStat.size >= 128;
|
||||
|
||||
@@ -939,7 +953,7 @@ export async function importSingleFurni(params: {
|
||||
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
|
||||
);
|
||||
try {
|
||||
await fs.unlink(nitroPath);
|
||||
await fs.unlink(resolveNitro() ?? nitroPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -1003,8 +1017,8 @@ export async function importSingleFurni(params: {
|
||||
warnings,
|
||||
),
|
||||
mirrorFileToDirs(
|
||||
nitroPath,
|
||||
`${safeNitroName}.nitro`,
|
||||
resolveNitro() ?? nitroPath,
|
||||
`${safeNitroName}${LOCAL_BUNDLE_EXT}`,
|
||||
mirrorNitroDirs,
|
||||
warnings,
|
||||
),
|
||||
@@ -1077,11 +1091,11 @@ export async function importSingleFurni(params: {
|
||||
let nitroJsonForModes: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const parsed = parseNitroBundle(
|
||||
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
||||
await fs.readFile(resolveNitro() ?? nitroPath),
|
||||
);
|
||||
nitroJsonForModes = parsed.json as Record<string, unknown>;
|
||||
} catch {
|
||||
// no .nitro JSON available — keyword fallback only
|
||||
// no bundle JSON available — keyword fallback only
|
||||
}
|
||||
const autoInteraction = autoDetectInteraction(classname, bestName, {
|
||||
...realFlags,
|
||||
@@ -1257,17 +1271,15 @@ async function realNitroModes(classname: string): Promise<number | undefined> {
|
||||
const cached = nitroModesCache.get(base);
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const nitroPath = getRuntimePath(
|
||||
const nitroPath = resolveBundleInDir(
|
||||
/*turbopackIgnore: true*/ FURNI_NITRO_DIR,
|
||||
`${base}.nitro`,
|
||||
base,
|
||||
);
|
||||
if (!existsSync(nitroPath)) {
|
||||
if (!nitroPath) {
|
||||
nitroModesCache.set(base, undefined);
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseNitroBundle(
|
||||
await fs.readFile(/*turbopackIgnore: true*/ nitroPath),
|
||||
);
|
||||
const parsed = parseNitroBundle(await fs.readFile(nitroPath));
|
||||
const modes = nitroAnimationStatesCount(
|
||||
parsed.json as Record<string, unknown>,
|
||||
);
|
||||
@@ -1481,11 +1493,13 @@ export async function deleteImportedItem(classname: string): Promise<{
|
||||
errors.push(`FurnitureData.json: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 4. Delete files (.nitro, .swf, icon)
|
||||
// 4. Delete files (bundle — `.hab` and any legacy `.nitro` — plus .swf, icon)
|
||||
const filesToDelete = [
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
...[nitroDir, ...assetTargets.mirrorDirs.map((d) => d.nitroDir)].flatMap(
|
||||
(dir) =>
|
||||
bundleFileNames(baseClassname).map((name) =>
|
||||
getRuntimePath(/*turbopackIgnore: true*/ dir, name),
|
||||
),
|
||||
),
|
||||
getRuntimePath(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`),
|
||||
getRuntimePath(
|
||||
@@ -1493,10 +1507,6 @@ export async function deleteImportedItem(classname: string): Promise<{
|
||||
`${iconFileName}_icon.png`,
|
||||
),
|
||||
...assetTargets.mirrorDirs.flatMap((dirs) => [
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ dirs.nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
),
|
||||
getRuntimePath(
|
||||
/*turbopackIgnore: true*/ dirs.swfDir,
|
||||
`${baseClassname}.swf`,
|
||||
@@ -1541,12 +1551,8 @@ export async function cleanupConvertedSwfs(): Promise<{
|
||||
|
||||
for (const swfFile of swfFiles) {
|
||||
const cn = swfFile.replace(".swf", "");
|
||||
const nitroFilePath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${cn}.nitro`,
|
||||
);
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) {
|
||||
if (bundleExistsInDir(/*turbopackIgnore: true*/ nitroDir, cn)) {
|
||||
try {
|
||||
await fs.unlink(
|
||||
getRuntimePath(/*turbopackIgnore: true*/ swfDir, swfFile),
|
||||
|
||||
Reference in new issue
Block a user