feat(housekeeping): integrate studio operations
CI / check (pull_request) Successful in 38s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-30 15:29:58 +02:00
1 parent 020c06f172
commit 2e95a106e0
36 files changed
+2296 -60

No files matched your search

+14 -3
View File
@@ -134,13 +134,16 @@ async function fetchWithFallback(url: string): Promise<string> {
export async function fetchSourceFurnidata(
url: string,
now = Date.now(),
signal?: AbortSignal,
): Promise<SourceFurni[]> {
signal?.throwIfAborted();
const hit = cache.get(url);
if (hit && now && now - hit.ts < TTL) return hit.list;
let body: string;
try {
const timeout = AbortSignal.timeout(30000);
const res = await fetch(url, {
signal: AbortSignal.timeout(30000),
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
headers: browserHeaders(),
});
if (res.ok) {
@@ -159,7 +162,8 @@ export async function fetchSourceFurnidata(
} else {
body = await fetchWithFallback(url);
}
} catch {
} catch (error) {
if (signal?.aborted) throw error;
body = await fetchWithFallback(url);
}
if (body.trimStart().startsWith("<")) {
@@ -243,11 +247,13 @@ export async function cloneSingleFurni(params: {
source: CloneSource;
entry: SourceFurni;
onProgress?: (status: string) => void;
signal?: AbortSignal;
/** Skip the per-item FurnitureData.json write and return the entry instead,
* so batch callers can append everything in one lock + write cycle. */
deferFurniData?: boolean;
}): Promise<CloneResult> {
const { source, entry, onProgress, deferFurniData } = params;
const { source, entry, onProgress, signal, deferFurniData } = params;
signal?.throwIfAborted();
const { classname, itemType } = entry;
const warnings: string[] = [];
@@ -287,6 +293,7 @@ export async function cloneSingleFurni(params: {
// Download .nitro + icon directly from the source hotel.
onProgress?.("downloading");
signal?.throwIfAborted();
const nitroPath = getRuntimePath(
/*turbopackIgnore: true*/ nitroDir,
`${classname}.nitro`,
@@ -300,6 +307,7 @@ export async function cloneSingleFurni(params: {
nitroPath,
{
maxRetries: 2,
signal,
},
);
if (!dl.ok) {
@@ -334,6 +342,7 @@ export async function cloneSingleFurni(params: {
{
maxRetries: 1,
validate: "png",
signal,
},
);
if (!iconDl.ok) {
@@ -366,6 +375,7 @@ export async function cloneSingleFurni(params: {
}
onProgress?.("writing_db");
signal?.throwIfAborted();
const stackHeight =
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
@@ -422,6 +432,7 @@ export async function cloneSingleFurni(params: {
// AND our offerid. The source's offerid points at the source hotel's sprite
// ids, which would silently break the emulator's catalog offer lookup.
onProgress?.("writing_furnidata");
signal?.throwIfAborted();
const furniDataEntry = {
...entry,
id: newId,
+10 -2
View File
@@ -89,8 +89,10 @@ export async function importClothingSet(params: {
setType: string;
setId: number;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportClothingSetResult> {
const { setType, setId, onProgress } = params;
const { setType, setId, onProgress, signal } = params;
signal?.throwIfAborted();
const warnings: string[] = [];
const [officialFd, officialLibs] = await Promise.all([
@@ -136,12 +138,17 @@ export async function importClothingSet(params: {
let hardFail = false;
for (const libId of requiredLibIds) {
signal?.throwIfAborted();
const officialLib = officialLibs.find((l) => l.id === libId);
if (!officialLib) continue;
if (!localLibIds.has(libId)) {
onProgress?.(`importing library ${libId}`);
const result = await importSingleFigure({ lib: libId, onProgress });
const result = await importSingleFigure({
lib: libId,
onProgress,
signal,
});
if (!result.ok) {
hardFail = true;
warnings.push(
@@ -169,6 +176,7 @@ export async function importClothingSet(params: {
}
const mappedSet = mapOfficialSet(set);
signal?.throwIfAborted();
await mergeSet(setType, st.paletteId, mappedSet as never);
if (palette) {
+9 -2
View File
@@ -76,8 +76,10 @@ export async function importSingleEffect(params: {
type: string;
revision: number;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportEffectResult> {
const { id, lib, type, revision, onProgress } = params;
const { id, lib, type, revision, onProgress, signal } = params;
signal?.throwIfAborted();
const warnings: string[] = [];
await ensureEffectDirs();
@@ -100,7 +102,10 @@ export async function importSingleEffect(params: {
error: `Could not resolve SWF URL: ${(err as Error).message}`,
};
}
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
const dl = await downloadFile(swfUrl, swfPath, {
validate: "swf",
signal,
});
if (!dl.ok)
return {
ok: false,
@@ -111,6 +116,7 @@ export async function importSingleEffect(params: {
// Convert SWF → Nitro
onProgress?.("converting");
signal?.throwIfAborted();
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
@@ -127,6 +133,7 @@ export async function importSingleEffect(params: {
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
onProgress?.("writing_map");
signal?.throwIfAborted();
try {
const entry: EffectMapEntry = { id, lib, type, revision };
await mergeEffect(entry);
+8 -2
View File
@@ -78,8 +78,10 @@ async function nitroPathFor(lib: string): Promise<string> {
export async function importSingleFigure(params: {
lib: string;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportFigureResult> {
const { lib, onProgress } = params;
const { lib, onProgress, signal } = params;
signal?.throwIfAborted();
const warnings: string[] = [];
await ensureFigureDirs();
@@ -99,7 +101,10 @@ export async function importSingleFigure(params: {
error: `Could not resolve SWF URL: ${(err as Error).message}`,
};
}
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
const dl = await downloadFile(swfUrl, swfPath, {
validate: "swf",
signal,
});
if (!dl.ok)
return {
ok: false,
@@ -109,6 +114,7 @@ export async function importSingleFigure(params: {
};
onProgress?.("converting");
signal?.throwIfAborted();
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
+18 -4
View File
@@ -403,10 +403,13 @@ async function syncDirMissingFiles(
* so all imported furniture stays renderable in-game even when a mirror
* write was skipped.
*/
export async function syncAssetsToGamedataBundle(): Promise<{
export async function syncAssetsToGamedataBundle(
signal?: AbortSignal,
): Promise<{
copiedNitros: string[];
copiedIcons: string[];
}> {
signal?.throwIfAborted();
if (process.platform === "win32")
return { copiedNitros: [], copiedIcons: [] };
@@ -423,6 +426,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{
"bundled/furniture",
);
if (nitroDir !== nitroTarget) {
signal?.throwIfAborted();
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
entry.endsWith(".nitro"),
);
@@ -434,6 +438,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{
"icons",
);
if (iconDir !== iconTarget) {
signal?.throwIfAborted();
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
entry.endsWith("_icon.png"),
);
@@ -508,6 +513,7 @@ export async function importSingleFurni(params: {
skipFurniDataWrite?: boolean;
updateExisting?: boolean;
onProgress?: (status: string) => void;
signal?: AbortSignal;
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
sourceSwfBaseUrl?: string;
/** Per-source nitro bundle + icon base URL. */
@@ -525,10 +531,12 @@ export async function importSingleFurni(params: {
skipFurniDataWrite,
updateExisting,
onProgress,
signal,
sourceSwfBaseUrl,
nitroBaseUrl: sourceNitroBaseUrl,
iconBaseUrl: sourceIconBaseUrl,
} = params;
signal?.throwIfAborted();
const warnings: string[] = [];
// Check if already exists by classname OR by spriteId (primary key collision).
@@ -604,6 +612,7 @@ export async function importSingleFurni(params: {
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db");
signal?.throwIfAborted();
try {
if (isUpdate) {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
@@ -660,6 +669,7 @@ export async function importSingleFurni(params: {
// fall back to base-classname icon (Nitro reuses base for all colors
// when no color-specific icon exists).
onProgress?.("downloading");
signal?.throwIfAborted();
const iconColorAware = classname.replace(/\*/g, "_");
@@ -701,8 +711,8 @@ export async function importSingleFurni(params: {
// Download icon + SWF concurrently (independent assets). Each tries its
// candidate URLs in order until one succeeds.
const [iconOkResult, swfOk] = await Promise.all([
tryDownloadCandidates(iconUrls, iconPath, "png"),
tryDownloadCandidates(swfUrls, swfPath, "swf"),
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
]);
let iconOk = iconOkResult;
@@ -715,7 +725,10 @@ export async function importSingleFurni(params: {
!existsSync(/*turbopackIgnore: true*/ nitroPath)
) {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" });
const dl = await downloadFile(nitroUrl, nitroPath, {
validate: "png",
signal,
});
if (dl.ok) nitroDownloadOk = true;
}
@@ -744,6 +757,7 @@ export async function importSingleFurni(params: {
// ── Convert SWF to Nitro ──────────────────────────────────────────
onProgress?.("converting");
signal?.throwIfAborted();
let conversionResult: ConversionResult | null = null;
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
+8 -1
View File
@@ -635,14 +635,21 @@ export interface FixAllResult {
/** Run every repair in a sensible order, then return before/after health. */
export async function fixEverything(
opts: { dedupePages?: boolean } = {},
opts: { dedupePages?: boolean; signal?: AbortSignal } = {},
): Promise<FixAllResult> {
opts.signal?.throwIfAborted();
const healthBefore = await getFurniHealth();
opts.signal?.throwIfAborted();
const sprite = await fixSpriteIds();
opts.signal?.throwIfAborted();
const offers = await fixCatalogOffers();
opts.signal?.throwIfAborted();
const reconcile = await reconcileIds();
opts.signal?.throwIfAborted();
const dedup = await removeDuplicates(opts.dedupePages ?? true);
opts.signal?.throwIfAborted();
const align = await forceItemsBaseIdsToFurnidata(true);
opts.signal?.throwIfAborted();
const healthAfter = await getFurniHealth();
return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter };
}
+22 -7
View File
@@ -17,7 +17,9 @@ export async function writeBadgeToExternalTexts(
code: string,
name: string,
description: string,
signal?: AbortSignal,
): Promise<ImportBadgeResult> {
signal?.throwIfAborted();
const gamedataRoot = await getGamedataRoot();
if (!gamedataRoot) {
return {
@@ -35,6 +37,7 @@ export async function writeBadgeToExternalTexts(
const raw = await fs.readFile(extPath, "utf-8");
texts = JSON.parse(raw) as Record<string, string>;
} catch (err) {
if (signal?.aborted) throw err;
// File not present yet (fresh deployment) — start with an empty object.
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
console.warn(
@@ -49,6 +52,7 @@ export async function writeBadgeToExternalTexts(
}
texts = {};
}
signal?.throwIfAborted();
if (texts[nameKey] !== undefined) {
return {
@@ -63,9 +67,11 @@ export async function writeBadgeToExternalTexts(
try {
await fs.mkdir(path.dirname(extPath), { recursive: true });
signal?.throwIfAborted();
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
return { ok: true };
} catch (err) {
if (signal?.aborted) throw err;
console.warn(
"[import-badges] Failed to update ExternalTexts.json:",
(err as Error).message,
@@ -83,7 +89,9 @@ export async function upsertWebsiteBadge(
code: string,
name: string,
description: string,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted();
const now = new Date();
await db
.insert(WebsiteBadges)
@@ -107,21 +115,28 @@ export async function upsertWebsiteBadge(
* Single import path: ExternalTexts.json + WebsiteBadges.
* Callers should gate with ASSETS_IMPORT.
*/
export async function importBadgeSynced(input: {
code: string;
name: string;
description: string;
}): Promise<ImportBadgeResult> {
export async function importBadgeSynced(
input: {
code: string;
name: string;
description: string;
},
signal?: AbortSignal,
): Promise<ImportBadgeResult> {
signal?.throwIfAborted();
const texts = await writeBadgeToExternalTexts(
input.code,
input.name,
input.description,
signal,
);
if (!texts.ok) return texts;
try {
await upsertWebsiteBadge(input.code, input.name, input.description);
} catch {
signal?.throwIfAborted();
await upsertWebsiteBadge(input.code, input.name, input.description, signal);
} catch (error) {
if (signal?.aborted) throw error;
return { ok: false, error: "Failed to import badge", status: 500 };
}
+14 -3
View File
@@ -22,9 +22,11 @@ export async function tryDownloadCandidates(
urls: string[],
destPath: string,
kind: "swf" | "png",
signal?: AbortSignal,
): Promise<boolean> {
for (const url of urls) {
const res = await downloadFile(url, destPath, { validate: kind });
signal?.throwIfAborted();
const res = await downloadFile(url, destPath, { validate: kind, signal });
if (res.ok) return true;
}
return false;
@@ -33,18 +35,26 @@ export async function tryDownloadCandidates(
export async function downloadFile(
url: string,
destPath: string,
options?: { maxRetries?: number; validate?: "swf" | "png" },
options?: {
maxRetries?: number;
validate?: "swf" | "png";
signal?: AbortSignal;
},
): Promise<{ ok: boolean; size: number }> {
const maxRetries = options?.maxRetries ?? 3;
const baseDelay = 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
options?.signal?.throwIfAborted();
if (attempt > 0) {
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
}
const timeout = AbortSignal.timeout(15000);
const res = await fetch(url, {
signal: AbortSignal.timeout(15000),
signal: options?.signal
? AbortSignal.any([options.signal, timeout])
: timeout,
headers: browserHeaders("image", {
Accept: "image/png,image/*,*/*;q=0.8",
}),
@@ -82,6 +92,7 @@ export async function downloadFile(
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
return { ok: true, size: buffer.length };
} catch (err) {
if (options?.signal?.aborted) throw err;
if (attempt === maxRetries) {
console.warn(
`[import-download] Download error ${url}:`,
+8 -2
View File
@@ -45,8 +45,10 @@ function nitroPathFor(lib: string): string {
export async function importSinglePet(params: {
lib: string;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportPetResult> {
const { lib, onProgress } = params;
const { lib, onProgress, signal } = params;
signal?.throwIfAborted();
const warnings: string[] = [];
await ensurePetDirs();
@@ -66,7 +68,10 @@ export async function importSinglePet(params: {
error: `Could not resolve SWF URL: ${(err as Error).message}`,
};
}
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
const dl = await downloadFile(swfUrl, swfPath, {
validate: "swf",
signal,
});
if (!dl.ok)
return {
ok: false,
@@ -76,6 +81,7 @@ export async function importSinglePet(params: {
};
onProgress?.("converting");
signal?.throwIfAborted();
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
+6 -2
View File
@@ -104,7 +104,9 @@ export interface RepairEvent {
export async function repairMissingIcons(
onEvent?: (evt: RepairEvent) => void,
options?: { signal?: AbortSignal },
): Promise<{ succeeded: number; failed: number; skipped: number }> {
options?.signal?.throwIfAborted();
let succeeded = 0;
let failed = 0;
let skipped = 0;
@@ -204,6 +206,7 @@ export async function repairMissingIcons(
onEvent?.({ type: "started", total });
const processItem = async (classname: string) => {
options?.signal?.throwIfAborted();
// Special items (badges, pets, effects, bots, sticky notes) don't have
// .png icon files to repair. Skip them.
if (dbSkipSet.has(classname)) {
@@ -312,7 +315,7 @@ export async function repairMissingIcons(
targets.iconDir,
fileName,
),
{ maxRetries: 1, validate: "png" },
{ maxRetries: 1, validate: "png", signal: options?.signal },
);
if (dl.ok) {
const icon = await fs.readFile(
@@ -350,7 +353,7 @@ export async function repairMissingIcons(
const dl = await downloadFile(
`${source.nitroBaseUrl}/${classname}.nitro`,
nitroTmp,
{ maxRetries: 1 },
{ maxRetries: 1, signal: options?.signal },
);
if (dl.ok) {
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
@@ -390,6 +393,7 @@ export async function repairMissingIcons(
};
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
options?.signal?.throwIfAborted();
const classname = item.item_name;
const result = await processItem(classname);
if (result.status === "skipped") {
+8 -2
View File
@@ -106,7 +106,9 @@ export interface RepairNitroEvent {
export async function repairMissingNitros(
onEvent?: (evt: RepairNitroEvent) => void,
options?: { signal?: AbortSignal },
): Promise<{ succeeded: number; failed: number; skipped: number }> {
options?.signal?.throwIfAborted();
let succeeded = 0;
let failed = 0;
let skipped = 0;
@@ -201,6 +203,7 @@ export async function repairMissingNitros(
onEvent?.({ type: "started", total });
const processItem = async (classname: string) => {
options?.signal?.throwIfAborted();
// Special items (badges, pets, effects, bots, sticky notes) don't have
// furni .nitro bundles to repair. Skip them.
if (skipSet.has(classname)) {
@@ -276,7 +279,7 @@ export async function repairMissingNitros(
const dl = await downloadFile(
`${source.nitroBaseUrl}/${nitroName}`,
tmpPath,
{ maxRetries: 1 },
{ maxRetries: 1, signal: options?.signal },
);
if (dl.ok) {
// Validate it is a real Nitro bundle before writing it
@@ -315,6 +318,7 @@ export async function repairMissingNitros(
};
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
options?.signal?.throwIfAborted();
const classname = item.item_name;
const result = await processItem(classname);
if (result.status === "skipped") {
@@ -384,13 +388,14 @@ export async function repairMissingNitros(
*/
export async function addSize32AllNitros(
onEvent?: (evt: RepairNitroEvent) => void,
opts?: { concurrency?: number },
opts?: { concurrency?: number; signal?: AbortSignal },
): Promise<{
succeeded: number;
failed: number;
skipped: number;
existing: number;
}> {
opts?.signal?.throwIfAborted();
let succeeded = 0;
let failed = 0;
let skipped = 0;
@@ -442,6 +447,7 @@ export async function addSize32AllNitros(
[...fileNames],
opts?.concurrency ?? 12,
async (fileName, i) => {
opts?.signal?.throwIfAborted();
let buf: Buffer | null = null;
for (const dir of nitroDirs) {
try {
+9
View File
@@ -234,6 +234,7 @@ export async function uploadSingleFurni(params: {
nitroBuffer: Buffer;
iconBuffer?: Buffer | null;
generateSql?: boolean;
signal?: AbortSignal;
}): Promise<UploadResult> {
const {
classname,
@@ -250,7 +251,9 @@ export async function uploadSingleFurni(params: {
nitroBuffer,
iconBuffer,
generateSql = false,
signal,
} = params;
signal?.throwIfAborted();
const warnings: string[] = [];
if (!/^[\w\-.*]+$/.test(classname)) {
@@ -280,6 +283,7 @@ export async function uploadSingleFurni(params: {
}
await ensureDirectories();
signal?.throwIfAborted();
const assetTargets = await getFurniAssetWriteTargets();
const { iconDir, nitroDir } = assetTargets;
@@ -297,6 +301,7 @@ export async function uploadSingleFurni(params: {
};
}
await fs.writeFile(nitroPath, nitroBuffer);
signal?.throwIfAborted();
let iconFileName: string | null = null;
let iconPath: string | null = null;
@@ -307,6 +312,7 @@ export async function uploadSingleFurni(params: {
}
const mirroredPaths: string[] = [];
signal?.throwIfAborted();
await mirrorUploadedAsset(
nitroPath,
nitroFileName,
@@ -346,6 +352,7 @@ export async function uploadSingleFurni(params: {
const interactionModesCount = autoInteraction.interactionModesCount;
let newId: number;
signal?.throwIfAborted();
try {
newId = await allocateItemsBaseId(async (nextId) => {
await db.execute(sql`
@@ -372,6 +379,7 @@ export async function uploadSingleFurni(params: {
}
try {
signal?.throwIfAborted();
const furniEntry = buildFurniEntry({
id: newId,
classname,
@@ -390,6 +398,7 @@ export async function uploadSingleFurni(params: {
let catalogItemId: number | null = null;
try {
signal?.throwIfAborted();
// Skip when the item already has a catalog row — re-uploads must never
// create a second row for the same item_ids.
const [existingCatalog] = (await db.execute(sql`