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
@@ -0,0 +1,110 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
bundleExistsInDir,
|
||||
bundleExistsInDirs,
|
||||
bundleFileNames,
|
||||
bundleWritePathInDir,
|
||||
existingBundlePaths,
|
||||
resolveBundleFileNameInDir,
|
||||
resolveBundleInDir,
|
||||
resolveBundleInDirs,
|
||||
stripBundleExt,
|
||||
} from "./bundle-file";
|
||||
|
||||
let dir: string;
|
||||
let mirror: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "hab-bundle-"));
|
||||
mirror = await fs.mkdtemp(path.join(os.tmpdir(), "hab-bundle-mirror-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
await fs.rm(mirror, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("bundleFileNames", () => {
|
||||
it("prefers `.hab` and keeps `.nitro` as the legacy fallback", () => {
|
||||
expect(bundleFileNames("sofa_blue")).toEqual([
|
||||
"sofa_blue.hab",
|
||||
"sofa_blue.nitro",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripBundleExt", () => {
|
||||
it("strips either extension", () => {
|
||||
expect(stripBundleExt("sofa_blue.hab")).toBe("sofa_blue");
|
||||
expect(stripBundleExt("sofa_blue.nitro")).toBe("sofa_blue");
|
||||
expect(stripBundleExt("SOFA_BLUE.HAB")).toBe("SOFA_BLUE");
|
||||
});
|
||||
it("rejects anything else", () => {
|
||||
expect(stripBundleExt("sofa_blue.png")).toBeNull();
|
||||
expect(stripBundleExt("sofa_blue")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolution", () => {
|
||||
it("finds a `.hab` bundle", async () => {
|
||||
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "x");
|
||||
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(true);
|
||||
expect(resolveBundleInDir(dir, "sofa_blue")).toBe(
|
||||
path.join(dir, "sofa_blue.hab"),
|
||||
);
|
||||
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe("sofa_blue.hab");
|
||||
});
|
||||
|
||||
// The whole point of the fallback: an import must not report an item as
|
||||
// missing just because its bundle predates the `.hab` convention.
|
||||
it("still finds a legacy `.nitro` bundle", async () => {
|
||||
await fs.writeFile(path.join(dir, "sofa_blue.nitro"), "x");
|
||||
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(true);
|
||||
expect(resolveBundleInDir(dir, "sofa_blue")).toBe(
|
||||
path.join(dir, "sofa_blue.nitro"),
|
||||
);
|
||||
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe(
|
||||
"sofa_blue.nitro",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a missing bundle", async () => {
|
||||
expect(bundleExistsInDir(dir, "sofa_blue")).toBe(false);
|
||||
expect(resolveBundleInDir(dir, "sofa_blue")).toBeNull();
|
||||
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBeNull();
|
||||
});
|
||||
|
||||
it("searches mirrors when the primary has nothing", async () => {
|
||||
await fs.writeFile(path.join(mirror, "sofa_blue.nitro"), "x");
|
||||
expect(bundleExistsInDirs([dir, mirror], "sofa_blue")).toBe(true);
|
||||
expect(resolveBundleInDirs([dir, mirror], "sofa_blue")).toBe(
|
||||
path.join(mirror, "sofa_blue.nitro"),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers `.hab` over a same-stem legacy file", async () => {
|
||||
await fs.writeFile(path.join(dir, "sofa_blue.nitro"), "old");
|
||||
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "new");
|
||||
expect(resolveBundleFileNameInDir(dir, "sofa_blue")).toBe("sofa_blue.hab");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writing", () => {
|
||||
it("always writes `.hab`", () => {
|
||||
expect(bundleWritePathInDir("/assets", "sofa_blue")).toBe(
|
||||
path.join("/assets", "sofa_blue.hab"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists every existing variant so a delete can never leave one behind", async () => {
|
||||
await fs.writeFile(path.join(dir, "sofa_blue.hab"), "x");
|
||||
await fs.writeFile(path.join(mirror, "sofa_blue.nitro"), "x");
|
||||
expect(existingBundlePaths([dir, mirror], "sofa_blue")).toEqual([
|
||||
path.join(dir, "sofa_blue.hab"),
|
||||
path.join(mirror, "sofa_blue.nitro"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname";
|
||||
|
||||
/**
|
||||
* Local furniture-bundle lookups.
|
||||
*
|
||||
* Bundles are written as `<classname>.hab`, but a large body of pre-existing
|
||||
* assets is still `.nitro`, so every existence check has to consider both. These
|
||||
* helpers are the single place that knows that, which keeps "is this item's
|
||||
* asset present?" from silently flipping to `false` for legacy files and the
|
||||
* nitro-cleanup scan from classifying them as fake leftovers.
|
||||
*
|
||||
* Remote URLs deliberately do NOT go through here: sources still serve `.nitro`.
|
||||
*/
|
||||
|
||||
export function bundleFileNames(base: string): string[] {
|
||||
return LOCAL_BUNDLE_EXTS.map((ext) => `${base}${ext}`);
|
||||
}
|
||||
|
||||
/** The classname stem of a bundle file name, or null for any other extension. */
|
||||
export function stripBundleExt(fileName: string): string | null {
|
||||
for (const ext of LOCAL_BUNDLE_EXTS) {
|
||||
if (fileName.toLowerCase().endsWith(ext)) {
|
||||
return fileName.slice(0, -ext.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Path of `base`'s bundle inside `dir`, or null when neither extension is there. */
|
||||
export function resolveBundleInDir(dir: string, base: string): string | null {
|
||||
for (const fileName of bundleFileNames(base)) {
|
||||
const candidate = path.join(dir, fileName);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** File name of `base`'s bundle inside `dir`, preferring `.hab`. */
|
||||
export function resolveBundleFileNameInDir(
|
||||
dir: string,
|
||||
base: string,
|
||||
): string | null {
|
||||
for (const fileName of bundleFileNames(base)) {
|
||||
if (existsSync(path.join(dir, fileName))) return fileName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function bundleExistsInDir(dir: string, base: string): boolean {
|
||||
return resolveBundleInDir(dir, base) !== null;
|
||||
}
|
||||
|
||||
/** True when the bundle exists in `dir` or in any mirror dir. */
|
||||
export function bundleExistsInDirs(dirs: string[], base: string): boolean {
|
||||
return dirs.some((dir) => bundleExistsInDir(dir, base));
|
||||
}
|
||||
|
||||
/** First hit across `dirs`, primary first — callers pass the primary dir first. */
|
||||
export function resolveBundleInDirs(
|
||||
dirs: string[],
|
||||
base: string,
|
||||
): string | null {
|
||||
for (const dir of dirs) {
|
||||
const found = resolveBundleInDir(dir, base);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a newly imported bundle should be written to. Colliding with an
|
||||
* existing `.nitro` is reported so the caller can decide — writing `.hab`
|
||||
* beside a live `.nitro` would leave two competing bundles.
|
||||
*/
|
||||
export function bundleWritePathInDir(dir: string, base: string): string {
|
||||
return path.join(dir, `${base}${LOCAL_BUNDLE_EXT}`);
|
||||
}
|
||||
|
||||
/** Every existing bundle file for `base`, across all dirs — for cleanup/deletes. */
|
||||
export function existingBundlePaths(dirs: string[], base: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
for (const fileName of bundleFileNames(base)) {
|
||||
const candidate = path.join(dir, fileName);
|
||||
if (existsSync(candidate)) out.push(candidate);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBaseClassname,
|
||||
getBundleFileName,
|
||||
getHabboCdnIconUrl,
|
||||
getIconFileName,
|
||||
getLocalBundleFileNames,
|
||||
getLocalIconUrl,
|
||||
getNitroFileName,
|
||||
getSafeClassnameStem,
|
||||
getSwfFileName,
|
||||
isValidClassname,
|
||||
normalizeClassname,
|
||||
SOURCE_BUNDLE_EXT,
|
||||
} from "./classname";
|
||||
|
||||
describe("getBaseClassname", () => {
|
||||
@@ -36,8 +39,19 @@ describe("file name helpers", () => {
|
||||
it("builds swf file names from the base classname", () => {
|
||||
expect(getSwfFileName("sofa_blue*")).toBe("sofa_blue.swf");
|
||||
});
|
||||
it("builds nitro file names from the base classname", () => {
|
||||
expect(getNitroFileName("sofa_blue*")).toBe("sofa_blue.nitro");
|
||||
it("builds the primary (`.hab`) bundle name from the base classname", () => {
|
||||
expect(getNitroFileName("sofa_blue*")).toBe("sofa_blue.hab");
|
||||
});
|
||||
it("offers `.hab` first and keeps legacy `.nitro` resolvable", () => {
|
||||
expect(getLocalBundleFileNames("sofa_blue*")).toEqual([
|
||||
"sofa_blue.hab",
|
||||
"sofa_blue.nitro",
|
||||
]);
|
||||
});
|
||||
it("builds a bundle name for an explicit extension", () => {
|
||||
expect(getBundleFileName("sofa_blue*", SOURCE_BUNDLE_EXT)).toBe(
|
||||
"sofa_blue.nitro",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,8 +17,39 @@ export function getSwfFileName(classname: string): string {
|
||||
return `${getBaseClassname(classname)}.swf`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension locally stored furniture bundles are written with. This deployment's
|
||||
* Nitro client requests `<classname>.hab`, so that is what new imports produce.
|
||||
*/
|
||||
export const LOCAL_BUNDLE_EXT = ".hab";
|
||||
|
||||
/**
|
||||
* Extension remote sources serve bundles with. Upstream (Habbo's CDN and every
|
||||
* configured clone source) keeps shipping `.nitro`, so download URLs are built
|
||||
* with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk.
|
||||
*/
|
||||
export const SOURCE_BUNDLE_EXT = ".nitro";
|
||||
|
||||
/** Both extensions a local bundle may carry, newest convention first. */
|
||||
export const LOCAL_BUNDLE_EXTS = [LOCAL_BUNDLE_EXT, SOURCE_BUNDLE_EXT] as const;
|
||||
|
||||
/** Primary local bundle name — `.hab` wins over a legacy `.nitro`. */
|
||||
export function getNitroFileName(classname: string): string {
|
||||
return `${getBaseClassname(classname)}.nitro`;
|
||||
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
|
||||
}
|
||||
|
||||
/** Every local file name this classname's bundle may be stored under. */
|
||||
export function getLocalBundleFileNames(classname: string): string[] {
|
||||
const base = getBaseClassname(classname);
|
||||
return LOCAL_BUNDLE_EXTS.map((ext) => `${base}${ext}`);
|
||||
}
|
||||
|
||||
/** File name for an explicit extension — use `SOURCE_BUNDLE_EXT` for URLs. */
|
||||
export function getBundleFileName(
|
||||
classname: string,
|
||||
ext: string = LOCAL_BUNDLE_EXT,
|
||||
): string {
|
||||
return `${getBaseClassname(classname)}${ext}`;
|
||||
}
|
||||
|
||||
export function normalizeClassname(raw: string): string {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { bundleExistsInDirs } from "@/lib/furni/bundle-file";
|
||||
import { getFigureDataPath, readFigureData } from "@/lib/services/figuredata";
|
||||
import {
|
||||
getFigureMapPath,
|
||||
@@ -151,10 +152,10 @@ function existsIn(dirs: string[], fileName: string): boolean {
|
||||
return dirs.some((dir) => existsSync(path.join(dir, fileName)));
|
||||
}
|
||||
|
||||
/** True when a `.nitro` bundle for this classname exists in any asset dir. */
|
||||
/** True when a bundle (`.hab`, or legacy `.nitro`) exists in any asset dir. */
|
||||
export async function hasNitroBundle(classname: string): Promise<boolean> {
|
||||
const { nitroDirs } = await assetDirs();
|
||||
return existsIn(nitroDirs, `${classname}.nitro`);
|
||||
return bundleExistsInDirs(nitroDirs, classname);
|
||||
}
|
||||
|
||||
/** True when a furniture icon for this classname exists in any asset dir. */
|
||||
|
||||
Reference in new issue
Block a user