Fix TypeScript errors and implement furniture import ID integrity with 18+ age verification
CI / check (push) Successful in 29s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m2s

- Add termsAccepted and ageVerified columns to User table
- Update register schema with new boolean fields
- Fix register form age verification checkbox (th -> t)
- Fix furni-import spriteId declaration order
- Fix batch route variable naming (id -> spriteId)
- Fix catalog-audit import path and ensure correct types
- Hardened import with per-item id conflict checks
- Added audit option for FurnitureData.json spriteId conflicts
- Updated tagline to include Leeftijdsvereiste: 18+
This commit is contained in:
openhands committed 2026-08-14 16:37:00 +02:00
1 parent e5ec3c1f06
commit e76c530e4f
13 files changed
+267 -104

No files matched your search

+58
View File
@@ -16,6 +16,7 @@ import {
repairFurniData,
repairOrphanedCatalog,
} from "./catalog-repair";
import { readFurniData } from "./furni-data";
import { ensureDirectories } from "./furni-import";
import {
assetNameCandidates,
@@ -118,6 +119,7 @@ export interface AuditSummary {
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
furniDataIdConflicts: number;
}
export interface CatalogAuditOptions {
@@ -128,6 +130,7 @@ export interface CatalogAuditOptions {
organizeSql?: boolean;
repairFurniData?: boolean;
repairStructure?: boolean;
checkFurniDataIds?: boolean;
}
export async function runCatalogAudit(
@@ -722,6 +725,60 @@ export async function runCatalogAudit(
}
}
// ── FurnitureData.json id conflict check ──────────────
let furniDataIdConflicts: {
classname: string;
itemId: number;
conflictingId: number;
existingClassname: string;
}[] = [];
if (options?.checkFurniDataIds) {
onEvent?.({
type: "progress",
message: "Checking FurnitureData.json for spriteId conflicts…",
});
try {
const furniData = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>;
// Build map of id → classname from both sections
const idToClassname = new Map<number, string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of furniData[section]?.furnitype ?? []) {
const id = Number(e?.id);
const classname = typeof e?.classname === "string" ? e.classname : "";
if (!Number.isFinite(id) || id <= 0 || !classname) continue;
const existing = idToClassname.get(id);
if (existing && existing !== classname) {
// Conflict: same id used by different classname
furniDataIdConflicts.push({
classname,
itemId: id,
conflictingId: id,
existingClassname: existing,
});
} else if (!existing) {
idToClassname.set(id, classname);
}
}
}
// Remove duplicates (same classname + id pair counted once)
const seen = new Set<string>();
furniDataIdConflicts = furniDataIdConflicts.filter((c) => {
const key = `${c.classname}:${c.conflictingId}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
} catch (err) {
onEvent?.({
type: "error",
message: `FurnitureData id check failed: ${(err as Error).message}`,
});
}
}
onEvent?.({ type: "progress", message: "Comparing with clone sources…" });
const sources = await listSources();
@@ -811,6 +868,7 @@ export async function runCatalogAudit(
duplicatesMerged,
duplicateRowsRemoved,
remappedReferences,
furniDataIdConflicts: furniDataIdConflicts.length,
};
onEvent?.({
+87
View File
@@ -106,6 +106,77 @@ async function acquireDiskLock(lockPath: string): Promise<void> {
}
}
/** Build a map of sprite id → classname from both sections of the furnidata.
* If multiple entries share the same id, the first encountered classname wins. */
function furniDataIdOwners(
data: Record<string, { furnitype: Array<Record<string, unknown>> }>,
): Map<number, string> {
const owners = new Map<number, string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of data[section]?.furnitype ?? []) {
const id = Number(e?.id);
if (!Number.isFinite(id) || id <= 0) continue;
const classname = typeof e?.classname === "string" ? e.classname : "";
if (!classname) continue;
if (!owners.has(id)) owners.set(id, classname);
}
}
return owners;
}
/** Assert that no entry in `entries` has a spriteId already used by a different classname
* in the existing furnidata or within this batch. Throws if a conflict is found. */
function assertNoFurniDataIdConflicts(
existingOwners: Map<number, string>,
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
): void {
const conflicts: string[] = [];
for (const { entry } of entries) {
const id = Number(entry?.id);
if (!Number.isFinite(id) || id <= 0) continue;
const classname =
typeof entry?.classname === "string" ? entry.classname : "";
const existing = existingOwners.get(id);
if (existing !== undefined && existing !== classname) {
conflicts.push(
`spriteId ${id} already used by "${existing}" in FurnitureData.json ` +
`(attempted "${classname}")`,
);
continue;
}
// reserve the id within this batch so a second entry with the same id + different classname
// is also caught (even though the file hasn't been written yet).
existingOwners.set(id, classname);
}
if (conflicts.length > 0) {
throw new Error(
`FurnitureData spriteId conflicts: ${conflicts.join("; ")}`,
);
}
}
/** Check if a spriteId is already used by a different classname in the local
* FurnitureData.json. Returns the existing classname if there's a conflict,
* or null if the spriteId is free or the file couldn't be read. */
export async function findFurniDataIdConflict(
spriteId: number,
classname: string,
): Promise<string | null> {
try {
const furniData = await readFurniData();
const owners = furniDataIdOwners(
furniData as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>,
);
const existing = owners.get(spriteId);
return existing !== undefined && existing !== classname ? existing : null;
} catch {
return null; // if file missing/unreadable, treat as no conflict
}
}
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
let release!: () => void;
const acquired = new Promise<void>((r) => {
@@ -228,6 +299,11 @@ export async function appendFurniEntry(
string,
{ furnitype: Array<Record<string, unknown>> }
>;
// Guard: ensure the spriteId is not already used by a different classname in the file.
const existingOwners = furniDataIdOwners(furniData);
assertNoFurniDataIdConflicts(existingOwners, [{ entry, itemType }]);
if (itemType === "i") {
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] };
upsertEntryInSection(furniData.wallitemtypes, entry);
@@ -331,6 +407,17 @@ export async function appendFurniEntriesBatch(
string,
{ furnitype: unknown[] }
>;
// Guard: ensure no spriteId conflicts with existing file entries
// or within this batch (same id + different classname).
const existingOwners = furniDataIdOwners(
furniData as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>,
);
assertNoFurniDataIdConflicts(existingOwners, entries);
for (const { entry, itemType } of entries) {
if (itemType === "i") {
if (!furniData.wallitemtypes)
+1
View File
@@ -11,6 +11,7 @@ vi.mock("@/lib/services/furni-data", () => ({
appendFurniEntry: vi.fn(),
buildFurniEntry: vi.fn(),
removeFurniEntry: vi.fn(),
findFurniDataIdConflict: vi.fn(),
}));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
+11
View File
@@ -14,6 +14,7 @@ import {
import {
appendFurniEntry,
buildFurniEntry,
findFurniDataIdConflict,
removeFurniEntry,
} from "@/lib/services/furni-data";
import {
@@ -472,6 +473,16 @@ export async function importSingleFurni(params: {
};
}
// ── Check if spriteId already used in FurnitureData.json ──
const takenBy = await findFurniDataIdConflict(originalId, classname);
if (takenBy) {
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${takenBy}" in FurnitureData.json`,
};
}
const itemType = type === "wallitem" ? "i" : "s";
const spriteId = originalId;