Files
EpicNext-Cms/src/lib/services/catalog-repair.ts
T
openhands eb885c044f
CI / check (push) Successful in 31s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m7s
fix: truncate furniture-line slug to fit caption_save varchar(25)
Line pages used caption_save 'imp_line_<slug>' which could exceed the
25-char column limit (e.g. imp_line_pixel_collectible = 26 chars),
causing the INSERT to fail. Cap the slug at 16 chars.
2026-08-22 11:36:09 +02:00

1040 lines
32 KiB
TypeScript

import { readdir } from "node:fs/promises";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
readFurniData,
withFurniDataLock,
writeFurniData,
} from "@/lib/services/furni-data";
import {
allocateCatalogItemId,
autoPriceFurni,
CATEGORY_PAGE,
classifyFurni,
getOrCreateCategoryPage,
getOrCreateImportedParentPage,
resetCatalogPageCache,
} from "@/lib/services/furni-import";
import {
assetNameCandidates,
classifyCatalogItem,
NO_CATALOG_KINDS,
} from "@/lib/services/item-kind";
export interface MissingCatalogEntry {
classname: string;
itemId: number;
itemType: string;
publicName?: string;
}
export interface CatalogSqlResult {
statements: string[];
sql: string;
count: number;
skipped: number;
}
function escSqlLiteral(value: string): string {
return value.replace(/'/g, "''");
}
const VALID_IDENTIFIER = /^[a-zA-Z0-9_]+$/;
function quoteIdentifier(identifier: string): string {
if (!VALID_IDENTIFIER.test(identifier)) throw new Error("Invalid identifier");
return `\`${identifier}\``;
}
/**
* Mapping of event prefix substrings (lowercase, no leading underscore) to
* their human-readable English page labels. Covers every seasonal/holiday/event
* abbreviation that has appeared across Habbo's history. Extend this map as new
* prefixes surface.
*/
const EVENT_PREFIXES: Record<string, string> = {
// Seasonal / Holiday events
xmas: "Christmas",
christmas: "Christmas",
xmastree: "Christmas",
xmasgift: "Christmas",
xmaspresent: "Christmas",
xmaseve: "Christmas",
hween: "Halloween",
halloween: "Halloween",
hw: "Halloween",
hall: "Halloween",
spook: "Halloween",
easter: "Easter",
easterbunny: "Easter",
val: "Valentine's Day",
valentine: "Valentine's Day",
valentines: "Valentine's Day",
love: "Valentine's Day",
heart: "Valentine's Day",
newyear: "New Year",
nye: "New Year",
countdown: "New Year",
summer: "Summer",
summersale: "Summer",
sun: "Summer",
tropic: "Summer",
tropical: "Summer",
resort: "Summer",
fall: "Fall",
autumn: "Autumn",
harvest: "Fall",
winter: "Winter",
snow: "Winter",
snowman: "Winter",
frost: "Winter",
glacier: "Winter",
arctic: "Winter",
icicle: "Winter",
carnival: "Carnival",
festival: "Carnival",
mardi: "Carnival",
mardigras: "Carnival",
fair: "Fair",
rodeo: "Fair",
thanksgiving: "Thanksgiving",
stpat: "St. Patrick's Day",
stpaddy: "St. Patrick's Day",
green: "St. Patrick's Day",
hanukkah: "Hanukkah",
chanukah: "Hanukkah",
diwali: "Diwali",
deepavali: "Diwali",
holiday: "Holiday",
holidays: "Holiday",
// Habbo-specific events
anniversary: "Anniversary",
anniv: "Anniversary",
hb: "Habbo Birthday",
hbday: "Habbo Birthday",
birthday: "Birthday",
habbohome: "HabboHome",
quest: "Quest Event",
mission: "Mission Event",
task: "Task Event",
challenge: "Challenge Event",
gm: "GM Event",
gamemaker: "GM Event",
moderation: "Staff Event",
staff: "Staff Event",
club: "Habbo Club",
hc: "Habbo Club",
nitro: "Nitro Event",
bingo: "Bingo",
quiz: "Quiz",
trivia: "Quiz",
game: "Games",
arcade: "Arcade",
puzzle: "Puzzles",
// Retro hotel / community server prefixes
leet: "Leet",
hubbly: "Hubbly",
crazy: "Crazy",
shock: "Shock",
flash: "Flash",
nova: "Nova",
uber: "Uber",
vip: "VIP",
exclusive: "Exclusive",
collectible: "Collectible",
vintage: "Vintage",
retro: "Retro",
classic: "Classic",
rare: "Rares",
// Furniture categories
room: "Room Furniture",
wall: "Wall Items",
floor: "Floor Items",
wallpaper: "Wallpapers",
tile: "Floor Tiles",
carpet: "Carpets",
rug: "Rugs",
door: "Doors",
gate: "Gates",
stair: "Stairs",
light: "Lighting",
lamp: "Lighting",
chair: "Chairs",
table: "Tables",
sofa: "Sofas",
bed: "Beds",
storage: "Storage",
wardrobe: "Wardrobes",
closet: "Wardrobes",
plant: "Plants",
flower: "Plants",
tree: "Plants",
bush: "Plants",
grass: "Plants",
sign: "Signs",
board: "Signs",
poster: "Posters",
banner: "Banners",
flag: "Flags",
statue: "Statues",
fountain: "Fountains",
pond: "Ponds",
garden: "Garden",
kitchen: "Kitchen",
bathroom: "Bathroom",
bath: "Bathroom",
book: "Books",
shelf: "Shelves",
bookshelf: "Bookshelves",
cabinet: "Cabinets",
drawer: "Drawers",
chest: "Storage",
box: "Storage",
trunk: "Storage",
crate: "Storage",
// Pets
pet: "Pets",
dog: "Dogs",
cat: "Cats",
bird: "Birds",
horse: "Horses",
pig: "Pets",
monkey: "Monkeys",
frog: "Pets",
bear: "Bears",
spider: "Pets",
bunny: "Rabbits",
rabbit: "Rabbits",
reptile: "Reptiles",
lizard: "Reptiles",
snake: "Reptiles",
tank: "Fish Tank",
fish: "Fish",
// Effects / Badges
badge: "Badges",
effect: "Effects",
bg: "Background",
aura: "Auras",
// Wired
wired: "Wired",
wf: "Wired",
trigger: "Wired",
// Credits / Exchange
credit: "Credits",
cf: "Credit Furniture",
exe: "Exchange",
diamond: "Diamonds",
ducket: "Duckets",
// Music
trax: "Music",
jukebox: "Music",
sound: "Sound",
record: "Records",
stereo: "Music",
musicdisk: "Music",
musicdisc: "Music",
// Clothing / Fashion
clothing: "Clothing",
clothes: "Clothing",
shirt: "Shirt",
pants: "Pants",
shoes: "Shoes",
hat: "Hat",
costume: "Costumes",
dress: "Dresses",
};
/**
* Derive a human-readable English page caption from a furniture classname.
* Automatically detects event prefixes (xmas2025 → "Christmas 2025") using a
* comprehensive dictionary of Habbo event abbreviations. Handles multiple
* separator styles (_, nothing). When no known pattern matches, returns the
* fallback caption.
*/
function derivePageCaption(classname: string, fallback: string): string {
const lower = classname.toLowerCase();
// Try to extract a year from anywhere in the classname.
const yearMatch = lower.match(/(\d{4})/);
const year = yearMatch ? yearMatch[1] : null;
// Check for known event prefixes. Iterate from longest prefix to shortest
// to avoid partial matches (e.g., "hween" before "hw").
const sortedPrefixes = Object.keys(EVENT_PREFIXES).sort(
(a, b) => b.length - a.length,
);
for (const prefix of sortedPrefixes) {
const re = new RegExp(`^${prefix.replace(/_/g, "[_]?")}(?:_)?(\\d*)`, "i");
const m = lower.match(re);
if (m) {
const matchEnd = (m.index ?? 0) + m[0].length;
const nextChar = lower[matchEnd];
if (
!nextChar ||
nextChar === "_" ||
nextChar === "." ||
/\d/.test(nextChar)
) {
const event = EVENT_PREFIXES[prefix.toLowerCase()];
return year ? `${event} ${year}` : event;
}
}
}
// Also try matching suffix patterns (e.g., "something_xmas2025").
for (const prefix of sortedPrefixes) {
const re = new RegExp(
`${prefix.replace(/_/g, "[_]?")}(?:_)?(\\d{4})(_|$)`,
"i",
);
const m = lower.match(re);
if (m) {
const event = EVENT_PREFIXES[prefix.toLowerCase()];
return year ? `${event} ${year}` : event;
}
}
return fallback;
}
/**
* Build standalone `catalog_items` INSERT statements for items_base entries
* that exist (with assets) but have no catalog entry yet. Ids and prices are
* resolved the same way the app's own imports do it (autoPriceFurni + category
* page + next catalog id), so running the SQL matches an app-managed import.
*
* When `organizeSql` is true, the output also contains `catalog_pages` INSERT
* statements that organise items into sub-pages under "Imported Furniture",
* using derived English captions by event+year (e.g. "Christmas 2025").
*/
export async function generateCatalogSql(
entries: MissingCatalogEntry[],
opts: { organizeSql?: boolean } = {},
): Promise<CatalogSqlResult> {
const statements: string[] = [
"-- AtomCMS catalog repair",
`-- Generated ${new Date().toISOString()}`,
`-- ${entries.length} item(s) to add`,
"START TRANSACTION;",
];
const { organizeSql = false } = opts;
// When organizing, we also emit catalog_pages INSERTs for new sub-pages.
// Read the Imported Furniture parent page id and current max page id once.
let parentPageId: number | null = null;
let nextPageId = 0;
if (organizeSql) {
parentPageId = await getOrCreateImportedParentPage();
const [maxPageResult] = (await db.execute(sql`
SELECT MAX(id) as maxId FROM catalog_pages
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
nextPageId = Number(maxPageResult[0]?.maxId ?? 0) + 1;
}
// The generation pass does not INSERT, so MAX(id) would never advance and
// per-entry allocateCatalogItemId would hand out the same id every time.
// Read the current max once and hand out sequential ids from there.
const [maxIdResult] = (await db.execute(sql`
SELECT MAX(id) as maxId FROM catalog_items
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
// Skip entries whose sprite id no longer exists in items_base (e.g. because
// a duplicate-classname merge ran between the audit snapshot and this
// generation). Inserting them would create orphaned catalog_items rows.
const [validIds] = (await db.execute(sql`
SELECT id FROM items_base
`)) as unknown as [Array<{ id: number }>, unknown];
const validItemIds = new Set(validIds.map((r) => Number(r.id)));
let skipped = 0;
// Build a map of caption_save → pageId so multiple items in the same
// category share one page (and we emit the page INSERT only once).
const pageMap = new Map<string, number>();
let generated = 0;
for (const entry of entries) {
if (!validItemIds.has(entry.itemId)) {
skipped++;
continue;
}
// Re-check against the live table: the audit snapshot may be stale and
// an import may have created the entry in the meantime. Inserting would
// create a duplicate catalog_items row for the same item_ids.
const [existing] = (await db.execute(sql`
SELECT id FROM catalog_items WHERE item_ids = ${String(entry.itemId)} LIMIT 1
`)) as unknown as [Array<{ id: number }>, unknown];
if (existing.length > 0) {
skipped++;
continue;
}
let pageId: number;
if (organizeSql && parentPageId) {
const catKey = classifyFurni(entry.classname, entry.itemType);
const captionSave = `imp_${catKey}`;
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other;
const caption = derivePageCaption(
entry.classname,
entry.publicName ?? catInfo.label,
);
let existing = pageMap.get(captionSave);
if (!existing) {
const [found] = (await db.execute(sql`
SELECT id FROM catalog_pages WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId}
`)) as unknown as [Array<{ id: number }>, unknown];
if (found?.[0]?.id) {
pageMap.set(captionSave, found[0].id);
} else {
const newId = nextPageId++;
pageMap.set(captionSave, newId);
statements.push(
`INSERT INTO catalog_pages (id, caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) VALUES (${newId}, '${escSqlLiteral(captionSave)}', '${escSqlLiteral(caption)}', 'default_3x3', ${parentPageId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '');`,
);
}
existing = pageMap.get(captionSave);
}
if (!existing) continue;
pageId = existing;
} else {
try {
pageId = await getOrCreateCategoryPage(entry.classname, entry.itemType);
} catch {
continue;
}
}
const price = autoPriceFurni(entry.classname);
// catalog_name must match items_base.item_name (the classname), exactly
// like the app's own import pipeline, so the studio's in-catalog joins
// and the emulator's item lookup stay consistent. offer_id is the item id
// (sprite id), not a literal '-1'.
statements.push(
`INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(entry.classname)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${entry.itemId}, '');`,
);
nextId++;
generated++;
}
statements.push("COMMIT;");
return { statements, sql: statements.join("\n"), count: generated, skipped };
}
/** Execute generated INSERT statements directly against the database. */
export async function applyCatalogSql(
statements: string[],
): Promise<{ applied: number; failed: number }> {
let applied = 0;
let failed = 0;
for (const statement of statements) {
const trimmed = statement.trim();
if (
!trimmed ||
trimmed.startsWith("--") ||
trimmed === "START TRANSACTION;" ||
trimmed === "COMMIT;"
) {
continue;
}
try {
await db.execute(sql.raw(trimmed));
applied++;
} catch {
failed++;
}
}
return { applied, failed };
}
export interface OrganizeCatalogResult {
/** Number of catalog sub-pages created under "Imported Furniture". */
pagesCreated: number;
/** Number of items that got a brand new catalog_items entry. */
catalogAdded: number;
/** Number of existing catalog entries re-homed/re-priced onto their correct page. */
catalogRehomed: number;
}
/**
* Fully automatic one-shot organization of the entire `items_base` table into
* the catalog. For every furniture item it:
* 1. ensures the "Imported Furniture" parent page and the correct target
* sub-page exist (creating it if missing). The target is the item's
* furniture line (e.g. "weebz", "habbox") when known, otherwise its
* auto-detected category (e.g. "Room & Building", "Rares"),
* 2. creates a catalog_items entry if the item isn't in the catalog yet
* (placed on its line/category sub-page with the auto price),
* 3. re-homes and re-prices any existing catalog entry so it sits on its
* correct sub-page with the canonical auto-detected price.
*
* No manual selection required — runs over the entire database.
*/
export async function organizeCatalogAll(): Promise<OrganizeCatalogResult> {
// 1) Parent page (sub-pages are created on demand below).
const parentPageId = await getOrCreateImportedParentPage();
// Furniture line (brand/series) per classname, sourced from FurnitureData.json
// since `items_base` does not store it. Used to give each line its own page.
const furniData = (await readFurniData()) as Record<
string,
{ furnitype?: Array<{ classname?: string; furniline?: string }> }
>;
const furnilineByClass = new Map<string, string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of furniData[section]?.furnitype ?? []) {
const cn = typeof e.classname === "string" ? e.classname : "";
const fl = typeof e.furniline === "string" ? e.furniline.trim() : "";
if (!cn || !fl) continue;
furnilineByClass.set(cn, fl);
const base = cn.split("*")[0];
if (base && base !== cn) furnilineByClass.set(base, fl);
}
}
const resolveFurniline = (classname: string): string => {
const direct = furnilineByClass.get(classname);
if (direct) return direct;
const base = classname.split("*")[0];
return (base && furnilineByClass.get(base)) || "";
};
// The `caption_save` column is varchar(25). The line prefix `imp_line_`
// takes 9 chars, so the slug may be at most 16 chars to stay within limit.
const slug = (value: string): string =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 16);
// Minimum number of items a furniture line must have before it gets its own
// dedicated catalog page. Lines below this threshold (one-off customs, etc.)
// collapse onto the category page so we don't create hundreds of tiny pages.
const LINE_PAGE_MIN_ITEMS = 3;
// Ensure a sub-page exists under the imported parent and return its id.
const ensureSubPage = async (
captionSave: string,
caption: string,
): Promise<number> => {
const [existing] = (await db.execute(sql`
SELECT id FROM catalog_pages
WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId}
`)) as unknown as [Array<{ id: number }>, unknown];
if (existing?.[0]?.id) return existing[0].id;
await db.execute(sql`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${captionSave}, ${caption}, 'default_3x3', ${parentPageId}, '1', 50, 263, '1', '1', '', '', '', '', '', '', '', '')
`);
const [created] = (await db.execute(sql`
SELECT id FROM catalog_pages
WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId}
`)) as unknown as [Array<{ id: number }>, unknown];
return created?.[0]?.id ?? existing?.[0]?.id ?? 0;
};
// Sub-pages that already exist, so we can count the ones we create.
const [existingPages] = (await db.execute(sql`
SELECT caption_save FROM catalog_pages
WHERE parent_id = ${parentPageId} AND caption_save LIKE 'imp\\_%'
`)) as unknown as [Array<{ caption_save: string }>, unknown];
const existingCaptions = new Set(existingPages.map((r) => r.caption_save));
const createdCaptions = new Set<string>();
let pagesCreated = 0;
// 2) Inventory of items_base + current catalog state.
const [items] = (await db.execute(sql`
SELECT id, item_name, public_name, type, interaction_type
FROM items_base ORDER BY id
`)) as unknown as [
Array<{
id: number;
item_name: string;
public_name: string;
type: string;
interaction_type: string | null;
}>,
unknown,
];
const [catalogRows] = (await db.execute(sql`
SELECT id, item_ids, page_id, cost_credits FROM catalog_items
`)) as unknown as [
Array<{
id: number;
item_ids: string;
page_id: number | string;
cost_credits: number;
}>,
unknown,
];
const idsInCatalog = new Set<number>();
const catalogPageByItem = new Map<number, number>();
const catalogCreditsByItem = new Map<number, number>();
for (const row of catalogRows) {
const ids = (row.item_ids ?? "")
.split(";")
.map((s) => Number(s.trim()))
.filter(Boolean);
for (const id of ids) {
idsInCatalog.add(id);
if (!catalogPageByItem.has(id)) {
catalogPageByItem.set(id, Number(row.page_id));
catalogCreditsByItem.set(id, Number(row.cost_credits));
}
}
}
const [validPages] = (await db.execute(sql`
SELECT id FROM catalog_pages
`)) as unknown as [Array<{ id: number }>, unknown];
const validPageIds = new Set(validPages.map((r) => Number(r.id)));
// Count items per furniture line so we only spin up a dedicated line page
// for lines that actually have enough items. Everything else falls back to
// the category page.
const lineCounts = new Map<string, number>();
for (const item of items) {
if (NO_CATALOG_KINDS.has(classifyCatalogItem(item))) continue;
const fl = resolveFurniline(item.item_name);
if (fl) lineCounts.set(fl, (lineCounts.get(fl) ?? 0) + 1);
}
const qualifyingLines = new Set<string>();
for (const [line, count] of lineCounts) {
if (count >= LINE_PAGE_MIN_ITEMS) qualifyingLines.add(line);
}
// Nitro presence (pets are purchasable without a furni .nitro bundle).
const { nitroDir } = await getFurniAssetDirs();
const nitroFiles = await readdir(nitroDir).catch(() => [] as string[]);
const nitroSet = new Set(nitroFiles);
let catalogAdded = 0;
let catalogRehomed = 0;
for (const item of items) {
const kind = classifyCatalogItem(item);
if (NO_CATALOG_KINDS.has(kind)) continue;
const itemId = Number(item.id);
const inCatalog = idsInCatalog.has(itemId);
// Items not yet in the catalog are only added when they are actually
// purchasable furniture (have a .nitro or are a pet).
if (!inCatalog) {
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) =>
nitroSet.has(n),
);
const purchasable = kind === "pet" || hasNitro;
if (!purchasable) continue;
}
// Target page: the item's furniture line (e.g. "weebz", "habbox") when
// it qualifies for its own page, otherwise its auto-detected category.
// The line page is created if it does not exist yet.
let captionSave: string;
let caption: string;
const furniline = resolveFurniline(item.item_name);
if (furniline && slug(furniline) && qualifyingLines.has(furniline)) {
captionSave = `imp_line_${slug(furniline)}`;
caption = furniline;
} else {
const catKey = classifyFurni(item.item_name, item.type);
captionSave = `imp_${catKey}`;
caption = CATEGORY_PAGE[catKey]?.label ?? CATEGORY_PAGE.other.label;
}
if (
!existingCaptions.has(captionSave) &&
!createdCaptions.has(captionSave)
) {
createdCaptions.add(captionSave);
pagesCreated++;
}
const pageId = await ensureSubPage(captionSave, caption);
const price = autoPriceFurni(item.item_name);
if (!inCatalog) {
await allocateCatalogItemId(async (nextId) => {
await db.execute(sql`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextId}, ${pageId}, ${itemId}, ${item.item_name}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${itemId}, '')
`);
});
catalogAdded++;
continue;
}
// Re-classify onto the correct sub-page and re-apply the auto price if
// either drifted away from the canonical values.
const currentPage = catalogPageByItem.get(itemId);
const currentCredits = catalogCreditsByItem.get(itemId);
const needsMove =
currentPage === undefined ||
!validPageIds.has(currentPage) ||
currentPage !== pageId;
const needsReprice = currentCredits !== price.credits;
if (needsMove || needsReprice) {
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageId},
cost_credits = ${price.credits},
cost_points = ${price.points},
points_type = ${price.pointsType}
WHERE item_ids = ${String(itemId)}
`);
catalogRehomed++;
}
}
resetCatalogPageCache();
return { pagesCreated, catalogAdded, catalogRehomed };
}
export interface OrphanedCatalogResult {
removed: number;
cleaned: number;
}
export interface DuplicateClassnameResult {
merged: number;
rowsRemoved: number;
remapped: number;
}
/**
* Remove catalog_items rows whose item_ids only reference non-existent
* items_base entries, and strip orphaned ids from mixed rows. Also deletes
* catalog_items_limited rows that pointed at the removed catalog entries so no
* new orphans are left behind.
*/
export async function repairOrphanedCatalog(): Promise<OrphanedCatalogResult> {
const [itemRows] = (await db.execute(sql`
SELECT id FROM items_base
`)) as unknown as [Array<{ id: number }>, unknown];
const valid = new Set<number>();
for (const row of itemRows) valid.add(Number(row.id));
const [catalogRows] = (await db.execute(sql`
SELECT id, item_ids FROM catalog_items
`)) as unknown as [Array<{ id: number; item_ids: string }>, unknown];
let removed = 0;
let cleaned = 0;
for (const row of catalogRows) {
const ids = (row.item_ids ?? "")
.split(";")
.map((s) => Number(s.trim()))
.filter(Boolean);
if (ids.length === 0) continue;
const kept = ids.filter((id) => valid.has(id));
if (kept.length === ids.length) continue;
if (kept.length === 0) {
await db.execute(sql`
DELETE FROM catalog_items_limited WHERE catalog_item_id = ${row.id}
`);
await db.execute(sql`DELETE FROM catalog_items WHERE id = ${row.id}`);
removed++;
} else {
await db.execute(sql`
UPDATE catalog_items SET item_ids = ${kept.join(";")} WHERE id = ${row.id}
`);
cleaned++;
}
}
return { removed, cleaned };
}
/**
* Merge duplicate classnames in items_base into a single canonical row per
* classname, remapping every reference (catalog item id lists, player items,
* presents, marketplace, etc.) to the canonical id, then delete the duplicate
* rows. The canonical id is the one players actually purchase through
* catalog_items when present, otherwise the lowest id.
*/
export async function repairDuplicateClassnames(): Promise<DuplicateClassnameResult> {
const [itemRows] = (await db.execute(sql`
SELECT id, item_name FROM items_base
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
const byName = new Map<string, number[]>();
for (const row of itemRows) {
const arr = byName.get(row.item_name) ?? [];
arr.push(Number(row.id));
byName.set(row.item_name, arr);
}
const [catalogRows] = (await db.execute(sql`
SELECT item_ids FROM catalog_items
`)) as unknown as [Array<{ item_ids: string }>, unknown];
const referenced = new Set<number>();
for (const row of catalogRows) {
for (const part of (row.item_ids ?? "").split(";")) {
const id = Number(part.trim());
if (id) referenced.add(id);
}
}
const remap = new Map<number, number>();
let merged = 0;
for (const ids of byName.values()) {
if (ids.length <= 1) continue;
merged++;
const sorted = [...ids].sort((a, b) => a - b);
const canonical = sorted.find((id) => referenced.has(id)) ?? sorted[0];
for (const id of ids) {
if (id !== canonical) remap.set(id, canonical);
}
}
if (remap.size === 0) return { merged: 0, rowsRemoved: 0, remapped: 0 };
let remapped = 0;
// Semicolon-separated item_ids columns.
const listTables: Array<{ table: string; idCol: string }> = [
{ table: "catalog_items", idCol: "id" },
{ table: "catalog_items_bc", idCol: "id" },
{ table: "logs_shop_purchases", idCol: "id" },
];
for (const { table, idCol } of listTables) {
const [rows] = (await db.execute(
sql`SELECT ${sql.raw(quoteIdentifier(idCol))} AS pk, item_ids AS v FROM ${sql.raw(quoteIdentifier(table))}`,
)) as unknown as [Array<{ pk: number; v: string | null }>, unknown];
for (const row of rows) {
if (!row.v) continue;
const parts = row.v
.split(";")
.map((s) => s.trim())
.filter(Boolean);
let changed = false;
const out: number[] = [];
const seen = new Set<number>();
for (const part of parts) {
const id = Number(part);
const mapped = remap.get(id) ?? id;
if (mapped !== id) changed = true;
if (seen.has(mapped)) continue;
seen.add(mapped);
out.push(mapped);
}
if (!changed) continue;
await db.execute(
sql`UPDATE ${sql.raw(quoteIdentifier(table))} SET item_ids = ${out.join(";")} WHERE ${sql.raw(quoteIdentifier(idCol))} = ${row.pk}`,
);
remapped++;
}
}
// Single item_id columns that reference items_base.id. Remap in chunks via
// a single CASE statement per table to keep the query count low even with
// thousands of duplicate rows.
const singleTables = [
"catalog_items_limited",
"items",
"items_presents",
"pet_items",
"pet_foods",
"pet_drinks",
"items_hoppers",
"marketplace_items",
"items_highscore_data",
"items_crackable",
"builders_club_items",
"recycler_prizes",
"gift_wrappers",
"youtube_playlists",
"room_trade_log_items",
"calendar_rewards",
"room_trax_playlist",
"trax_playlist",
"website_event_prizes",
"room_templates_items",
"crafting_recipes_ingredients",
"website_rare_values",
"logs_economy",
];
const entries = [...remap.entries()];
const CHUNK = 400;
for (const table of singleTables) {
for (let i = 0; i < entries.length; i += CHUNK) {
const chunk = entries.slice(i, i + CHUNK);
const cases = chunk.map(
([dup, canonical]) => sql`WHEN ${dup} THEN ${canonical}`,
);
const dupList = chunk.map(([dup]) => dup);
try {
const [result] = (await db.execute(sql`
UPDATE ${sql.raw(quoteIdentifier(table))}
SET item_id = CASE item_id ${sql.join(cases, sql` `)} ELSE item_id END
WHERE item_id IN (${sql.join(dupList, sql`, `)})
`)) as unknown as [Record<string, unknown>, unknown];
remapped += Number(result.affectedRows ?? 0);
} catch {
// Table may not exist on some hotel schemas — skip it.
}
}
}
const dupIds = entries.map(([dup]) => dup);
await db.execute(sql`
DELETE FROM items_base WHERE id IN (${sql.join(dupIds, sql`, `)})
`);
return { merged, rowsRemoved: dupIds.length, remapped };
}
export interface FurniDataRepairResult {
added: number;
removedDuplicates: number;
removedIdConflicts: number;
}
function buildMinimalFurniEntry(item: {
id: number;
item_name: string;
public_name: string;
}): Record<string, unknown> {
return {
id: item.id,
classname: item.item_name,
revision: 0,
category: "unknown",
defaultdir: 0,
xdim: 1,
ydim: 1,
partcolors: { color: [] },
name: item.public_name || item.item_name,
description: "",
adurl: "",
offerid: item.id,
buyout: false,
rentofferid: -1,
rentbuyout: false,
bc: false,
excludeddynamic: false,
customparams: "",
specialtype: 0,
canstandon: false,
cansiton: false,
canlayon: false,
furniline: "",
environment: "",
rare: false,
};
}
/**
* Add FurnitureData.json entries for items_base rows that are missing from the
* file, remove duplicate classnames, and resolve spriteId conflicts
* (keeping the first classname per id, removing conflicting ones).
*/
export async function repairFurniData(
items: Array<{
id: number;
item_name: string;
public_name: string;
type: string;
}>,
): Promise<FurniDataRepairResult> {
if (items.length === 0)
return { added: 0, removedDuplicates: 0, removedIdConflicts: 0 };
return withFurniDataLock(async () => {
const furniData = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>;
// 1) Deduplicate classnames per section (keep the first occurrence).
let removedDuplicates = 0;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
const sec = furniData[section];
if (!sec?.furnitype) continue;
const seen = new Set<string>();
const deduped = sec.furnitype.filter((e) => {
const classname = typeof e.classname === "string" ? e.classname : "";
if (!classname) return true;
if (seen.has(classname)) {
removedDuplicates++;
return false;
}
seen.add(classname);
return true;
});
if (deduped.length !== sec.furnitype.length) {
furniData[section].furnitype = deduped;
}
}
// 2) Resolve spriteId conflicts (same id used by different classnames) — keep first, remove others.
let removedIdConflicts = 0;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
const sec = furniData[section];
if (!sec?.furnitype) continue;
const idToClassname = new Map<number, string>();
const deduped = sec.furnitype.filter((e) => {
const id = Number(e?.id);
const classname = typeof e.classname === "string" ? e.classname : "";
if (!Number.isFinite(id) || id <= 0 || !classname) return true;
const existing = idToClassname.get(id);
if (existing && existing !== classname) {
// Conflict: same id used by different classname — keep first, remove this
removedIdConflicts++;
return false;
}
if (!existing) {
idToClassname.set(id, classname);
}
return true;
});
if (deduped.length !== sec.furnitype.length) {
furniData[section].furnitype = deduped;
}
}
// 3) Add entries for items_base rows missing from the file.
const existing = new Set<string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of furniData[section]?.furnitype ?? []) {
if (typeof e.classname === "string") existing.add(e.classname);
}
}
const added: Array<{ entry: Record<string, unknown>; itemType: string }> =
[];
for (const item of items) {
if (existing.has(item.item_name)) continue;
added.push({
entry: buildMinimalFurniEntry(item),
itemType: item.type,
});
}
// 4) Write once if anything changed.
if (removedDuplicates > 0 || removedIdConflicts > 0 || added.length > 0) {
for (const { entry, itemType } of added) {
const sectionKey = itemType === "i" ? "wallitemtypes" : "roomitemtypes";
if (!furniData[sectionKey]) {
furniData[sectionKey] = { furnitype: [] };
}
furniData[sectionKey].furnitype.push(entry);
}
await writeFurniData(furniData as Record<string, unknown>);
}
return { added: added.length, removedDuplicates, removedIdConflicts };
});
}