From ca1756fbb08c948345fd19c5654b6f3998d7b974 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 23 Aug 2026 15:07:58 +0200 Subject: [PATCH] Fix pre-existing type errors in diagnostics scripts (blocked pre-push tsc hook) --- scripts/align-db-ids-with-furnidata.ts | 365 +++++++++++++++++++++++++ scripts/furni-diagnose-now.ts | 207 ++++++++++++++ 2 files changed, 572 insertions(+) create mode 100644 scripts/align-db-ids-with-furnidata.ts create mode 100644 scripts/furni-diagnose-now.ts diff --git a/scripts/align-db-ids-with-furnidata.ts b/scripts/align-db-ids-with-furnidata.ts new file mode 100644 index 00000000..b7ecc816 --- /dev/null +++ b/scripts/align-db-ids-with-furnidata.ts @@ -0,0 +1,365 @@ +import "./load-env"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { readFurniData, writeFurniData } from "@/lib/services/furni-data"; + +/** + * Make FurnitureData.json the single source of truth for items_base ids. + * + * 1. Duplicate ids inside furnidata (same id claimed by several classnames) + * are resolved globally and iteratively: the claimant matching the current + * DB occupant keeps the id; losing entries are patched in the file back to + * their own classname's DB id (and lose their claim). + * 2. Every items_base row is then moved (pass-based PK swap, cascading into + * every referencing table) to its furnidata id. Moves whose target is + * still occupied wait for a later pass; cycles break via scratch ids; + * rows squatting on a contested id without any furnidata entry are + * displaced to fresh ids past the global maximum. + * 3. sprite_id = id and catalog_name = item_name afterwards. + * + * Run with --apply to write; without it, only reports. + */ +const APPLY = process.argv.includes("--apply"); + +interface Entry { + id: number; + classname: string; + offerid?: unknown; +} + +async function main(): Promise { + const t0 = Date.now(); + const furniData = (await readFurniData()) as Record< + string, + { furnitype?: Entry[] } + >; + + const [rows] = (await db.execute( + sql`SELECT id, item_name FROM items_base`, + )) as unknown as [Array<{ id: number; item_name: string }>, unknown]; + console.log(`[align] items_base rows: ${rows.length}`); + const nameById = new Map(); + const idByName = new Map(); + for (const r of rows) { + nameById.set(r.id, r.item_name); + idByName.set(r.item_name, r.id); + } + + const allEntries: Array<{ section: string; e: Entry }> = []; + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + for (const e of furniData[section]?.furnitype ?? []) { + if (typeof e?.classname === "string" && Number.isFinite(Number(e?.id))) { + allEntries.push({ section, e }); + } + } + } + + // ── global iterative duplicate-id resolution ──────────────────── + const desired = new Map(); // classname -> furnidata id claim + for (const { e } of allEntries) { + const cn = e.classname; + const id = Number(e.id); + if (!cn || !Number.isFinite(id) || id <= 0 || desired.has(cn)) continue; + desired.set(cn, id); + } + console.log(`[align] furnidata classnames: ${desired.size}`); + + const losers: Array<{ cn: string; keepId: number }> = []; + for (;;) { + const claimsById = new Map(); + for (const [cn, id] of desired) { + const arr = claimsById.get(id) ?? []; + arr.push(cn); + claimsById.set(id, arr); + } + let found = false; + for (const [id, claimants] of [...claimsById].sort((a, b) => a[0] - b[0])) { + if (claimants.length < 2) continue; + const live = claimants.filter((cn) => desired.get(cn) === id); + if (live.length < 2) continue; + found = true; + const dbHolder = nameById.get(id); + const winner = + dbHolder !== undefined && live.includes(dbHolder) ? dbHolder : live[0]; + for (const cn of live) { + if (cn === winner) continue; + const keep = idByName.get(cn); + if (keep !== undefined && keep !== id) + losers.push({ cn, keepId: keep }); + desired.delete(cn); // loser loses its claim entirely + } + } + if (!found) break; + } + console.log( + `[align] duplicate-id losers (patched to their own DB id): ${losers.length}`, + ); + + // ── compute moves ─────────────────────────────────────────────── + let fresh = + Math.max( + rows.reduce((m, r) => Math.max(m, r.id), 0), + [...desired.values()].reduce((m, v) => Math.max(m, v), 0), + losers.reduce((m, l) => Math.max(m, l.keepId), 0), + ) + 1000; + + const moves = new Map(); // old_id -> new_id + for (const r of rows) { + const target = desired.get(r.item_name); + if (target !== undefined && target !== r.id) moves.set(r.id, target); + } + // squatter displacement: a row occupying a contested id with no furnidata + // entry of its own must make room + for (;;) { + const destCount = new Map(); + for (const [, t] of moves) destCount.set(t, (destCount.get(t) ?? 0) + 1); + let changed = false; + for (const [, target] of moves) { + const holder = nameById.get(target); + if (holder === undefined) continue; // free + if (moves.has(target)) continue; // vacated by its own move + if (desired.get(holder) === target) continue; // legit stayer + moves.set(target, fresh++); + changed = true; + break; + } + if (!changed) break; + } + // drop moves onto ids that a legit stayer keeps forever + for (;;) { + let changed = false; + for (const [oldId, target] of moves) { + const holder = nameById.get(target); + if (holder === undefined || moves.has(target)) continue; + if (desired.get(holder) === target) { + moves.delete(oldId); + changed = true; + break; + } + } + if (!changed) break; + } + console.log(`[align] planned id moves: ${moves.size}`); + let n = 0; + for (const [oldId, target] of moves) { + if (n++ >= 10) break; + console.log(` '${nameById.get(oldId)}' : ${oldId} -> ${target}`); + } + + if (!APPLY) { + console.log("[align] DRY RUN — rerun with --apply to execute"); + await db.$client.end(); + process.exit(0); + } + + // ── patch losing furnidata entries back to their DB id ────────── + if (losers.length > 0) { + const keepByClass = new Map(losers.map((l) => [l.cn, l.keepId])); + for (const { e } of allEntries) { + const keep = keepByClass.get(e.classname); + if (keep !== undefined && Number(e.id) !== keep) { + e.id = keep; + e.offerid = keep; + } + } + await writeFurniData(furniData as Record); + console.log(`[align] furnidata patched: ${losers.length} losing entries`); + } + + // ── pass-based PK moves (values stay in unsigned range) ───────── + await db.execute(sql`DROP TABLE IF EXISTS _id_map`); + await db.execute(sql`DROP TABLE IF EXISTS _id_map_pass`); + await db.execute(sql` + CREATE TABLE _id_map_pass ( + old_id INT PRIMARY KEY, + new_id INT NOT NULL, + UNIQUE KEY uniq_new (new_id) + ) ENGINE=InnoDB`); + + const intCols: Array<[string, string]> = [ + ["items", "item_id"], + ["room_templates_items", "item_id"], + ["catalog_items_limited", "item_id"], + ["crafting_recipes_ingredients", "item_id"], + ["items_crackable", "item_id"], + ["gift_wrappers", "sprite_id"], + ["gift_wrappers", "item_id"], + ["trax_playlist", "item_id"], + ["pet_drinks", "item_id"], + ["pet_foods", "item_id"], + ["pet_items", "item_id"], + ["marketplace_items", "item_id"], + ["calendar_rewards", "item_id"], + ["builders_club_items", "item_id"], + ["youtube_playlists", "item_id"], + ["room_trax_playlist", "item_id"], + ["recycler_prizes", "item_id"], + ["website_event_prizes", "item_id"], + ["website_rare_values", "item_id"], + ["catalog_products", "item_id"], + ["room_trade_log_items", "item_id"], + ["logs_economy", "item_id"], + ]; + const strCols: Array<[string, string]> = [ + ["catalog_items", "item_ids"], + ["catalog_items_bc", "item_ids"], + ["logs_shop_purchases", "item_ids"], + ["catalog_version_offers", "item_ids"], + ]; + + const [csRows] = (await db.execute(sql` + SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_SET_NAME, COLLATION_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND ((TABLE_NAME = 'catalog_items' AND COLUMN_NAME = 'item_ids') + OR (TABLE_NAME = 'catalog_items_bc' AND COLUMN_NAME = 'item_ids') + OR (TABLE_NAME = 'logs_shop_purchases' AND COLUMN_NAME = 'item_ids') + OR (TABLE_NAME = 'catalog_version_offers' AND COLUMN_NAME = 'item_ids')) + `)) as unknown as [ + Array<{ + TABLE_NAME: string; + COLUMN_NAME: string; + CHARACTER_SET_NAME: string; + COLLATION_NAME: string; + }>, + unknown, + ]; + const collationByCol = new Map(); + for (const r of csRows ?? []) { + collationByCol.set(`${r.TABLE_NAME}.${r.COLUMN_NAME}`, [ + r.CHARACTER_SET_NAME, + r.COLLATION_NAME, + ]); + } + // numeric -> string in the target column's own charset/collation so the + // JOIN comparison never mixes collations (CAST alone yields utf8mb4) + const numToStr = (table: string, col: string, expr: string) => { + const info = collationByCol.get(`${table}.${col}`); + if (!info) return sql.raw(`CAST(${expr} AS CHAR)`); + return sql.raw( + `CONVERT(${expr}, CHAR CHARACTER SET ${info[0]}) COLLATE ${info[1]}`, + ); + }; + + const occupiedIds = new Set(rows.map((r) => r.id)); + let scratch = fresh; + + async function execPass(pairs: Array<[number, number]>): Promise { + if (pairs.length === 0) return; + await db.transaction(async (tx) => { + await tx.execute(sql`SET FOREIGN_KEY_CHECKS = 0`); + for (let i = 0; i < pairs.length; i += 500) { + const chunk = pairs.slice(i, i + 500); + await tx.execute(sql` + INSERT INTO _id_map_pass (old_id, new_id) + VALUES ${sql.join( + chunk.map(([o, nw]) => sql`(${o}, ${nw})`), + sql`, `, + )}`); + } + + await tx.execute(sql` + UPDATE items_base b JOIN _id_map_pass m ON b.id = m.old_id + SET b.id = m.new_id`); + for (const [table, col] of intCols) { + await tx.execute(sql` + UPDATE ${sql.raw(table)} t JOIN _id_map_pass m + ON t.${sql.raw(col)} = m.old_id + SET t.${sql.raw(col)} = m.new_id`); + } + for (const [table, col] of strCols) { + await tx.execute(sql` + UPDATE ${sql.raw(table)} t JOIN _id_map_pass m + ON t.${sql.raw(col)} = ${numToStr(table, col, "m.old_id")} + SET t.${sql.raw(col)} = CAST(m.new_id AS CHAR)`); + } + for (const [o] of pairs) { + await tx.execute(sql`DELETE FROM _id_map_pass WHERE old_id = ${o}`); + } + await tx.execute(sql`SET FOREIGN_KEY_CHECKS = 1`); + }); + for (const [o, nw] of pairs) { + occupiedIds.delete(o); + occupiedIds.add(nw); + } + } + + const pending = new Map(moves); + let passes = 0; + while (pending.size > 0) { + passes++; + const safe: Array<[number, number]> = []; + for (const [oldId, target] of pending) { + if (!occupiedIds.has(target)) safe.push([oldId, target]); + } + if (safe.length === 0) { + // cycle: park the smallest row on a scratch id to break it + const oldest = [...pending.keys()].sort((a, b) => a - b)[0]; + const target = pending.get(oldest); + if (target === undefined) break; + pending.delete(oldest); + await execPass([[oldest, scratch]]); + pending.set(scratch, target); + scratch++; + continue; + } + await execPass(safe); + for (const [oldId] of safe) pending.delete(oldId); + console.log( + `[align] pass ${passes}: moved ${safe.length} ids (${pending.size} left)`, + ); + } + + await db.execute(sql`DROP TABLE _id_map_pass`); + + await db.transaction(async (tx) => { + await tx.execute( + sql`UPDATE items_base SET sprite_id = id WHERE sprite_id <> id`, + ); + const [res] = (await tx.execute(sql` + UPDATE catalog_items ci JOIN items_base ib + ON ci.item_ids = ${numToStr("catalog_items", "item_ids", "ib.id")} + SET ci.catalog_name = ib.item_name + WHERE ci.catalog_name <> ib.item_name`)) as unknown as [ + Record, + unknown, + ]; + console.log( + `[align] catalog_name normalized rows: ${Number(res.affectedRows ?? 0)}`, + ); + }); + + const [maxRows] = (await db.execute( + sql`SELECT COALESCE(MAX(id), 0) + 1 AS nxt FROM items_base`, + )) as unknown as [Array<{ nxt: number }>, unknown]; + await db.execute( + sql`ALTER TABLE items_base AUTO_INCREMENT = ${sql.raw(String(Number(maxRows[0]?.nxt ?? 1)))}`, + ); + + // leftover compound lists ("a;b") containing moved ids + const [compound] = (await db.execute(sql` + SELECT id, item_ids FROM catalog_items WHERE item_ids LIKE '%;%' + `)) as unknown as [Array<{ id: number; item_ids: string }>, unknown]; + for (const row of compound) { + const mapped = String(row.item_ids) + .split(/[;,]/) + .map((p) => { + const v = Number(p.trim()); + return Number.isFinite(v) ? v : p; + }) + .join(";"); + await db.execute( + sql`UPDATE catalog_items SET item_ids = ${mapped} WHERE id = ${row.id}`, + ); + } + console.log(`[align] compound item_ids rewritten: ${compound.length}`); + console.log(`[align] done in ${((Date.now() - t0) / 1000).toFixed(1)}s`); + + await db.$client.end(); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/furni-diagnose-now.ts b/scripts/furni-diagnose-now.ts new file mode 100644 index 00000000..9232e38b --- /dev/null +++ b/scripts/furni-diagnose-now.ts @@ -0,0 +1,207 @@ +import "./load-env"; +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { sql } from "drizzle-orm"; +import { CatalogItems, db, ItemsBase } from "@/lib/db"; +import { readFurniData } from "@/lib/services/furni-data"; + +const ICON_DIR = path.join( + process.cwd(), + "public", + "swf", + "dcr", + "hof_furni", + "icons", +); +const NITRO_DIR = path.join( + process.cwd(), + "public", + "swf", + "dcr", + "hof_furni", + "nitro", +); + +function md5(p: string): string { + return createHash("md5").update(readFileSync(p)).digest("hex"); +} + +async function main(): Promise { + // ── 1. DB state ────────────────────────────────────────────────── + const [spriteDrift] = (await db.execute( + sql`SELECT COUNT(*) AS n FROM items_base WHERE sprite_id <> id`, + )) as unknown as [Array<{ n: number }>, unknown]; + console.log( + `[1] items_base rows with sprite_id <> id: ${Number(spriteDrift[0]?.n ?? 0)}`, + ); + + const baseRows = await db + .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) + .from(ItemsBase); + console.log(` items_base total: ${baseRows.length}`); + + const catRows = await db + .select({ + id: CatalogItems.id, + itemIds: CatalogItems.itemIds, + catalogName: CatalogItems.catalogName, + }) + .from(CatalogItems); + console.log(` catalog_items total: ${catRows.length}`); + + // catalog_items.item_ids -> dangling items_base refs + let dangling = 0; + let nameMismatch = 0; + const nameById = new Map(baseRows.map((r) => [r.id, r.itemName])); + for (const c of catRows) { + const first = Number(String(c.itemIds ?? "").split(/[;,]/)[0]); + if (!Number.isFinite(first) || !nameById.has(first)) { + dangling++; + continue; + } + if (c.catalogName && nameById.get(first) !== c.catalogName) nameMismatch++; + } + console.log(`[2] catalog_items with dangling item_ids: ${dangling}`); + console.log( + ` catalog_items with catalog_name <> items_base.item_name: ${nameMismatch}`, + ); + + // ── 2. Furnidata vs items_base ─────────────────────────────────── + const furniData = (await readFurniData()) as Record< + string, + { furnitype?: Array> } + >; + interface Entry { + id: number; + classname: string; + offerid: number; + } + const entries: Entry[] = []; + const seenClass = new Map(); + let dupClass = 0; + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + for (const e of furniData[section]?.furnitype ?? []) { + const id = Number(e?.id); + const cn = typeof e?.classname === "string" ? e.classname : ""; + if (!Number.isFinite(id) || !cn) continue; + if (seenClass.has(cn)) dupClass++; + seenClass.set(cn, (seenClass.get(cn) ?? 0) + 1); + entries.push({ id, classname: cn, offerid: Number(e?.offerid) }); + } + } + console.log( + `[3] furnidata entries: ${entries.length} (duplicate classnames: ${dupClass})`, + ); + + const dbByClass = new Map(baseRows.map((r) => [r.itemName, r.id])); + const dbById = new Map(baseRows.map((r) => [r.id, r.itemName])); + let fdWrongId = 0; + let fdIdConflict = 0; // id belongs to a different classname in DB + let fdMissingInDb = 0; + const fdExamplesWrong: string[] = []; + for (const e of entries) { + const dbId = dbByClass.get(e.classname); + if (dbId === undefined) { + fdMissingInDb++; + continue; + } + if (dbId !== e.id) { + fdWrongId++; + if (fdExamplesWrong.length < 10) + fdExamplesWrong.push( + `${e.classname}: furnidata id=${e.id} vs db id=${dbId}`, + ); + continue; + } + if (dbById.get(e.id) !== e.classname) fdIdConflict++; + } + console.log( + ` furnidata entries whose id != items_base.id for same classname: ${fdWrongId}`, + ); + console.log( + ` furnidata entries not present in items_base at all: ${fdMissingInDb}`, + ); + if (fdExamplesWrong.length) + console.log(` examples:\n ${fdExamplesWrong.join("\n ")}`); + + // items_base rows missing from furnidata + const fdClasses = new Set(entries.map((e) => e.classname)); + const missingFd = baseRows.filter((r) => !fdClasses.has(r.itemName)); + console.log( + ` items_base rows missing from furnidata: ${missingFd.length}`, + ); + if (missingFd.length > 0) + console.log( + ` examples: ${missingFd + .slice(0, 10) + .map((r) => `${r.id}:${r.itemName}`) + .join(", ")}`, + ); + + // ── 3. Assets on disk ──────────────────────────────────────────── + const catalogClasses = new Set(); + for (const c of catRows) { + const nm = nameById.get(Number(String(c.itemIds ?? "").split(/[;,]/)[0])); + if (nm) catalogClasses.add(nm.replace(/\*/g, "_")); + } + + const files = readdirSync(ICON_DIR); + const iconSet = new Set(files); + let missingIcons = 0; + const missingIconExamples: string[] = []; + for (const cls of catalogClasses) { + const f = `${cls}_icon.png`; + if (!iconSet.has(f)) { + missingIcons++; + if (missingIconExamples.length < 15) missingIconExamples.push(f); + } + } + console.log( + `[4] catalog items without local icon file: ${missingIcons} of ${catalogClasses.size}`, + ); + if (missingIconExamples.length) + console.log(` examples: ${missingIconExamples.join(", ")}`); + + // duplicate-content icons across different classnames (suspicious downloads) + const byHash = new Map(); + for (const f of files) { + if (!f.endsWith("_icon.png")) continue; + const p = path.join(ICON_DIR, f); + const st = statSync(p); + if (st.size === 0) continue; + const h = md5(p); + const arr = byHash.get(h) ?? []; + arr.push(f); + byHash.set(h, arr); + } + let dupFiles = 0; + const dupGroups: string[][] = []; + for (const [, arr] of byHash) { + if (arr.length < 2) continue; + dupFiles += arr.length - 1; + if (dupGroups.length < 8) dupGroups.push(arr); + } + console.log( + ` byte-identical icon files across different classnames: ${dupFiles} extra copies`, + ); + for (const g of dupGroups) + console.log( + ` ${g.slice(0, 6).join(" == ")}${g.length > 6 ? ` (+${g.length - 6} more)` : ""}`, + ); + + let nitroMissing = 0; + const nitroSet = new Set(existsSync(NITRO_DIR) ? readdirSync(NITRO_DIR) : []); + for (const cls of catalogClasses) { + if (!nitroSet.has(`${cls}.nitro`)) nitroMissing++; + } + console.log(`[5] catalog items without .nitro bundle: ${nitroMissing}`); + + await db.$client.end(); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});