1 Commits
Author SHA1 Message Date
remco 084e090ba3 chore(deps): update All dependencies
CI / check (pull_request) Successful in 24s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-03 21:00:36 +00:00
8 changed files with 70 additions and 579 deletions

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
+9 -9
View File
@@ -21,8 +21,8 @@ importers:
.: .:
dependencies: dependencies:
'@base-ui/react': '@base-ui/react':
specifier: ^1.7.0 specifier: ^1.6.0
version: 1.7.0(@types/[email protected])([email protected]([email protected]))([email protected]) version: 1.6.0(@types/[email protected])([email protected]([email protected]))([email protected])
'@dnd-kit/core': '@dnd-kit/core':
specifier: ^6.3.1 specifier: ^6.3.1
version: 6.3.1([email protected]([email protected]))([email protected]) version: 6.3.1([email protected]([email protected]))([email protected])
@@ -251,8 +251,8 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@base-ui/react@1.7.0': '@base-ui/react@1.6.0':
resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
'@date-fns/tz': ^1.2.0 '@date-fns/tz': ^1.2.0
@@ -268,8 +268,8 @@ packages:
date-fns: date-fns:
optional: true optional: true
'@base-ui/[email protected].2': '@base-ui/[email protected].1':
resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==}
peerDependencies: peerDependencies:
'@types/react': ^17 || ^18 || ^19 '@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19 react: ^17 || ^18 || ^19
@@ -4191,10 +4191,10 @@ snapshots:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
'@base-ui/react@1.7.0(@types/[email protected])([email protected]([email protected]))([email protected])': '@base-ui/react@1.6.0(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
'@base-ui/utils': 0.3.2(@types/[email protected])([email protected]([email protected]))([email protected]) '@base-ui/utils': 0.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
'@floating-ui/react-dom': 2.1.9([email protected]([email protected]))([email protected]) '@floating-ui/react-dom': 2.1.9([email protected]([email protected]))([email protected])
'@floating-ui/utils': 0.2.12 '@floating-ui/utils': 0.2.12
react: 19.2.8 react: 19.2.8
@@ -4203,7 +4203,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.18 '@types/react': 19.2.18
'@base-ui/[email protected].2(@types/[email protected])([email protected]([email protected]))([email protected])': '@base-ui/[email protected].1(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
'@floating-ui/utils': 0.2.12 '@floating-ui/utils': 0.2.12
+13 -72
View File
@@ -32,12 +32,6 @@ interface MissingFromSource {
itemName: string; itemName: string;
} }
interface UnrepairableItem {
classname: string;
itemId: number;
missing: Array<"nitro" | "icon">;
}
interface AuditSummary { interface AuditSummary {
totalItemsBase: number; totalItemsBase: number;
totalCatalogItems: number; totalCatalogItems: number;
@@ -47,7 +41,6 @@ interface AuditSummary {
missingIcon: number; missingIcon: number;
duplicates: number; duplicates: number;
missingFromSourcesTotal: number; missingFromSourcesTotal: number;
unrepairableCount: number;
iconsRepaired: number; iconsRepaired: number;
iconsStillMissing: number; iconsStillMissing: number;
nitrosRepaired: number; nitrosRepaired: number;
@@ -78,7 +71,6 @@ interface AuditEvent {
section?: string; section?: string;
issues?: AuditIssue[]; issues?: AuditIssue[];
missingFromSources?: MissingFromSource[]; missingFromSources?: MissingFromSource[];
unrepairable?: UnrepairableItem[];
summary?: AuditSummary; summary?: AuditSummary;
repair?: RepairStats; repair?: RepairStats;
repairNitro?: RepairStats; repairNitro?: RepairStats;
@@ -146,9 +138,7 @@ function AuditCheckbox({
export function AuditClient() { export function AuditClient() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(""); const [progress, setProgress] = useState("");
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<"issues" | "sources">("issues");
"issues" | "sources" | "unrepairable"
>("issues");
const [repairEnabled, setRepairEnabled] = useState(true); const [repairEnabled, setRepairEnabled] = useState(true);
const [repairNitros, setRepairNitros] = useState(true); const [repairNitros, setRepairNitros] = useState(true);
const [generateSql, setGenerateSql] = useState(false); const [generateSql, setGenerateSql] = useState(false);
@@ -160,7 +150,6 @@ export function AuditClient() {
const [missingFromSources, setMissingFromSources] = useState< const [missingFromSources, setMissingFromSources] = useState<
MissingFromSource[] MissingFromSource[]
>([]); >([]);
const [unrepairable, setUnrepairable] = useState<UnrepairableItem[]>([]);
const [summary, setSummary] = useState<AuditSummary | null>(null); const [summary, setSummary] = useState<AuditSummary | null>(null);
const [repairStats, setRepairStats] = useState<RepairStats | null>(null); const [repairStats, setRepairStats] = useState<RepairStats | null>(null);
const [nitroRepairStats, setNitroRepairStats] = useState<RepairStats | null>( const [nitroRepairStats, setNitroRepairStats] = useState<RepairStats | null>(
@@ -196,7 +185,6 @@ export function AuditClient() {
setProgress("Starting audit…"); setProgress("Starting audit…");
setIssues([]); setIssues([]);
setMissingFromSources([]); setMissingFromSources([]);
setUnrepairable([]);
setSummary(null); setSummary(null);
setRepairStats(null); setRepairStats(null);
setNitroRepairStats(null); setNitroRepairStats(null);
@@ -310,22 +298,21 @@ export function AuditClient() {
if (evt.type === "batch_complete") { if (evt.type === "batch_complete") {
setIssues(evt.issues ?? []); setIssues(evt.issues ?? []);
setMissingFromSources(evt.missingFromSources ?? []); setMissingFromSources(evt.missingFromSources ?? []);
setUnrepairable(evt.unrepairable ?? []);
if (evt.summary) setSummary(evt.summary); if (evt.summary) setSummary(evt.summary);
setCheckProgress(null); setCheckProgress(null);
setProgress("Done"); setProgress("Done");
const errCount = if (
evt.issues?.filter((i) => i.severity === "error").length ?? 0; evt.issues &&
const warnCount = evt.issues.filter((i) => i.severity === "error").length > 0
evt.issues?.filter((i) => i.severity === "warning").length ?? ) {
0; toast.error(
const legacyCount = evt.unrepairable?.length ?? 0; `${evt.issues.filter((i) => i.severity === "error").length} error(s) found`,
if (errCount > 0) { );
toast.error(`${errCount} error(s) found`); } else if (
} else if (warnCount > 0) { evt.issues &&
evt.issues.filter((i) => i.severity === "warning").length > 0
) {
toast.warning("Warnings found"); toast.warning("Warnings found");
} else if (legacyCount > 0) {
toast.info(`${legacyCount} legacy item(s) can't be repaired`);
} else { } else {
toast.success("No issues found"); toast.success("No issues found");
} }
@@ -542,17 +529,6 @@ export function AuditClient() {
sources sources
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{summary.unrepairableCount > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-text-muted)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>
{summary.unrepairableCount} item(s) can't be repaired
(legacy/broken)
</span>
</div>
{repairStats && ( {repairStats && (
<div className="flex items-center gap-2 col-span-full"> <div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -714,9 +690,7 @@ export function AuditClient() {
</div> </div>
{/* Tabs */} {/* Tabs */}
{issues.length > 0 || {issues.length > 0 || missingFromSources.length > 0 ? (
missingFromSources.length > 0 ||
unrepairable.length > 0 ? (
<div className="border-b border-border"> <div className="border-b border-border">
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
@@ -741,17 +715,6 @@ export function AuditClient() {
> >
Missing from sources ({missingFromSources.length}) Missing from sources ({missingFromSources.length})
</button> </button>
<button
type="button"
onClick={() => setActiveTab("unrepairable")}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "unrepairable"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Unrepairable (legacy) ({unrepairable.length})
</button>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -833,27 +796,6 @@ export function AuditClient() {
</div> </div>
)} )}
{/* Unrepairable (legacy) list */}
{activeTab === "unrepairable" && unrepairable.length > 0 && (
<div className="space-y-2">
{unrepairable.map((item) => (
<div
key={item.classname}
className="rounded-xl border bg-card p-3 text-sm flex items-center gap-2"
>
<AlertCircle className="w-3.5 h-3.5 text-[var(--admin-text-muted)] shrink-0" />
<code className="font-mono text-xs">{item.classname}</code>
<span className="text-xs text-muted-foreground">
(ID {item.itemId})
</span>
<span className="text-xs text-[var(--admin-warning)] ml-auto">
missing {item.missing.join(" + ")}
</span>
</div>
))}
</div>
)}
{activeTab === "issues" && {activeTab === "issues" &&
issues.length === 0 && issues.length === 0 &&
missingFromSources.length === 0 && ( missingFromSources.length === 0 && (
@@ -872,7 +814,6 @@ export function AuditClient() {
setSummary(null); setSummary(null);
setIssues([]); setIssues([]);
setMissingFromSources([]); setMissingFromSources([]);
setUnrepairable([]);
setRepairStats(null); setRepairStats(null);
setNitroRepairStats(null); setNitroRepairStats(null);
setSqlText(null); setSqlText(null);
+27 -115
View File
@@ -17,14 +17,6 @@ import {
repairOrphanedCatalog, repairOrphanedCatalog,
} from "./catalog-repair"; } from "./catalog-repair";
import { ensureDirectories } from "./furni-import"; import { ensureDirectories } from "./furni-import";
import {
assetNameCandidates,
type CatalogItemKind,
classifyCatalogItem,
isBadgeType,
NO_ASSET_KINDS,
NO_CATALOG_KINDS,
} from "./item-kind";
import { repairMissingIcons } from "./repair-icons"; import { repairMissingIcons } from "./repair-icons";
import { repairMissingNitros } from "./repair-nitros"; import { repairMissingNitros } from "./repair-nitros";
@@ -47,7 +39,6 @@ export interface AuditEvent {
section?: string; section?: string;
issues?: AuditIssue[]; issues?: AuditIssue[];
missingFromSources?: MissingFromSource[]; missingFromSources?: MissingFromSource[];
unrepairable?: UnrepairableItem[];
summary?: AuditSummary; summary?: AuditSummary;
repair?: RepairStats; repair?: RepairStats;
repairNitro?: RepairStats; repairNitro?: RepairStats;
@@ -89,12 +80,6 @@ export interface MissingFromSource {
itemName: string; itemName: string;
} }
export interface UnrepairableItem {
classname: string;
itemId: number;
missing: Array<"nitro" | "icon">;
}
export interface AuditSummary { export interface AuditSummary {
totalItemsBase: number; totalItemsBase: number;
totalCatalogItems: number; totalCatalogItems: number;
@@ -104,7 +89,6 @@ export interface AuditSummary {
missingIcon: number; missingIcon: number;
duplicates: number; duplicates: number;
missingFromSourcesTotal: number; missingFromSourcesTotal: number;
unrepairableCount: number;
iconsRepaired: number; iconsRepaired: number;
iconsStillMissing: number; iconsStillMissing: number;
nitrosRepaired: number; nitrosRepaired: number;
@@ -135,13 +119,6 @@ export async function runCatalogAudit(
): Promise<void> { ): Promise<void> {
let issues: AuditIssue[] = []; let issues: AuditIssue[] = [];
const missingFromSources: MissingFromSource[] = []; const missingFromSources: MissingFromSource[] = [];
// Items whose missing assets could not be restored from any source even
// after a repair attempt — legacy / broken furniture. Keyed by classname so
// an item missing both assets is listed once.
const unrepairable = new Map<
string,
{ itemId: number; missing: Set<"nitro" | "icon"> }
>();
try { try {
await ensureDirectories(); await ensureDirectories();
@@ -184,10 +161,7 @@ export async function runCatalogAudit(
]); ]);
const iconSet = new Set(iconFiles); const iconSet = new Set(iconFiles);
const nitroSet = new Set(nitroFiles); const nitroSet = new Set(nitroFiles);
// Badge detection: `items_base.type = 'b'` is authoritative. The album1584 const badgeSet = new Set(
// `.gif` listing is kept as a fallback for hotels that store badges with a
// non-standard type.
const badgeGifSet = new Set(
badgeGifFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), badgeGifFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
); );
@@ -198,19 +172,16 @@ export async function runCatalogAudit(
item_name: string; item_name: string;
public_name: string; public_name: string;
type: string; type: string;
interaction_type: string;
}>; }>;
try { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
SELECT id, item_name, public_name, type, interaction_type SELECT id, item_name, public_name, type FROM items_base ORDER BY id
FROM items_base ORDER BY id
`)) as unknown as [ `)) as unknown as [
Array<{ Array<{
id: number; id: number;
item_name: string; item_name: string;
public_name: string; public_name: string;
type: string; type: string;
interaction_type: string;
}>, }>,
unknown, unknown,
]; ];
@@ -223,21 +194,6 @@ export async function runCatalogAudit(
return; return;
} }
// item_name → catalog item kind (badge / pet / effect / bot / sticky / furni).
const kindByName = new Map<string, CatalogItemKind>();
for (const item of items) {
let kind = classifyCatalogItem(item);
if (
kind === "furni" &&
(isBadgeType(item.type) || badgeGifSet.has(item.item_name))
) {
kind = "badge";
}
kindByName.set(item.item_name, kind);
}
const kindOf = (name: string): CatalogItemKind =>
kindByName.get(name) ?? "furni";
onEvent?.({ type: "items_loaded", total: items.length }); onEvent?.({ type: "items_loaded", total: items.length });
onEvent?.({ type: "progress", message: "Loading catalog_items…" }); onEvent?.({ type: "progress", message: "Loading catalog_items…" });
@@ -294,10 +250,6 @@ export async function runCatalogAudit(
const missingCatalogEntries: MissingCatalogEntry[] = []; const missingCatalogEntries: MissingCatalogEntry[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
const kind = kindOf(item.item_name);
// Badges and system items (effects, bots, sticky notes) are awarded or
// system-managed, not catalog-purchasable — don't flag or auto-add them.
if (NO_CATALOG_KINDS.has(kind)) continue;
if (!itemIdsWithCatalog.has(item.id)) { if (!itemIdsWithCatalog.has(item.id)) {
issues.push({ issues.push({
type: "missing_catalog", type: "missing_catalog",
@@ -306,12 +258,11 @@ export async function runCatalogAudit(
itemId: item.id, itemId: item.id,
message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`, message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`,
}); });
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) => // Badge items use .gif files, not .nitro bundles.
nitroSet.has(n), if (
); !badgeSet.has(item.item_name) &&
// Pets ship their assets through the pet pipeline, not as furni nitroSet.has(`${item.item_name}.nitro`)
// .nitro bundles, so they can get a catalog entry regardless. ) {
if (kind === "pet" || hasNitro) {
missingCatalogEntries.push({ missingCatalogEntries.push({
classname: item.item_name, classname: item.item_name,
itemId: item.id, itemId: item.id,
@@ -380,13 +331,9 @@ export async function runCatalogAudit(
const missingNitroClassnames: string[] = []; const missingNitroClassnames: string[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
// Badges, pets, effects, bots and sticky notes never ship a furni // Badge items use .gif files, not .nitro bundles.
// .nitro bundle — skip the asset check for them. if (badgeSet.has(item.item_name)) continue;
if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue; if (!nitroSet.has(`${item.item_name}.nitro`)) {
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) =>
nitroSet.has(n),
);
if (!hasNitro) {
missingNitroClassnames.push(item.item_name); missingNitroClassnames.push(item.item_name);
issues.push({ issues.push({
type: "missing_nitro", type: "missing_nitro",
@@ -411,13 +358,10 @@ export async function runCatalogAudit(
const missingIconClassnames: string[] = []; const missingIconClassnames: string[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
// Badges use album1584 .gif icons; pets, effects, bots and sticky notes // Badge items use .gif icons (stored in album1584), not .png icon files.
// never ship a furni icon file — skip the asset check for them. if (badgeSet.has(item.item_name)) continue;
if (NO_ASSET_KINDS.has(kindOf(item.item_name))) continue; const iconFileName = `${item.item_name}_icon.png`;
const hasIcon = assetNameCandidates(item.item_name).icon.some((n) => if (!iconSet.has(iconFileName)) {
iconSet.has(n),
);
if (!hasIcon) {
missingIconClassnames.push(item.item_name); missingIconClassnames.push(item.item_name);
issues.push({ issues.push({
type: "missing_icon", type: "missing_icon",
@@ -465,26 +409,17 @@ export async function runCatalogAudit(
}); });
// Re-check which of the previously-missing icons are still missing // Re-check which of the previously-missing icons are still missing
// and drop the ones the repair fixed from the issue list. The ones // and drop the ones the repair fixed from the issue list.
// left over cannot be restored from any source → legacy/broken.
const stillMissing: string[] = []; const stillMissing: string[] = [];
for (const classname of missingIconClassnames) { for (const classname of missingIconClassnames) {
const hasIcon = assetNameCandidates(classname).icon.some((n) => if (!iconSet.has(`${classname}_icon.png`)) {
iconSet.has(n),
);
if (!hasIcon) {
stillMissing.push(classname); stillMissing.push(classname);
} }
} }
issues = issues.filter((i) => i.type !== "missing_icon"); issues = issues.filter(
for (const classname of stillMissing) { (i) =>
const entry = unrepairable.get(classname) ?? { i.type !== "missing_icon" || stillMissing.includes(i.classname ?? ""),
itemId: itemByName.get(classname)?.id ?? 0, );
missing: new Set(),
};
entry.missing.add("icon");
unrepairable.set(classname, entry);
}
repairStats = { repairStats = {
succeeded: result.succeeded, succeeded: result.succeeded,
@@ -529,26 +464,18 @@ export async function runCatalogAudit(
}); });
// Re-check which of the previously-missing nitros are still missing // Re-check which of the previously-missing nitros are still missing
// and drop the ones the repair fixed from the issue list. The ones // and drop the ones the repair fixed from the issue list.
// left over cannot be restored from any source → legacy/broken.
const stillMissing: string[] = []; const stillMissing: string[] = [];
for (const classname of missingNitroClassnames) { for (const classname of missingNitroClassnames) {
const hasNitro = assetNameCandidates(classname).nitro.some((n) => if (!nitroSet.has(`${classname}.nitro`)) {
nitroSet.has(n),
);
if (!hasNitro) {
stillMissing.push(classname); stillMissing.push(classname);
} }
} }
issues = issues.filter((i) => i.type !== "missing_nitro"); issues = issues.filter(
for (const classname of stillMissing) { (i) =>
const entry = unrepairable.get(classname) ?? { i.type !== "missing_nitro" ||
itemId: itemByName.get(classname)?.id ?? 0, stillMissing.includes(i.classname ?? ""),
missing: new Set(), );
};
entry.missing.add("nitro");
unrepairable.set(classname, entry);
}
nitroRepairStats = { nitroRepairStats = {
succeeded: result.succeeded, succeeded: result.succeeded,
@@ -746,24 +673,11 @@ export async function runCatalogAudit(
} }
} }
// Items that a repair attempt could not fix are legacy/broken furniture —
// surfaced as a separate informational group (not counted as errors) so a
// fully repaired catalog can reach 0 errors while still listing exactly
// what is not restorable.
issues.sort((a, b) => { issues.sort((a, b) => {
const sev = { error: 0, warning: 1, info: 2 }; const sev = { error: 0, warning: 1, info: 2 };
return (sev[a.severity] ?? 0) - (sev[b.severity] ?? 0); return (sev[a.severity] ?? 0) - (sev[b.severity] ?? 0);
}); });
const unrepairableList: UnrepairableItem[] = [...unrepairable.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([classname, entry]) => ({
classname,
itemId: entry.itemId,
missing: [...entry.missing].sort(),
}));
const summary: AuditSummary = { const summary: AuditSummary = {
totalItemsBase: items.length, totalItemsBase: items.length,
totalCatalogItems: catalogItems.length, totalCatalogItems: catalogItems.length,
@@ -773,7 +687,6 @@ export async function runCatalogAudit(
missingIcon: issues.filter((i) => i.type === "missing_icon").length, missingIcon: issues.filter((i) => i.type === "missing_icon").length,
duplicates: issues.filter((i) => i.type === "duplicate_classname").length, duplicates: issues.filter((i) => i.type === "duplicate_classname").length,
missingFromSourcesTotal: missingFromSources.length, missingFromSourcesTotal: missingFromSources.length,
unrepairableCount: unrepairableList.length,
iconsRepaired: repairStats?.succeeded ?? 0, iconsRepaired: repairStats?.succeeded ?? 0,
iconsStillMissing: iconsStillMissing:
repairStats?.stillMissing ?? missingIconClassnames.length, repairStats?.stillMissing ?? missingIconClassnames.length,
@@ -795,7 +708,6 @@ export async function runCatalogAudit(
type: "batch_complete", type: "batch_complete",
issues, issues,
missingFromSources, missingFromSources,
unrepairable: unrepairableList,
summary, summary,
}); });
} }
-188
View File
@@ -1,188 +0,0 @@
import { describe, expect, it } from "vitest";
import {
assetNameCandidates,
classifyCatalogItem,
isBadgeType,
isPetClassname,
isPetInteraction,
NO_ASSET_KINDS,
NO_CATALOG_KINDS,
} from "./item-kind";
describe("isBadgeType", () => {
it("returns true for badge type b (any case)", () => {
expect(isBadgeType("b")).toBe(true);
expect(isBadgeType("B")).toBe(true);
expect(isBadgeType(" b ")).toBe(true);
});
it("returns false for furni types", () => {
expect(isBadgeType("s")).toBe(false);
expect(isBadgeType("i")).toBe(false);
expect(isBadgeType("")).toBe(false);
});
});
describe("isPetClassname / isPetInteraction", () => {
it("recognises a0 pet<N> classnames", () => {
expect(isPetClassname("a0 pet0")).toBe(true);
expect(isPetClassname("a0 pet80")).toBe(true);
expect(isPetClassname(" a0 pet13 ")).toBe(true);
expect(isPetClassname("a0_pet0")).toBe(false);
expect(isPetClassname("bunny")).toBe(false);
});
it("recognises pet<N> interaction types", () => {
expect(isPetInteraction("pet0")).toBe(true);
expect(isPetInteraction(" pet13 ")).toBe(true);
expect(isPetInteraction("pet0 ")).toBe(true);
expect(isPetInteraction("postit")).toBe(false);
expect(isPetInteraction("default")).toBe(false);
expect(isPetInteraction(null)).toBe(false);
});
});
describe("classifyCatalogItem", () => {
it("classifies badges by type b", () => {
expect(
classifyCatalogItem({
type: "b",
item_name: "YZI68",
interaction_type: "default",
}),
).toBe("badge");
expect(
classifyCatalogItem({
type: "b",
item_name: "horse",
interaction_type: "default",
}),
).toBe("badge");
});
it("classifies pets by classname and interaction type", () => {
expect(
classifyCatalogItem({
type: "s",
item_name: "a0 pet0",
interaction_type: "pet0 ",
}),
).toBe("pet");
expect(
classifyCatalogItem({
type: "s",
item_name: "a0 pet37",
interaction_type: "pet13",
}),
).toBe("pet");
});
it("classifies effects, bots and sticky notes", () => {
expect(
classifyCatalogItem({
type: "s",
item_name: "avatar_effect158",
interaction_type: "default",
}),
).toBe("effect");
expect(
classifyCatalogItem({
type: "s",
item_name: "bot_generic",
interaction_type: "default",
}),
).toBe("bot");
expect(
classifyCatalogItem({
type: "s",
item_name: "rentable_bot_visitor_log",
interaction_type: "default",
}),
).toBe("bot");
expect(
classifyCatalogItem({
type: "s",
item_name: "post.it",
interaction_type: "postit",
}),
).toBe("sticky");
});
it("does not mistake furni classnames that share prefixes", () => {
expect(
classifyCatalogItem({
type: "s",
item_name: "botanical_garden",
interaction_type: "default",
}),
).toBe("furni");
// `effect_faketile` is a real purchasable furni item.
expect(
classifyCatalogItem({
type: "s",
item_name: "effect_faketile",
interaction_type: "default",
}),
).toBe("furni");
});
it("classifies normal furniture", () => {
expect(
classifyCatalogItem({
type: "s",
item_name: "table_silo_med",
interaction_type: "default",
}),
).toBe("furni");
expect(
classifyCatalogItem({
type: "i",
item_name: "wall_poster",
interaction_type: "default",
}),
).toBe("furni");
});
});
describe("kind sets", () => {
it("badges and pets never expect furni assets", () => {
expect(NO_ASSET_KINDS.has("badge")).toBe(true);
expect(NO_ASSET_KINDS.has("pet")).toBe(true);
expect(NO_ASSET_KINDS.has("effect")).toBe(true);
expect(NO_ASSET_KINDS.has("bot")).toBe(true);
expect(NO_ASSET_KINDS.has("sticky")).toBe(true);
expect(NO_ASSET_KINDS.has("furni")).toBe(false);
});
it("pets are catalog-purchasable, badges and system items are not", () => {
expect(NO_CATALOG_KINDS.has("badge")).toBe(true);
expect(NO_CATALOG_KINDS.has("effect")).toBe(true);
expect(NO_CATALOG_KINDS.has("bot")).toBe(true);
expect(NO_CATALOG_KINDS.has("sticky")).toBe(true);
expect(NO_CATALOG_KINDS.has("pet")).toBe(false);
expect(NO_CATALOG_KINDS.has("furni")).toBe(false);
});
});
describe("assetNameCandidates", () => {
it("returns the exact name first for plain classnames", () => {
expect(assetNameCandidates("table_silo_med")).toEqual({
nitro: ["table_silo_med.nitro"],
icon: ["table_silo_med_icon.png"],
});
});
it("adds the base classname for star variants", () => {
expect(assetNameCandidates("table_silo_med*10").nitro).toContain(
"table_silo_med.nitro",
);
expect(assetNameCandidates("table_silo_med*10").icon).toContain(
"table_silo_med_10_icon.png",
);
});
it("adds the underscore name for dotted classnames", () => {
expect(assetNameCandidates("post.it").nitro).toContain("post_it.nitro");
expect(assetNameCandidates("post.it").icon).toContain("post_it_icon.png");
});
});
-108
View File
@@ -1,108 +0,0 @@
/**
* Classification of `items_base` rows so the catalog audit and repairs know
* exactly which items are expected to ship normal furni assets (`.nitro`
* bundles + `_icon.png` files) and which ones are special:
*
* - **badge** — stored with type `b`. Badges use album1584 `.gif` files and
* never ship furni `.nitro`/`_icon.png` assets. They are awarded, not
* purchased through the catalog.
* - **pet** — pet purchase items (`a0 pet<N>`, interaction `pet<N>`). Pet
* assets live in the pet asset pipeline (`public/nitro-assets/bundled/pet`),
* not the furniture bundle dirs.
* - **effect / bot / sticky** — system items with no furniture visuals at all.
* - **furni** — everything else; must ship a `.nitro` and `_icon.png`.
*/
export type CatalogItemKind =
| "badge"
| "pet"
| "effect"
| "bot"
| "sticky"
| "furni";
export interface CatalogItemLike {
type: string;
item_name: string;
interaction_type?: string | null;
}
/** Badges are stored in `items_base` with type `b`. */
export function isBadgeType(type: string): boolean {
return type.trim().toLowerCase() === "b";
}
/** Pet purchase items use classnames like `a0 pet0` … `a0 pet80`. */
export function isPetClassname(itemName: string): boolean {
return /^a0\s*pet\d+$/i.test(itemName.trim());
}
/** Pet purchase items use `pet<N>` as their interaction type. */
export function isPetInteraction(interactionType?: string | null): boolean {
return /^pet\d+$/i.test((interactionType ?? "").trim());
}
/** Classify an `items_base` row into a catalog item kind. */
export function classifyCatalogItem(item: CatalogItemLike): CatalogItemKind {
const name = item.item_name.trim();
if (isBadgeType(item.type)) return "badge";
if (isPetClassname(name) || isPetInteraction(item.interaction_type)) {
return "pet";
}
if (/^avatar_effect/i.test(name)) return "effect";
if (/^(bot_|rentable_bot_)/i.test(name)) return "bot";
if (/^post[._]?it/i.test(name)) return "sticky";
return "furni";
}
/** Kinds that never ship furni `.nitro` / `_icon.png` assets. */
export const NO_ASSET_KINDS: ReadonlySet<CatalogItemKind> = new Set([
"badge",
"pet",
"effect",
"bot",
"sticky",
]);
/** Kinds that are not purchasable furniture and should not get catalog entries. */
export const NO_CATALOG_KINDS: ReadonlySet<CatalogItemKind> = new Set([
"badge",
"effect",
"bot",
"sticky",
]);
/**
* Asset file names for a classname, mirroring how `importSingleFurni` writes
* files. Returns `.nitro` and icon candidates (most specific first):
* the exact classname, the star-variant base, the star→underscore name and the
* dot→underscore name (`post.it` → `post_it.nitro`).
*/
export function assetNameCandidates(classname: string): {
nitro: string[];
icon: string[];
} {
const unique = (names: string[]): string[] => {
const seen = new Set<string>();
const out: string[] = [];
for (const n of names) {
if (!n || seen.has(n)) continue;
seen.add(n);
out.push(n);
}
return out;
};
const starBase = classname.split("*")[0];
const starUnderscore = classname.replace(/\*/g, "_");
const dotUnderscore = classname.replace(/\./g, "_");
return {
nitro: unique([classname, starBase, dotUnderscore, starUnderscore]).map(
(n) => `${n}.nitro`,
),
icon: unique([classname, starUnderscore, dotUnderscore, starBase]).map(
(n) => `${n}_icon.png`,
),
};
}
+10 -43
View File
@@ -15,7 +15,6 @@ import {
getGamedataRoot, getGamedataRoot,
} from "@/lib/services/furni-asset-dirs"; } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download"; import { downloadFile } from "@/lib/services/import/core/download";
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
import { ensureDirectories } from "./furni-import"; import { ensureDirectories } from "./furni-import";
const REPAIR_CONCURRENCY = 6; const REPAIR_CONCURRENCY = 6;
@@ -142,44 +141,28 @@ export async function repairMissingIcons(
...targets.mirrorDirs.map((d) => d.nitroDir), ...targets.mirrorDirs.map((d) => d.nitroDir),
]); ]);
// Badges and other special items (pets, effects, bots, sticky notes) never // Load known badge codes from the album directory so we can skip badge
// ship furni `_icon.png` files — resolve the skip set from the DB so badge // items — they use .gif files, not .png icons.
// detection is precise (`items_base.type = 'b'`) instead of relying only on let badgeSet: Set<string> | null = null;
// which .gif files happen to exist in the album directory.
let skipSet: Set<string> | null = null;
try { try {
const gamedataRoot = await getGamedataRoot(); const gamedataRoot = await getGamedataRoot();
if (gamedataRoot) { if (gamedataRoot) {
const badgeFiles = await fs const badgeFiles = await fs
.readdir(path.join(gamedataRoot, "album1584")) .readdir(path.join(gamedataRoot, "album1584"))
.catch(() => [] as string[]); .catch(() => [] as string[]);
const albumBadgeSet = new Set( badgeSet = new Set(
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
); );
skipSet = albumBadgeSet;
} }
} catch { } catch {
skipSet = null; badgeSet = null;
} }
let items: Array<{ let items: Array<{ id: number; item_name: string }>;
id: number;
item_name: string;
type: string;
interaction_type: string;
}>;
try { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id SELECT id, item_name FROM items_base ORDER BY id
`)) as unknown as [ `)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
Array<{
id: number;
item_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = rows; items = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
@@ -189,24 +172,13 @@ export async function repairMissingIcons(
return { succeeded: 0, failed: 0, skipped: 0 }; return { succeeded: 0, failed: 0, skipped: 0 };
} }
// Build the authoritative skip set from the DB, supplemented by album .gifs.
const dbSkipSet = new Set<string>();
for (const item of items) {
if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) {
dbSkipSet.add(item.item_name);
}
}
for (const code of skipSet ?? []) {
dbSkipSet.add(code);
}
const total = items.length; const total = items.length;
onEvent?.({ type: "started", total }); onEvent?.({ type: "started", total });
const processItem = async (classname: string) => { const processItem = async (classname: string) => {
// Special items (badges, pets, effects, bots, sticky notes) don't have // Badge items that only exist as .gif files (in album1584) don't have
// .png icon files to repair. Skip them. // .png icon files to repair. Skip them.
if (dbSkipSet.has(classname)) { if (badgeSet?.has(classname)) {
return { status: "skipped" } as const; return { status: "skipped" } as const;
} }
const fileName = `${classname}_icon.png`; const fileName = `${classname}_icon.png`;
@@ -297,11 +269,6 @@ export async function repairMissingIcons(
`${classname.replace(/\*/g, "_")}_icon.png`, `${classname.replace(/\*/g, "_")}_icon.png`,
] ]
: []), : []),
// Classnames with dots (`post.it`) are stored on disk with
// underscores (`post_it_icon.png`).
...(classname.includes(".")
? [`${classname.replace(/\./g, "_")}_icon.png`]
: []),
]; ];
for (const iconName of iconCandidates) { for (const iconName of iconCandidates) {
+10 -43
View File
@@ -14,7 +14,6 @@ import {
getGamedataRoot, getGamedataRoot,
} from "@/lib/services/furni-asset-dirs"; } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download"; import { downloadFile } from "@/lib/services/import/core/download";
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
import { ensureDirectories } from "./furni-import"; import { ensureDirectories } from "./furni-import";
@@ -138,43 +137,28 @@ export async function repairMissingNitros(
...targets.mirrorDirs.map((d) => d.nitroDir), ...targets.mirrorDirs.map((d) => d.nitroDir),
]); ]);
// Badges and other special items (pets, effects, bots, sticky notes) never // Load known badge codes from the album directory so we can skip badge
// ship furni `.nitro` bundles — resolve the skip set from the DB so badge // items — they use .gif files, not .nitro bundles.
// detection is precise (`items_base.type = 'b'`) instead of relying only on let badgeSet: Set<string> | null = null;
// which .gif files happen to exist in the album directory.
let albumBadgeSet: Set<string> | null = null;
try { try {
const gamedataRoot = await getGamedataRoot(); const gamedataRoot = await getGamedataRoot();
if (gamedataRoot) { if (gamedataRoot) {
const badgeFiles = await fs const badgeFiles = await fs
.readdir(path.join(gamedataRoot, "album1584")) .readdir(path.join(gamedataRoot, "album1584"))
.catch(() => [] as string[]); .catch(() => [] as string[]);
albumBadgeSet = new Set( badgeSet = new Set(
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
); );
} }
} catch { } catch {
albumBadgeSet = null; badgeSet = null;
} }
let items: Array<{ let items: Array<{ id: number; item_name: string }>;
id: number;
item_name: string;
type: string;
interaction_type: string;
}>;
try { try {
const [rows] = (await db.execute(sql` const [rows] = (await db.execute(sql`
SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id SELECT id, item_name FROM items_base ORDER BY id
`)) as unknown as [ `)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
Array<{
id: number;
item_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = rows; items = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
@@ -184,24 +168,12 @@ export async function repairMissingNitros(
return { succeeded: 0, failed: 0, skipped: 0 }; return { succeeded: 0, failed: 0, skipped: 0 };
} }
// Build the authoritative skip set from the DB, supplemented by album .gifs.
const skipSet = new Set<string>();
for (const item of items) {
if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) {
skipSet.add(item.item_name);
}
}
for (const code of albumBadgeSet ?? []) {
skipSet.add(code);
}
const total = items.length; const total = items.length;
onEvent?.({ type: "started", total }); onEvent?.({ type: "started", total });
const processItem = async (classname: string) => { const processItem = async (classname: string) => {
// Special items (badges, pets, effects, bots, sticky notes) don't have // Badge items use .gif files, not .nitro bundles.
// furni .nitro bundles to repair. Skip them. if (badgeSet?.has(classname) ?? false) {
if (skipSet.has(classname)) {
return { status: "skipped" } as const; return { status: "skipped" } as const;
} }
const fileName = `${classname}.nitro`; const fileName = `${classname}.nitro`;
@@ -257,11 +229,6 @@ export async function repairMissingNitros(
`${classname.replace(/\*/g, "_")}.nitro`, `${classname.replace(/\*/g, "_")}.nitro`,
] ]
: []), : []),
// Classnames with dots (`post.it`) are stored on disk with
// underscores (`post_it.nitro`).
...(classname.includes(".")
? [`${classname.replace(/\./g, "_")}.nitro`]
: []),
]; ];
for (const source of sources) { for (const source of sources) {