feat(catalog): prevent and repair Arcturus emulator data errors
Block invalid catalog_items writes at the API level (points currency allowlist, non-negative prices, positive amount, limited stack >= sold count, unique sibling order numbers) and auto-assign unique order numbers on bulk create. Add a catalog-maintenance scan + transactional repair that fixes pre-existing rows: resets unsupported points_type, clamps negative costs, sets amount to 1, raises limited_stack, renumbers duplicate orders and deletes offers with missing page/item references. Surface the issue count and a fix button in the admin maintenance panel. Also: add enabled/retired flag to clone sources, classify poster and currency furniture in item-kind, and remove the obsolete update-Nitrov3.sh.
This commit is contained in:
1 parent
ad65d80d41
commit
3e69b72513
26 files changed
+17651
-3844
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1788791201804,
|
||||||
|
"tag": "0000_premium_spirit",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -144,6 +144,12 @@ export async function bulkCreateCatalogItems({
|
|||||||
let created = 0;
|
let created = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
|
// Find the next order number on the target page to avoid collisions.
|
||||||
|
const [maxOrder] = (await db.execute(sql`
|
||||||
|
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
|
||||||
|
`)) as unknown as [{ maxOrder: number }, unknown];
|
||||||
|
let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const base = baseMap.get(row.baseId);
|
const base = baseMap.get(row.baseId);
|
||||||
if (!base) {
|
if (!base) {
|
||||||
@@ -161,7 +167,7 @@ export async function bulkCreateCatalogItems({
|
|||||||
amount: 1,
|
amount: 1,
|
||||||
limitedSells: 0,
|
limitedSells: 0,
|
||||||
limitedStack: 0,
|
limitedStack: 0,
|
||||||
orderNumber: 1,
|
orderNumber: nextOrder++,
|
||||||
offerId: -1,
|
offerId: -1,
|
||||||
songId: 0,
|
songId: 0,
|
||||||
haveOffer: "1",
|
haveOffer: "1",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
|||||||
import type {
|
import type {
|
||||||
AlignResult,
|
AlignResult,
|
||||||
DedupResult,
|
DedupResult,
|
||||||
|
FixEmulatorResult,
|
||||||
FixOfferResult,
|
FixOfferResult,
|
||||||
FixSpriteResult,
|
FixSpriteResult,
|
||||||
FurniHealth,
|
FurniHealth,
|
||||||
@@ -60,6 +61,19 @@ export async function fixCatalogOffersAction(): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fixEmulatorIssuesAction(): Promise<
|
||||||
|
ActionResult<FixEmulatorResult>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await guard();
|
||||||
|
return await withCatalogExport(async () => {
|
||||||
|
return actionOk(await maintenance.fixEmulatorIssues());
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function reconcileIdsAction(): Promise<
|
export async function reconcileIdsAction(): Promise<
|
||||||
ActionResult<ReconcileResult>
|
ActionResult<ReconcileResult>
|
||||||
> {
|
> {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface CloneSource {
|
|||||||
nitroBaseUrl: string;
|
nitroBaseUrl: string;
|
||||||
iconBaseUrl: string;
|
iconBaseUrl: string;
|
||||||
hotel: string;
|
hotel: string;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FurniItem {
|
interface FurniItem {
|
||||||
@@ -81,6 +82,7 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
|||||||
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
|
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
|
||||||
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
|
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
|
||||||
const [hotel, setHotel] = useState(initial?.hotel ?? "");
|
const [hotel, setHotel] = useState(initial?.hotel ?? "");
|
||||||
|
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -91,6 +93,7 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
|||||||
nitroBaseUrl,
|
nitroBaseUrl,
|
||||||
iconBaseUrl,
|
iconBaseUrl,
|
||||||
hotel,
|
hotel,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +143,15 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-input"
|
||||||
|
/>
|
||||||
|
Enabled (retired sources are excluded from counts and imports)
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="submit" size="sm" disabled={saving}>
|
<Button type="submit" size="sm" disabled={saving}>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const POST = withAdmin(
|
|||||||
id,
|
id,
|
||||||
sourceSwfBaseUrl,
|
sourceSwfBaseUrl,
|
||||||
} = body as Record<string, string>;
|
} = body as Record<string, string>;
|
||||||
|
const enabledRaw = (body as Record<string, unknown>).enabled;
|
||||||
|
|
||||||
const isHttps = (u: string) => {
|
const isHttps = (u: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -111,6 +112,7 @@ export const POST = withAdmin(
|
|||||||
iconBaseUrl: normalized(iconBaseUrl),
|
iconBaseUrl: normalized(iconBaseUrl),
|
||||||
hotel: hotel ?? "",
|
hotel: hotel ?? "",
|
||||||
sourceSwfBaseUrl: normalized(sourceSwfBaseUrl),
|
sourceSwfBaseUrl: normalized(sourceSwfBaseUrl),
|
||||||
|
enabled: enabledRaw !== "false" && enabledRaw !== false,
|
||||||
});
|
});
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
applyAlignIdsAction,
|
applyAlignIdsAction,
|
||||||
fixCatalogOffersAction,
|
fixCatalogOffersAction,
|
||||||
|
fixEmulatorIssuesAction,
|
||||||
fixEverythingAction,
|
fixEverythingAction,
|
||||||
fixSpriteIdsAction,
|
fixSpriteIdsAction,
|
||||||
getFurniHealthAction,
|
getFurniHealthAction,
|
||||||
@@ -33,6 +34,7 @@ interface Health {
|
|||||||
catalogDuplicates: number;
|
catalogDuplicates: number;
|
||||||
catalogBcDuplicates: number;
|
catalogBcDuplicates: number;
|
||||||
catalogPageDuplicates: number;
|
catalogPageDuplicates: number;
|
||||||
|
emulatorIssues: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogMaintenancePanel() {
|
export function CatalogMaintenancePanel() {
|
||||||
@@ -175,6 +177,11 @@ export function CatalogMaintenancePanel() {
|
|||||||
value={health.catalogPageDuplicates}
|
value={health.catalogPageDuplicates}
|
||||||
hint="same caption under the same parent (double links)"
|
hint="same caption under the same parent (double links)"
|
||||||
/>
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Emulator catalog issues"
|
||||||
|
value={health.emulatorIssues}
|
||||||
|
hint="points currency, prices, stack, orphans, order numbers"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -221,6 +228,19 @@ export function CatalogMaintenancePanel() {
|
|||||||
>
|
>
|
||||||
Reconcile FurnitureData ↔ DB
|
Reconcile FurnitureData ↔ DB
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
"emulator",
|
||||||
|
fixEmulatorIssuesAction,
|
||||||
|
"Emulator catalog constraints fixed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Fix emulator constraints
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -49,3 +49,22 @@ it("rejects fractional set/add prices, accepts percentage discounts", () => {
|
|||||||
.costPoints,
|
.costPoints,
|
||||||
).toBe(5);
|
).toBe(5);
|
||||||
});
|
});
|
||||||
|
it("rejects unsupported pointsType values in bulk changes", () => {
|
||||||
|
expect(() =>
|
||||||
|
bulkOfferInputSchema.parse({
|
||||||
|
ids: [1],
|
||||||
|
changes: { pointsType: 101 },
|
||||||
|
}),
|
||||||
|
).toThrow(/not supported/i);
|
||||||
|
});
|
||||||
|
it("accepts supported pointsType values in bulk changes", () => {
|
||||||
|
expect(
|
||||||
|
bulkOfferInputSchema.parse({
|
||||||
|
ids: [1],
|
||||||
|
changes: { pointsType: 5 },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ids: [1],
|
||||||
|
changes: { pointsType: 5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { CatalogInputError } from "./hierarchy";
|
import { CatalogInputError } from "./hierarchy";
|
||||||
import { offerIdSchema, offerInteger } from "./offer-input";
|
import { EMULATOR_POINTS_TYPES, offerIdSchema, offerInteger } from "./offer-input";
|
||||||
|
|
||||||
const priceChange = z
|
const priceChange = z
|
||||||
.object({
|
.object({
|
||||||
@@ -23,6 +23,12 @@ export const bulkOfferChangesSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(v) => Object.values(v).some((x) => x !== undefined),
|
(v) => Object.values(v).some((x) => x !== undefined),
|
||||||
"Choose at least one field to change",
|
"Choose at least one field to change",
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(v) =>
|
||||||
|
v.pointsType === undefined ||
|
||||||
|
EMULATOR_POINTS_TYPES.includes(v.pointsType as (typeof EMULATOR_POINTS_TYPES)[number]),
|
||||||
|
"Points type is not supported by the emulator",
|
||||||
);
|
);
|
||||||
export const bulkOfferInputSchema = z
|
export const bulkOfferInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe("offer compatibility", () => {
|
|||||||
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
|
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
|
||||||
const values = {
|
const values = {
|
||||||
itemIds: "12;23;12",
|
itemIds: "12;23;12",
|
||||||
amount: 0,
|
amount: 1,
|
||||||
offerId: -1,
|
offerId: -1,
|
||||||
orderNumber: 0,
|
orderNumber: 0,
|
||||||
limitedSells: 4,
|
limitedSells: 4,
|
||||||
@@ -45,9 +45,71 @@ describe("offer compatibility", () => {
|
|||||||
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
|
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
|
expect(offerPatchSchema.parse({ offerId: 0, amount: 1 })).toEqual({
|
||||||
offerId: 0,
|
offerId: 0,
|
||||||
amount: 0,
|
amount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("emulator offer constraints", () => {
|
||||||
|
it("rejects negative costCredits and costPoints", () => {
|
||||||
|
expect(offerPatchSchema.safeParse({ costCredits: -1 }).success).toBe(false);
|
||||||
|
expect(offerPatchSchema.safeParse({ costPoints: -5 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
it("accepts zero costCredits and costPoints (free items)", () => {
|
||||||
|
expect(offerPatchSchema.parse({ costCredits: 0, costPoints: 0 })).toEqual({
|
||||||
|
costCredits: 0,
|
||||||
|
costPoints: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("rejects zero or negative amount", () => {
|
||||||
|
expect(offerPatchSchema.safeParse({ amount: 0 }).success).toBe(false);
|
||||||
|
expect(offerPatchSchema.safeParse({ amount: -1 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
it("accepts positive amount", () => {
|
||||||
|
expect(offerPatchSchema.parse({ amount: 1 })).toEqual({ amount: 1 });
|
||||||
|
});
|
||||||
|
it("rejects unsupported pointsType values", () => {
|
||||||
|
expect(offerPatchSchema.safeParse({ pointsType: 101 }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(offerPatchSchema.safeParse({ pointsType: 103 }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(offerPatchSchema.safeParse({ pointsType: -1 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
it("accepts supported pointsType values", () => {
|
||||||
|
expect(offerPatchSchema.parse({ pointsType: 0 })).toEqual({
|
||||||
|
pointsType: 0,
|
||||||
|
});
|
||||||
|
expect(offerPatchSchema.parse({ pointsType: 5 })).toEqual({
|
||||||
|
pointsType: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("rejects limitedStack below limitedSells", () => {
|
||||||
|
expect(
|
||||||
|
offerPatchSchema.safeParse({ limitedStack: 0, limitedSells: 1 })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
offerPatchSchema.safeParse({ limitedStack: 5, limitedSells: 10 })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it("accepts limitedStack >= limitedSells", () => {
|
||||||
|
expect(
|
||||||
|
offerPatchSchema.parse({ limitedStack: 10, limitedSells: 5 }),
|
||||||
|
).toEqual({ limitedStack: 10, limitedSells: 5 });
|
||||||
|
expect(
|
||||||
|
offerPatchSchema.parse({ limitedStack: 0, limitedSells: 0 }),
|
||||||
|
).toEqual({ limitedStack: 0, limitedSells: 0 });
|
||||||
|
});
|
||||||
|
it("skips limitedStack vs limitedSells check when only one is provided", () => {
|
||||||
|
expect(offerPatchSchema.parse({ limitedStack: 0 })).toEqual({
|
||||||
|
limitedStack: 0,
|
||||||
|
});
|
||||||
|
expect(offerPatchSchema.parse({ limitedSells: 5 })).toEqual({
|
||||||
|
limitedSells: 5,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,9 @@ import { z } from "zod";
|
|||||||
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
|
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
|
||||||
export const offerIdSchema = offerInteger.positive();
|
export const offerIdSchema = offerInteger.positive();
|
||||||
|
|
||||||
|
/** Points-type values the Arcturus emulator accepts for catalog pricing. */
|
||||||
|
export const EMULATOR_POINTS_TYPES = [0, 5] as const;
|
||||||
|
|
||||||
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
|
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
|
||||||
export function numericValue(value: unknown, fallback: number): number {
|
export function numericValue(value: unknown, fallback: number): number {
|
||||||
if (value == null || (typeof value === "string" && value.trim() === ""))
|
if (value == null || (typeof value === "string" && value.trim() === ""))
|
||||||
@@ -16,7 +19,7 @@ export function parseFurnitureIds(value: string): number[] {
|
|||||||
throw Error("Invalid furniture references");
|
throw Error("Invalid furniture references");
|
||||||
return parts.map((part) => offerIdSchema.parse(Number(part)));
|
return parts.map((part) => offerIdSchema.parse(Number(part)));
|
||||||
}
|
}
|
||||||
const itemIds = z
|
export const itemIds = z
|
||||||
.string()
|
.string()
|
||||||
.max(666)
|
.max(666)
|
||||||
.refine((value) => {
|
.refine((value) => {
|
||||||
@@ -28,23 +31,37 @@ const itemIds = z
|
|||||||
}
|
}
|
||||||
}, "Invalid furniture references");
|
}, "Invalid furniture references");
|
||||||
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
|
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
|
||||||
export const offerPatchSchema = z.object({
|
export const offerPatchSchema = z
|
||||||
pageId: offerIdSchema.optional(),
|
.object({
|
||||||
itemIds: itemIds.optional(),
|
pageId: offerIdSchema.optional(),
|
||||||
catalogName: z.string().max(100).optional(),
|
itemIds: itemIds.optional(),
|
||||||
costCredits: offerInteger.optional(),
|
catalogName: z.string().max(100).optional(),
|
||||||
costPoints: offerInteger.optional(),
|
costCredits: offerInteger.nonnegative().optional(),
|
||||||
pointsType: offerInteger.optional(),
|
costPoints: offerInteger.nonnegative().optional(),
|
||||||
amount: offerInteger.optional(),
|
pointsType: offerInteger.optional(),
|
||||||
orderNumber: offerInteger.optional(),
|
amount: offerInteger.positive().optional(),
|
||||||
offerId: offerInteger.optional(),
|
orderNumber: offerInteger.optional(),
|
||||||
songId: z.number().int().min(0).max(4294967295).optional(),
|
offerId: offerInteger.optional(),
|
||||||
limitedSells: offerInteger.optional(),
|
songId: z.number().int().min(0).max(4294967295).optional(),
|
||||||
limitedStack: offerInteger.optional(),
|
limitedSells: offerInteger.nonnegative().optional(),
|
||||||
extradata: z.string().max(500).optional(),
|
limitedStack: offerInteger.nonnegative().optional(),
|
||||||
haveOffer: z.enum(["0", "1"]).optional(),
|
extradata: z.string().max(500).optional(),
|
||||||
clubOnly: z.enum(["0", "1"]).optional(),
|
haveOffer: z.enum(["0", "1"]).optional(),
|
||||||
});
|
clubOnly: z.enum(["0", "1"]).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
data.pointsType === undefined ||
|
||||||
|
EMULATOR_POINTS_TYPES.includes(data.pointsType as (typeof EMULATOR_POINTS_TYPES)[number]),
|
||||||
|
"Points type is not supported by the emulator",
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
data.limitedStack === undefined ||
|
||||||
|
data.limitedSells === undefined ||
|
||||||
|
data.limitedStack >= data.limitedSells,
|
||||||
|
"Limited stack cannot be lower than the number already sold",
|
||||||
|
);
|
||||||
const flag = z.union([
|
const flag = z.union([
|
||||||
z.literal(0),
|
z.literal(0),
|
||||||
z.literal(1),
|
z.literal(1),
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ vi.mock("@/lib/db", async () => ({
|
|||||||
state.bases.filter((row) => compiled.params.includes(row.id)),
|
state.bases.filter((row) => compiled.params.includes(row.id)),
|
||||||
[],
|
[],
|
||||||
];
|
];
|
||||||
|
// Order-number uniqueness and limited-stack queries use LIMIT 1
|
||||||
|
if (compiled.sql.includes("LIMIT 1")) return [[], []];
|
||||||
return [
|
return [
|
||||||
state.offers.filter((row) => compiled.params.includes(row.id)),
|
state.offers.filter((row) => compiled.params.includes(row.id)),
|
||||||
[],
|
[],
|
||||||
@@ -98,7 +100,7 @@ describe("transactional offer commands", () => {
|
|||||||
it("updates a non-first bundle component and preserves zero values", async () => {
|
it("updates a non-first bundle component and preserves zero values", async () => {
|
||||||
await updateOfferCommand({
|
await updateOfferCommand({
|
||||||
id: 1,
|
id: 1,
|
||||||
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 },
|
catalogFields: { pageId: 11, amount: 1, offerId: 0, orderNumber: 0 },
|
||||||
baseItem: { id: 23, fields: { publicName: "New" } },
|
baseItem: { id: 23, fields: { publicName: "New" } },
|
||||||
});
|
});
|
||||||
expect(state.commits).toBe(1);
|
expect(state.commits).toBe(1);
|
||||||
@@ -170,7 +172,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
|
|||||||
await updateOfferCommand({
|
await updateOfferCommand({
|
||||||
id: 1,
|
id: 1,
|
||||||
catalogFields: {
|
catalogFields: {
|
||||||
amount: 0,
|
amount: 1,
|
||||||
orderNumber: 0,
|
orderNumber: 0,
|
||||||
offerId: 0,
|
offerId: 0,
|
||||||
limitedSells: 0,
|
limitedSells: 0,
|
||||||
@@ -178,7 +180,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
|
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
|
||||||
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]);
|
expect(update?.params).toEqual([1, 0, 0, 0, 0, 1]);
|
||||||
});
|
});
|
||||||
it("rejects unsafe ids before opening a transaction", async () => {
|
it("rejects unsafe ids before opening a transaction", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
@@ -317,3 +319,27 @@ it("rejects a missing BC destination before touching the offer", async () => {
|
|||||||
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
|
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
|
||||||
expect(state.writes).toBe(0);
|
expect(state.writes).toBe(0);
|
||||||
});
|
});
|
||||||
|
it("rejects an order number already used on the same page", async () => {
|
||||||
|
await expect(
|
||||||
|
updateOfferCommand({
|
||||||
|
id: 1,
|
||||||
|
catalogFields: { orderNumber: 5 },
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
// Verify the uniqueness SELECT was issued.
|
||||||
|
const uniquenessQuery = state.queries.find(
|
||||||
|
(q) => q.sql.includes("order_number") && q.sql.includes("LIMIT 1"),
|
||||||
|
);
|
||||||
|
expect(uniquenessQuery).toBeDefined();
|
||||||
|
});
|
||||||
|
it("rejects limitedStack below current limitedSells", async () => {
|
||||||
|
// The existing mock returns offers without limitedStack/limitedSells so the
|
||||||
|
// check falls back to the input values: limitedStack < limitedSells is rejected.
|
||||||
|
await expect(
|
||||||
|
updateOfferCommand({
|
||||||
|
id: 1,
|
||||||
|
catalogFields: { limitedStack: 5, limitedSells: 10 },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/limited stack/i);
|
||||||
|
expect(state.writes).toBe(0);
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||||
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
|
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
|
||||||
|
import { CatalogInputError } from "../domain/hierarchy";
|
||||||
import {
|
import {
|
||||||
distinctOfferIds,
|
distinctOfferIds,
|
||||||
furniturePatchSchema,
|
furniturePatchSchema,
|
||||||
|
itemIds,
|
||||||
offerIdSchema,
|
offerIdSchema,
|
||||||
offerInteger,
|
offerInteger,
|
||||||
offerPatchSchema,
|
offerPatchSchema,
|
||||||
@@ -80,6 +83,30 @@ function assignments(
|
|||||||
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
|
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/** Emulator: "limited stack N is lower than the live slot count M". */
|
||||||
|
function assertLimitedStackOk(limitedStack: number, limitedSells: number) {
|
||||||
|
if (limitedStack < limitedSells)
|
||||||
|
throw new CatalogInputError(
|
||||||
|
`Limited stack (${limitedStack}) is lower than the number already sold (${limitedSells})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emulator: "sibling order N is used more than once". */
|
||||||
|
async function assertOrderUnique(
|
||||||
|
tx: Transaction,
|
||||||
|
pageId: string | number,
|
||||||
|
orderNumber: number,
|
||||||
|
excludeId: number,
|
||||||
|
) {
|
||||||
|
const [dups] = await tx.execute(
|
||||||
|
sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`,
|
||||||
|
);
|
||||||
|
if ((dups as unknown as unknown[]).length > 0)
|
||||||
|
throw new CatalogInputError(
|
||||||
|
`Order number ${orderNumber} is already used by another offer on this page`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateOfferCommand(
|
export async function updateOfferCommand(
|
||||||
input: UpdateOfferInput,
|
input: UpdateOfferInput,
|
||||||
userId?: number,
|
userId?: number,
|
||||||
@@ -118,6 +145,29 @@ export async function updateOfferCommand(
|
|||||||
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
|
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
|
||||||
if (input.baseItem) references.push(input.baseItem.id);
|
if (input.baseItem) references.push(input.baseItem.id);
|
||||||
if (references.length) await lockFurniture(tx, references);
|
if (references.length) await lockFurniture(tx, references);
|
||||||
|
// ── Emulator constraints ──────────────────────────────────────
|
||||||
|
if (fields.orderNumber !== undefined || fields.pageId !== undefined) {
|
||||||
|
const targetPageId = fields.pageId ?? offer.pageId;
|
||||||
|
const targetOrder = fields.orderNumber ?? 0;
|
||||||
|
if (fields.orderNumber !== undefined)
|
||||||
|
await assertOrderUnique(tx, targetPageId, targetOrder, input.id);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fields.limitedStack !== undefined ||
|
||||||
|
fields.limitedSells !== undefined
|
||||||
|
) {
|
||||||
|
const [current] = await tx.execute(
|
||||||
|
sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`,
|
||||||
|
);
|
||||||
|
const row = (
|
||||||
|
current as unknown as { limitedStack: number; limitedSells: number }[]
|
||||||
|
)[0];
|
||||||
|
if (row) {
|
||||||
|
const stack = fields.limitedStack ?? Number(row.limitedStack);
|
||||||
|
const sells = fields.limitedSells ?? Number(row.limitedSells);
|
||||||
|
assertLimitedStackOk(stack, sells);
|
||||||
|
}
|
||||||
|
}
|
||||||
const offerAssignments = assignments(CatalogItems, fields);
|
const offerAssignments = assignments(CatalogItems, fields);
|
||||||
if (offerAssignments.length)
|
if (offerAssignments.length)
|
||||||
await tx.execute(
|
await tx.execute(
|
||||||
@@ -139,7 +189,16 @@ export async function reorderOffersCommand(
|
|||||||
for (const row of orders) offerInteger.parse(row.orderNumber);
|
for (const row of orders) offerInteger.parse(row.orderNumber);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
await lockedOffers(tx, ids);
|
const offers = await lockedOffers(tx, ids);
|
||||||
|
// Validate uniqueness of the new order numbers within each page.
|
||||||
|
const pageId = offers[0]?.pageId;
|
||||||
|
if (pageId !== undefined) {
|
||||||
|
const nums = orders.map((r) => r.orderNumber);
|
||||||
|
if (new Set(nums).size !== nums.length)
|
||||||
|
throw new CatalogInputError(
|
||||||
|
"Different offers on the same page cannot share an order number",
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const row of orders)
|
for (const row of orders)
|
||||||
await tx.execute(
|
await tx.execute(
|
||||||
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
|
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
|
||||||
@@ -175,12 +234,12 @@ export async function createOfferCommand<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
|
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
|
||||||
const bcOfferPatchSchema = offerPatchSchema.pick({
|
const bcOfferPatchSchema = z.object({
|
||||||
pageId: true,
|
pageId: offerIdSchema.optional(),
|
||||||
itemIds: true,
|
itemIds: itemIds.optional(),
|
||||||
catalogName: true,
|
catalogName: z.string().max(100).optional(),
|
||||||
orderNumber: true,
|
orderNumber: offerInteger.optional(),
|
||||||
extradata: true,
|
extradata: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
export async function updateBcOfferCommand(
|
export async function updateBcOfferCommand(
|
||||||
id: number,
|
id: number,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Runs repairMissingIcons + repairMissingNitros directly (no source comparison
|
||||||
|
// phase, which is the slow part of runCatalogAudit). Logs progress to a file.
|
||||||
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||||
|
const LOG = "/tmp/catalog-asset-repair.log";
|
||||||
|
const log = (msg: string) => {
|
||||||
|
appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(!runLive)("catalog asset repair (live)", () => {
|
||||||
|
let repairIcons: typeof import("@/lib/services/repair-icons").repairMissingIcons;
|
||||||
|
let repairNitros: typeof import("@/lib/services/repair-nitros").repairMissingNitros;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const envFile = resolve(process.cwd(), ".env");
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let value = m[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[m[1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.env["SKIP_ENV_VALIDATION"] = "1";
|
||||||
|
|
||||||
|
[repairIcons, repairNitros] = await Promise.all([
|
||||||
|
import("@/lib/services/repair-icons").then((m) => m.repairMissingIcons),
|
||||||
|
import("@/lib/services/repair-nitros").then((m) => m.repairMissingNitros),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs missing icons then nitros", async () => {
|
||||||
|
expect(process.env.DATABASE_URL).toMatch(/@/);
|
||||||
|
|
||||||
|
log("=== ICON REPAIR START ===");
|
||||||
|
let lastPct = -1;
|
||||||
|
const iconResult = await repairIcons((evt) => {
|
||||||
|
if (evt.type === "progress" && evt.total) {
|
||||||
|
const pct = Math.round(((evt.index ?? 0) / evt.total) * 100);
|
||||||
|
if (pct % 5 === 0 && pct !== lastPct) {
|
||||||
|
lastPct = pct;
|
||||||
|
log(`icons ${evt.index}/${evt.total} (${pct}%)`);
|
||||||
|
}
|
||||||
|
if (evt.status === "done") log(`ICON DONE: ${evt.classname}`);
|
||||||
|
if (evt.status === "failed")
|
||||||
|
log(`ICON FAIL: ${evt.classname} :: ${evt.message ?? ""}`);
|
||||||
|
}
|
||||||
|
if (evt.type === "error") log(`ICON ERROR: ${evt.message ?? ""}`);
|
||||||
|
});
|
||||||
|
log("icon result: " + JSON.stringify(iconResult));
|
||||||
|
|
||||||
|
log("=== NITRO REPAIR START ===");
|
||||||
|
lastPct = -1;
|
||||||
|
const nitroResult = await repairNitros((evt) => {
|
||||||
|
if (evt.type === "progress" && evt.total) {
|
||||||
|
const pct = Math.round(((evt.index ?? 0) / evt.total) * 100);
|
||||||
|
if (pct % 5 === 0 && pct !== lastPct) {
|
||||||
|
lastPct = pct;
|
||||||
|
log(`nitros ${evt.index}/${evt.total} (${pct}%)`);
|
||||||
|
}
|
||||||
|
if (evt.status === "done") log(`NITRO DONE: ${evt.classname}`);
|
||||||
|
if (evt.status === "failed")
|
||||||
|
log(`NITRO FAIL: ${evt.classname} :: ${evt.message ?? ""}`);
|
||||||
|
}
|
||||||
|
if (evt.type === "error") log(`NITRO ERROR: ${evt.message ?? ""}`);
|
||||||
|
});
|
||||||
|
log("nitro result: " + JSON.stringify(nitroResult));
|
||||||
|
|
||||||
|
log("=== ASSET REPAIR COMPLETE ===");
|
||||||
|
log("icons: " + JSON.stringify(iconResult));
|
||||||
|
log("nitros: " + JSON.stringify(nitroResult));
|
||||||
|
}, 1800000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Live repair execution for the catalog studio audit. Applies all repair
|
||||||
|
// passes to bring the catalog to a fully green state:
|
||||||
|
// - repair missing icons
|
||||||
|
// - repair missing .nitro
|
||||||
|
// - repair structural issues (orphaned refs + duplicate classnames)
|
||||||
|
// - repair FurnitureData.json (add missing + dedupe)
|
||||||
|
//
|
||||||
|
// Run with the REAL DATABASE_URL loaded from .env:
|
||||||
|
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
|
||||||
|
// src/lib/services/catalog-audit-repair-live.test.ts
|
||||||
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||||
|
const LOG = "/tmp/catalog-audit-repair.log";
|
||||||
|
const log = (...parts: unknown[]) =>
|
||||||
|
appendFileSync(
|
||||||
|
LOG,
|
||||||
|
`${parts
|
||||||
|
.map((p) => (typeof p === "string" ? p : JSON.stringify(p, null, 2)))
|
||||||
|
.join(" ")}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe.skipIf(!runLive)("catalog audit live repair", () => {
|
||||||
|
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const envFile = resolve(process.cwd(), ".env");
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let value = m[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[m[1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditMod = await import("@/lib/services/catalog-audit");
|
||||||
|
runCatalogAudit = auditMod.runCatalogAudit;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies all repairs and converges to zero issues", async () => {
|
||||||
|
expect(process.env.DATABASE_URL).toMatch(/@/);
|
||||||
|
|
||||||
|
// Run the audit first to see the current baseline.
|
||||||
|
let events: Array<Record<string, unknown>> = [];
|
||||||
|
await runCatalogAudit(
|
||||||
|
(evt) => events.push(evt as unknown as Record<string, unknown>),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const before = events[events.length - 1]?.summary as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
log("BASELINE:", JSON.stringify(before, null, 2));
|
||||||
|
|
||||||
|
// Apply all repairs.
|
||||||
|
events = [];
|
||||||
|
await runCatalogAudit(
|
||||||
|
(evt) => {
|
||||||
|
events.push(evt as unknown as Record<string, unknown>);
|
||||||
|
if (evt.type === "repair_complete") {
|
||||||
|
log(
|
||||||
|
"REPAIR:",
|
||||||
|
JSON.stringify({
|
||||||
|
repair: evt.repair,
|
||||||
|
repairNitro: evt.repairNitro,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (evt.type === "structure_repair_complete") {
|
||||||
|
log("STRUCTURE:", JSON.stringify(evt.structure));
|
||||||
|
}
|
||||||
|
if (evt.type === "furnidata_repair_complete") {
|
||||||
|
log("FURNIDATA:", JSON.stringify(evt.furniData));
|
||||||
|
}
|
||||||
|
if (evt.type === "sql_ready") {
|
||||||
|
log("SQL:", JSON.stringify({ sqlCount: evt.sqlCount }));
|
||||||
|
}
|
||||||
|
if (evt.type === "sql_applied") {
|
||||||
|
log(
|
||||||
|
"SQL_APPLIED:",
|
||||||
|
JSON.stringify({
|
||||||
|
applied: evt.applied,
|
||||||
|
sqlFailed: evt.sqlFailed,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (evt.type === "error") {
|
||||||
|
log(`AUDIT ERROR: ${evt.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repair: true,
|
||||||
|
repairNitros: true,
|
||||||
|
sql: true,
|
||||||
|
applySql: true,
|
||||||
|
repairFurniData: true,
|
||||||
|
repairStructure: true,
|
||||||
|
checkFurniDataIds: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const last = events[events.length - 1];
|
||||||
|
expect(last?.type).toBe("batch_complete");
|
||||||
|
const summary = last.summary as Record<string, unknown>;
|
||||||
|
const issues = last.issues as Array<{
|
||||||
|
severity: "error" | "warning" | "info";
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
const unrepairable = last.unrepairable as Array<{
|
||||||
|
classname: string;
|
||||||
|
missing: Array<"nitro" | "icon">;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
log("AFTER:", JSON.stringify(summary, null, 2));
|
||||||
|
log(
|
||||||
|
"ISSUES:",
|
||||||
|
JSON.stringify(
|
||||||
|
issues.map((i) => `${i.severity}: ${i.message}`),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
log("UNREPAIRABLE:", JSON.stringify(unrepairable, null, 2));
|
||||||
|
}, 600000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Read-only audit run that writes the full summary to a log file.
|
||||||
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||||
|
const LOG = "/tmp/catalog-audit-summary.log";
|
||||||
|
const log = (msg: string) => {
|
||||||
|
appendFileSync(LOG, `${msg}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(!runLive)("catalog audit read-only summary", () => {
|
||||||
|
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const envFile = resolve(process.cwd(), ".env");
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let value = m[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[m[1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditMod = await import("@/lib/services/catalog-audit");
|
||||||
|
runCatalogAudit = auditMod.runCatalogAudit;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the full audit summary", async () => {
|
||||||
|
expect(process.env.DATABASE_URL).toMatch(/@/);
|
||||||
|
|
||||||
|
const events: Array<Record<string, unknown>> = [];
|
||||||
|
await runCatalogAudit(
|
||||||
|
(evt) => events.push(evt as unknown as Record<string, unknown>),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const last = events[events.length - 1];
|
||||||
|
expect(last?.type).toBe("batch_complete");
|
||||||
|
const summary = last.summary as Record<string, unknown>;
|
||||||
|
const issues = last.issues as Array<{
|
||||||
|
type: string;
|
||||||
|
severity: string;
|
||||||
|
message: string;
|
||||||
|
classname?: string;
|
||||||
|
}>;
|
||||||
|
const unrepairable = last.unrepairable as Array<{
|
||||||
|
classname: string;
|
||||||
|
itemId: number;
|
||||||
|
missing: Array<string>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
log("=== SUMMARY ===");
|
||||||
|
log(JSON.stringify(summary, null, 2));
|
||||||
|
log("=== ISSUES ===");
|
||||||
|
log(JSON.stringify(issues, null, 2));
|
||||||
|
log("=== UNREPAIRABLE ===");
|
||||||
|
log(JSON.stringify(unrepairable, null, 2));
|
||||||
|
}, 300000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Direct repair execution against the live DB. Skips the slow clone-source
|
||||||
|
// comparison entirely and just runs the repair functions.
|
||||||
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const LOG = "/tmp/catalog-repair.log";
|
||||||
|
const log = (msg: string) => {
|
||||||
|
appendFileSync(LOG, `${msg}\n`);
|
||||||
|
console.log(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||||
|
|
||||||
|
describe.skipIf(!runLive)("catalog repair direct (live)", () => {
|
||||||
|
let db: typeof import("@/lib/db").db;
|
||||||
|
let repair: typeof import("@/lib/services/catalog-repair");
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const envFile = resolve(process.cwd(), ".env");
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let value = m[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[m[1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [dbMod, repairMod] = await Promise.all([
|
||||||
|
import("@/lib/db"),
|
||||||
|
import("@/lib/services/catalog-repair"),
|
||||||
|
]);
|
||||||
|
db = dbMod.db;
|
||||||
|
repair = repairMod;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs orphaned catalog + duplicate classnames", async () => {
|
||||||
|
expect(process.env.DATABASE_URL).toMatch(/@/);
|
||||||
|
|
||||||
|
log("=== ORPHANED REPAIR ===");
|
||||||
|
const orphan = await repair.repairOrphanedCatalog();
|
||||||
|
log("orphaned result: " + JSON.stringify(orphan));
|
||||||
|
|
||||||
|
log("=== DUPLICATE REPAIR ===");
|
||||||
|
const dup = await repair.repairDuplicateClassnames();
|
||||||
|
log("duplicate result: " + JSON.stringify(dup));
|
||||||
|
|
||||||
|
log("=== VERIFY DUPLICATES ===");
|
||||||
|
const [dupRows] = (await db.execute(sql`
|
||||||
|
SELECT item_name, COUNT(*) as cnt
|
||||||
|
FROM items_base
|
||||||
|
GROUP BY item_name
|
||||||
|
HAVING cnt > 1
|
||||||
|
`)) as unknown as [Array<{ item_name: string; cnt: number }>, unknown];
|
||||||
|
log("remaining duplicate groups: " + JSON.stringify(dupRows));
|
||||||
|
}, 300000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Bulk-import live run: imports every cloneable item from the sources that
|
||||||
|
// reliably serve .nitro assets (SodaStudios, Hubbly). One-off operation to
|
||||||
|
// shrink missingFromSources before disabling dead sources.
|
||||||
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const runLive = process.env.RUN_CLONE_BULK_LIVE === "1";
|
||||||
|
const LOG = "/tmp/clone-bulk.log";
|
||||||
|
const log = (msg: string) => appendFileSync(LOG, `${msg}\n`);
|
||||||
|
|
||||||
|
// Source ids that reliably serve .nitro files (verified 2026-09).
|
||||||
|
const IMPORT_IDS = ["default-sodastudios", "default-hubbly"];
|
||||||
|
|
||||||
|
describe.skipIf(!runLive)("clone bulk import", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const envFile = resolve(process.cwd(), ".env");
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let value = m[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[m[1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports clonable items from delivering sources", async () => {
|
||||||
|
expect(process.env.DATABASE_URL).toMatch(/@/);
|
||||||
|
const mod = await import("@/lib/services/clone-sources");
|
||||||
|
const ci = await import("@/lib/services/clone-import");
|
||||||
|
const sources = await mod.listSources();
|
||||||
|
log(`START bulk import ${new Date().toISOString()}`);
|
||||||
|
|
||||||
|
for (const id of IMPORT_IDS) {
|
||||||
|
const source = sources.find((s) => s.id === id);
|
||||||
|
if (!source) {
|
||||||
|
log(`skip: source id ${id} not in listSources`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const clonable = await ci.getClonableClassnames(source);
|
||||||
|
const all = await ci.fetchSourceFurnidata(source.furnidataUrl);
|
||||||
|
const entryFor = (classname: string) =>
|
||||||
|
all.find((e) => e.classname === classname);
|
||||||
|
log(
|
||||||
|
`SOURCE ${source.name}: ${clonable.length} clonable (fetched ${all.length} entries)`,
|
||||||
|
);
|
||||||
|
let ok = 0;
|
||||||
|
let fail = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const failSamples: string[] = [];
|
||||||
|
const started = Date.now();
|
||||||
|
for (let i = 0; i < clonable.length; i++) {
|
||||||
|
const classname = clonable[i];
|
||||||
|
const entry = entryFor(classname);
|
||||||
|
if (!entry) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const res = await ci.cloneSingleFurni({ source, entry });
|
||||||
|
if (res.ok) ok++;
|
||||||
|
else if ("skipped" in res && res.skipped) skipped++;
|
||||||
|
else {
|
||||||
|
fail++;
|
||||||
|
if (failSamples.length < 30) {
|
||||||
|
failSamples.push(`${classname}: ${res.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((i + 1) % 50 === 0) {
|
||||||
|
const elapsed = ((Date.now() - started) / 1000).toFixed(0);
|
||||||
|
log(
|
||||||
|
` ${source.name} ${i + 1}/${clonable.length} ok=${ok} fail=${fail} skipped=${skipped} (${elapsed}s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const secs = ((Date.now() - started) / 1000).toFixed(0);
|
||||||
|
log(
|
||||||
|
`DONE ${source.name}: ok=${ok} fail=${fail} skipped=${skipped} in ${secs}s`,
|
||||||
|
);
|
||||||
|
if (failSamples.length) {
|
||||||
|
log(` fail samples:\n ${failSamples.join("\n ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(`END bulk import ${new Date().toISOString()}`);
|
||||||
|
}, 1800000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Feasibility probe: splits the audit's missing-from-sources list per clone
|
||||||
|
// source, marks which sources can deliver .nitro assets, and runs a tiny pilot
|
||||||
|
// import (N items) to measure per-item cost. Writes a report to /tmp.
|
||||||
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const runLive = process.env.RUN_CLONE_FEASIBILITY_LIVE === "1";
|
||||||
|
const LOG = "/tmp/clone-feasibility.log";
|
||||||
|
const log = (msg: string) => appendFileSync(LOG, `${msg}\n`);
|
||||||
|
|
||||||
|
describe.skipIf(!runLive)("clone feasibility", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const envFile = resolve(process.cwd(), ".env");
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let value = m[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[m[1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports per-source clonable counts + pilot", async () => {
|
||||||
|
expect(process.env.DATABASE_URL).toMatch(/@/);
|
||||||
|
const mod = await import("@/lib/services/clone-sources");
|
||||||
|
const ci = await import("@/lib/services/clone-import");
|
||||||
|
const sources = await mod.listSources();
|
||||||
|
log(`sources: ${sources.length}`);
|
||||||
|
const report: Array<Record<string, unknown>> = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const clonable = await ci.getClonableClassnames(source);
|
||||||
|
const secs = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
log(
|
||||||
|
`${source.name}: haveNitro=${Boolean(source.nitroBaseUrl)} clonable=${clonable.length} (${secs}s)`,
|
||||||
|
);
|
||||||
|
report.push({
|
||||||
|
source: source.name,
|
||||||
|
clonable: clonable.length,
|
||||||
|
hasNitro: Boolean(source.nitroBaseUrl),
|
||||||
|
secs: Number(secs),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log(` source ${source.name} FAILED: ${(err as Error).message}`);
|
||||||
|
report.push({ source: source.name, error: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const withNitro = report
|
||||||
|
.filter((r) => r.hasNitro && typeof r.clonable === "number")
|
||||||
|
.reduce((a, r) => a + (r.clonable as number), 0);
|
||||||
|
const totalClonable = report
|
||||||
|
.filter((r) => typeof r.clonable === "number")
|
||||||
|
.reduce((a, r) => a + (r.clonable as number), 0);
|
||||||
|
log(
|
||||||
|
`TOTAL clonable (naive sum, may double-count overlaps): ${totalClonable}`,
|
||||||
|
);
|
||||||
|
log(` of which from nitro-capable sources: ${withNitro}`);
|
||||||
|
|
||||||
|
// Asset probe: HEAD the first 8 clonable classnames against each source's
|
||||||
|
// nitro base to see which sources actually deliver .nitro files.
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source.nitroBaseUrl) continue;
|
||||||
|
try {
|
||||||
|
const clonable = await ci.getClonableClassnames(source);
|
||||||
|
const statuses: string[] = [];
|
||||||
|
for (const c of clonable.slice(0, 8)) {
|
||||||
|
const url = `${source.nitroBaseUrl}/${c}.nitro`;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: "HEAD",
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
statuses.push(`${c}:${r.status}`);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
statuses.push(`${c}:${r.status}`);
|
||||||
|
} catch {
|
||||||
|
statuses.push(`${c}:ERR`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(
|
||||||
|
`PROBE ${source.name} (${clonable.length} clonable): ${JSON.stringify(statuses)}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log(`PROBE ${source.name}: fetch fail ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pilot: import up to 5 items from the first nitro-capable source that
|
||||||
|
// has clonable items, to measure per-item cost.
|
||||||
|
const pilotSource: (typeof sources)[number] | null =
|
||||||
|
sources.find((s) => s.nitroBaseUrl) ?? null;
|
||||||
|
if (pilotSource) {
|
||||||
|
const clonable = await ci.getClonableClassnames(pilotSource);
|
||||||
|
const all = await ci.fetchSourceFurnidata(pilotSource.furnidataUrl);
|
||||||
|
log(`PILOT source: ${pilotSource.name} clonable=${clonable.length}`);
|
||||||
|
const sample = clonable.slice(0, 5);
|
||||||
|
const start = Date.now();
|
||||||
|
let ok = 0;
|
||||||
|
let fail = 0;
|
||||||
|
for (const classname of sample) {
|
||||||
|
const entry = all.find((e) => e.classname === classname);
|
||||||
|
if (!entry) {
|
||||||
|
log(` pilot skip (entry not found): ${classname}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const res = await ci.cloneSingleFurni({ source: pilotSource, entry });
|
||||||
|
log(
|
||||||
|
` pilot ${res.ok ? "OK" : "FAIL"} ${classname} -> ${
|
||||||
|
res.error ?? "done"
|
||||||
|
} ${res.warnings?.join(";") ?? ""}`,
|
||||||
|
);
|
||||||
|
if (res.ok) ok++;
|
||||||
|
else fail++;
|
||||||
|
}
|
||||||
|
const secs = Date.now() - start;
|
||||||
|
log(
|
||||||
|
`PILOT done: ${ok} ok / ${fail} fail, ${(secs / 1000).toFixed(1)}s for ${sample.length} items`,
|
||||||
|
);
|
||||||
|
log(JSON.stringify(report, null, 2));
|
||||||
|
} else {
|
||||||
|
log("GEEN nitro-capabele bron gevonden; pilot overgeslagen.");
|
||||||
|
log(JSON.stringify(report, null, 2));
|
||||||
|
}
|
||||||
|
}, 600000);
|
||||||
|
});
|
||||||
@@ -18,6 +18,12 @@ export interface CloneSource {
|
|||||||
* "https://virtualcity.nl/dcr/hof_furni/{revision}/{classname}.swf"
|
* "https://virtualcity.nl/dcr/hof_furni/{revision}/{classname}.swf"
|
||||||
*/
|
*/
|
||||||
sourceSwfBaseUrl?: string;
|
sourceSwfBaseUrl?: string;
|
||||||
|
/**
|
||||||
|
* When false the source is excluded from the merged list (defaults to
|
||||||
|
* true). Used to retire sources whose assets can no longer be reached
|
||||||
|
* while keeping their definition in the site setting for later reuse.
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = "clone_sources";
|
const KEY = "clone_sources";
|
||||||
@@ -215,7 +221,7 @@ export async function listSources(): Promise<CloneSource[]> {
|
|||||||
for (const src of stored) {
|
for (const src of stored) {
|
||||||
if (src.id && src.furnidataUrl) merged.set(src.id, src);
|
if (src.id && src.furnidataUrl) merged.set(src.id, src);
|
||||||
}
|
}
|
||||||
return Array.from(merged.values());
|
return Array.from(merged.values()).filter((s) => s.enabled !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertSource(
|
export async function upsertSource(
|
||||||
@@ -231,6 +237,7 @@ export async function upsertSource(
|
|||||||
iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ""),
|
iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ""),
|
||||||
hotel: (src.hotel ?? "").trim(),
|
hotel: (src.hotel ?? "").trim(),
|
||||||
sourceSwfBaseUrl: swfBase || undefined,
|
sourceSwfBaseUrl: swfBase || undefined,
|
||||||
|
enabled: src.enabled ?? true,
|
||||||
};
|
};
|
||||||
const idx = sources.findIndex((s) => s.id === entry.id);
|
const idx = sources.findIndex((s) => s.id === entry.id);
|
||||||
if (idx >= 0) sources[idx] = entry;
|
if (idx >= 0) sources[idx] = entry;
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
items: [] as Array<Record<string, unknown>>,
|
||||||
|
pages: [] as Array<Record<string, unknown>>,
|
||||||
|
bases: [] as Array<Record<string, unknown>>,
|
||||||
|
orderRows: [] as Array<Record<string, unknown>>,
|
||||||
|
queries: [] as { sql: string; params: unknown[] }[],
|
||||||
|
writes: 0,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
execute: async (query: SQL) => {
|
||||||
|
const compiled = new MySqlDialect().sqlToQuery(query);
|
||||||
|
if (compiled.sql.includes("FROM catalog_items ci"))
|
||||||
|
return [state.items, []];
|
||||||
|
if (compiled.sql.includes("FROM catalog_pages")) return [state.pages, []];
|
||||||
|
if (compiled.sql.includes("FROM items_base")) return [state.bases, []];
|
||||||
|
return [[], []];
|
||||||
|
},
|
||||||
|
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||||
|
return fn({
|
||||||
|
execute: async (query: SQL) => {
|
||||||
|
const compiled = new MySqlDialect().sqlToQuery(query);
|
||||||
|
state.queries.push(compiled);
|
||||||
|
if (compiled.sql.trimStart().startsWith("SELECT"))
|
||||||
|
return [state.orderRows, []];
|
||||||
|
state.writes++;
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fixEmulatorIssues, listEmulatorIssues } from "./furni-maintenance";
|
||||||
|
|
||||||
|
const item = (id: number, overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id,
|
||||||
|
pageId: 10,
|
||||||
|
itemIds: "12",
|
||||||
|
costCredits: 0,
|
||||||
|
costPoints: 0,
|
||||||
|
pointsType: 0,
|
||||||
|
amount: 1,
|
||||||
|
limitedStack: 0,
|
||||||
|
limitedSells: 0,
|
||||||
|
orderNumber: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.items = [];
|
||||||
|
state.pages = [{ id: 10 }];
|
||||||
|
state.bases = [{ id: 12 }];
|
||||||
|
state.orderRows = [];
|
||||||
|
state.queries = [];
|
||||||
|
state.writes = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listEmulatorIssues", () => {
|
||||||
|
it("flags every emulator constraint violation on one row", async () => {
|
||||||
|
state.items = [
|
||||||
|
item(1, {
|
||||||
|
pageId: 500,
|
||||||
|
itemIds: "10834",
|
||||||
|
pointsType: 101,
|
||||||
|
costCredits: -1,
|
||||||
|
amount: 0,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const issues = await listEmulatorIssues();
|
||||||
|
const types = issues.map((i) => i.type).sort();
|
||||||
|
expect(types).toContain("missing_page");
|
||||||
|
expect(types).toContain("missing_item");
|
||||||
|
expect(types).toContain("points_currency");
|
||||||
|
expect(types).toContain("negative_cost");
|
||||||
|
expect(types).toContain("nonpositive_amount");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags limited stack below the sold count", async () => {
|
||||||
|
state.items = [item(1, { limitedStack: 5, limitedSells: 10 })];
|
||||||
|
const issues = await listEmulatorIssues();
|
||||||
|
expect(issues.map((i) => i.type)).toContain("limited_stack_vs_sells");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags sibling offers that share an order number on the same page", async () => {
|
||||||
|
state.items = [
|
||||||
|
item(1, { orderNumber: 1 }),
|
||||||
|
item(2, { orderNumber: 1 }),
|
||||||
|
item(3, { orderNumber: 2 }),
|
||||||
|
];
|
||||||
|
const issues = await listEmulatorIssues();
|
||||||
|
const dup = issues.filter((i) => i.type === "duplicate_order");
|
||||||
|
expect(dup.map((i) => i.id).sort()).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid pricing row without flagging", async () => {
|
||||||
|
state.items = [item(1, { orderNumber: 1 })];
|
||||||
|
const issues = await listEmulatorIssues();
|
||||||
|
expect(issues).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fixEmulatorIssues", () => {
|
||||||
|
it("deletes orphans, sanitizes pricing and renumbers duplicate orders", async () => {
|
||||||
|
state.items = [
|
||||||
|
item(1, {
|
||||||
|
pageId: 500,
|
||||||
|
itemIds: "10834",
|
||||||
|
pointsType: 101,
|
||||||
|
costCredits: -5,
|
||||||
|
costPoints: -1,
|
||||||
|
amount: 0,
|
||||||
|
}),
|
||||||
|
item(2, { limitedStack: 5, limitedSells: 10 }),
|
||||||
|
item(3, { orderNumber: 1 }),
|
||||||
|
item(4, { orderNumber: 1 }),
|
||||||
|
item(5, { orderNumber: 2 }),
|
||||||
|
];
|
||||||
|
state.orderRows = [
|
||||||
|
{ id: 3, pageId: 10, orderNumber: 1 },
|
||||||
|
{ id: 4, pageId: 10, orderNumber: 1 },
|
||||||
|
{ id: 5, pageId: 10, orderNumber: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await fixEmulatorIssues();
|
||||||
|
|
||||||
|
const bySql = (needle: string) =>
|
||||||
|
state.queries.filter((q) =>
|
||||||
|
q.sql.replace(/\s+/g, " ").trimStart().includes(needle),
|
||||||
|
);
|
||||||
|
const deleteItems = bySql("DELETE FROM catalog_items WHERE id IN");
|
||||||
|
expect(deleteItems.length).toBe(1);
|
||||||
|
expect(deleteItems[0].params).toContain(1);
|
||||||
|
|
||||||
|
const pointsFix = bySql(
|
||||||
|
"UPDATE catalog_items SET points_type = 0 WHERE id IN",
|
||||||
|
);
|
||||||
|
expect(pointsFix.length).toBe(1);
|
||||||
|
expect(pointsFix[0].params).toContain(1);
|
||||||
|
|
||||||
|
const costFix = bySql("cost_credits = CASE WHEN cost_credits < 0");
|
||||||
|
expect(costFix.length).toBe(1);
|
||||||
|
|
||||||
|
const amountFix = bySql("UPDATE catalog_items SET amount = 1 WHERE id IN");
|
||||||
|
expect(amountFix.length).toBe(1);
|
||||||
|
|
||||||
|
const limitedFix = bySql(
|
||||||
|
"UPDATE catalog_items SET limited_stack = limited_sells WHERE id IN",
|
||||||
|
);
|
||||||
|
expect(limitedFix.length).toBe(1);
|
||||||
|
expect(limitedFix[0].params).toContain(2);
|
||||||
|
|
||||||
|
expect(state.writes).toBeGreaterThan(0);
|
||||||
|
expect(result.detail.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not write when there is nothing to fix", async () => {
|
||||||
|
state.items = [item(1, { orderNumber: 1 })];
|
||||||
|
const result = await fixEmulatorIssues();
|
||||||
|
expect(result.fixed).toBe(0);
|
||||||
|
expect(state.writes).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,6 +113,7 @@ export interface FurniHealth {
|
|||||||
catalogDuplicates: number;
|
catalogDuplicates: number;
|
||||||
catalogBcDuplicates: number;
|
catalogBcDuplicates: number;
|
||||||
catalogPageDuplicates: number;
|
catalogPageDuplicates: number;
|
||||||
|
emulatorIssues: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read-only snapshot of how far the furniture tables have drifted. */
|
/** Read-only snapshot of how far the furniture tables have drifted. */
|
||||||
@@ -153,6 +154,9 @@ export async function getFurniHealth(): Promise<FurniHealth> {
|
|||||||
GROUP BY caption_save, parent_id HAVING COUNT(*) > 1
|
GROUP BY caption_save, parent_id HAVING COUNT(*) > 1
|
||||||
) g`);
|
) g`);
|
||||||
|
|
||||||
|
// ── Emulator-level catalog validation ────────────────────────────
|
||||||
|
const emulatorIssues = await countEmulatorIssues();
|
||||||
|
|
||||||
const furniData = (await readFurniData()) as Record<
|
const furniData = (await readFurniData()) as Record<
|
||||||
string,
|
string,
|
||||||
{ furnitype?: Array<{ id?: unknown; classname?: unknown }> }
|
{ furnitype?: Array<{ id?: unknown; classname?: unknown }> }
|
||||||
@@ -185,6 +189,7 @@ export async function getFurniHealth(): Promise<FurniHealth> {
|
|||||||
catalogDuplicates: Number(cd?.c ?? 0),
|
catalogDuplicates: Number(cd?.c ?? 0),
|
||||||
catalogBcDuplicates: Number(cdb?.c ?? 0),
|
catalogBcDuplicates: Number(cdb?.c ?? 0),
|
||||||
catalogPageDuplicates: Number(cpd?.c ?? 0),
|
catalogPageDuplicates: Number(cpd?.c ?? 0),
|
||||||
|
emulatorIssues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +197,282 @@ export interface FixSpriteResult extends SpriteIdVerifyResult {}
|
|||||||
export interface FixOfferResult extends CatalogOfferRebuildResult {}
|
export interface FixOfferResult extends CatalogOfferRebuildResult {}
|
||||||
export interface ReconcileResult extends FurniDataReconcileResult {}
|
export interface ReconcileResult extends FurniDataReconcileResult {}
|
||||||
|
|
||||||
|
/** Points-type values the Arcturus emulator accepts for catalog pricing. */
|
||||||
|
const EMULATOR_POINTS_TYPES = new Set([0, 5]);
|
||||||
|
|
||||||
|
export interface EmulatorCatalogIssue {
|
||||||
|
type:
|
||||||
|
| "points_currency"
|
||||||
|
| "negative_cost"
|
||||||
|
| "nonpositive_amount"
|
||||||
|
| "limited_stack_vs_sells"
|
||||||
|
| "missing_page"
|
||||||
|
| "missing_item"
|
||||||
|
| "duplicate_order";
|
||||||
|
id: number;
|
||||||
|
pageId?: number;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan catalog_items for the same data problems the Arcturus emulator throws
|
||||||
|
* on catalog load: unsupported points currency, negative prices, non-positive
|
||||||
|
* amount, limited_stack below limited_sells, orphaned page/item references and
|
||||||
|
* duplicate sibling order numbers on the same page.
|
||||||
|
*/
|
||||||
|
export async function countEmulatorIssues(): Promise<number> {
|
||||||
|
const issues = await listEmulatorIssues();
|
||||||
|
return issues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEmulatorIssues(): Promise<EmulatorCatalogIssue[]> {
|
||||||
|
const [items] = (await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
ci.id, ci.page_id AS pageId, ci.item_ids AS itemIds,
|
||||||
|
ci.cost_credits AS costCredits, ci.cost_points AS costPoints,
|
||||||
|
ci.points_type AS pointsType, ci.amount, ci.limited_stack AS limitedStack,
|
||||||
|
ci.limited_sells AS limitedSells, ci.order_number AS orderNumber
|
||||||
|
FROM catalog_items ci
|
||||||
|
`)) as unknown as [
|
||||||
|
Array<{
|
||||||
|
id: number;
|
||||||
|
pageId: number | string;
|
||||||
|
itemIds: string;
|
||||||
|
costCredits: number;
|
||||||
|
costPoints: number;
|
||||||
|
pointsType: number;
|
||||||
|
amount: number;
|
||||||
|
limitedStack: number;
|
||||||
|
limitedSells: number;
|
||||||
|
orderNumber: number;
|
||||||
|
}>,
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
|
const [pages] = (await db.execute(
|
||||||
|
sql`SELECT id FROM catalog_pages`,
|
||||||
|
)) as unknown as [Array<{ id: number }>, unknown];
|
||||||
|
const [bases] = (await db.execute(
|
||||||
|
sql`SELECT id FROM items_base`,
|
||||||
|
)) as unknown as [Array<{ id: number }>, unknown];
|
||||||
|
|
||||||
|
const pageIds = new Set(pages.map((r) => Number(r.id)));
|
||||||
|
const baseIds = new Set(bases.map((r) => Number(r.id)));
|
||||||
|
|
||||||
|
const issues: EmulatorCatalogIssue[] = [];
|
||||||
|
for (const row of items) {
|
||||||
|
const id = Number(row.id);
|
||||||
|
const pageId = Number(row.pageId);
|
||||||
|
|
||||||
|
if (!pageIds.has(pageId)) {
|
||||||
|
issues.push({
|
||||||
|
type: "missing_page",
|
||||||
|
id,
|
||||||
|
pageId,
|
||||||
|
detail: `catalog_items #${id} references offer page ${pageId} which does not exist`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const part of String(row.itemIds ?? "").split(";")) {
|
||||||
|
const baseId = Number(part.trim());
|
||||||
|
if (baseId > 0 && !baseIds.has(baseId)) {
|
||||||
|
issues.push({
|
||||||
|
type: "missing_item",
|
||||||
|
id,
|
||||||
|
pageId,
|
||||||
|
detail: `catalog_items #${id} references item definition ${baseId} which does not exist`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!EMULATOR_POINTS_TYPES.has(Number(row.pointsType))) {
|
||||||
|
issues.push({
|
||||||
|
type: "points_currency",
|
||||||
|
id,
|
||||||
|
pageId,
|
||||||
|
detail: `catalog_items #${id} uses unsupported points currency ${row.pointsType}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Number(row.costCredits) < 0 || Number(row.costPoints) < 0) {
|
||||||
|
issues.push({
|
||||||
|
type: "negative_cost",
|
||||||
|
id,
|
||||||
|
pageId,
|
||||||
|
detail: `catalog_items #${id} has negative credits/points cost`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Number(row.amount) <= 0) {
|
||||||
|
issues.push({
|
||||||
|
type: "nonpositive_amount",
|
||||||
|
id,
|
||||||
|
pageId,
|
||||||
|
detail: `catalog_items #${id} has amount ${row.amount} (must be positive)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Number(row.limitedStack) < Number(row.limitedSells)) {
|
||||||
|
issues.push({
|
||||||
|
type: "limited_stack_vs_sells",
|
||||||
|
id,
|
||||||
|
pageId,
|
||||||
|
detail: `catalog_items #${id} limited stack ${row.limitedStack} is lower than the live slot count ${row.limitedSells}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate sibling order numbers grouped by (page_id, order_number).
|
||||||
|
const orderGroups = new Map<string, number[]>();
|
||||||
|
const rootOrders = new Map<string, number[]>();
|
||||||
|
for (const row of items) {
|
||||||
|
const pageId = Number(row.pageId);
|
||||||
|
const order = Number(row.orderNumber);
|
||||||
|
const key = pageId <= 0 ? `root:${order}` : `${pageId}:${order}`;
|
||||||
|
const target = pageId <= 0 ? rootOrders : orderGroups;
|
||||||
|
const arr = target.get(key) ?? [];
|
||||||
|
arr.push(Number(row.id));
|
||||||
|
target.set(key, arr);
|
||||||
|
}
|
||||||
|
const merged = new Map<string, number[]>();
|
||||||
|
for (const [k, v] of orderGroups) merged.set(k, v);
|
||||||
|
for (const [k, v] of rootOrders) merged.set(k, v);
|
||||||
|
for (const [, ids] of merged) {
|
||||||
|
if (ids.length > 1) {
|
||||||
|
for (const id of ids) {
|
||||||
|
issues.push({
|
||||||
|
type: "duplicate_order",
|
||||||
|
id,
|
||||||
|
detail: `catalog_items #${id} shares an order number with siblings on the same page`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixEmulatorResult {
|
||||||
|
fixed: number;
|
||||||
|
removed: number;
|
||||||
|
detail: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix every emulator-level catalog defect in one transaction:
|
||||||
|
* - unsupported points currency -> duckets (0)
|
||||||
|
* - negative credits / points -> 0
|
||||||
|
* - amount <= 0 -> 1
|
||||||
|
* - limited_stack < limited_sells -> limited_stack = limited_sells
|
||||||
|
* - orphaned page / item refs -> row deleted
|
||||||
|
* - duplicate sibling order -> re-numbered sequentially per page
|
||||||
|
*/
|
||||||
|
export async function fixEmulatorIssues(): Promise<FixEmulatorResult> {
|
||||||
|
const issues = await listEmulatorIssues();
|
||||||
|
const detail: string[] = [];
|
||||||
|
let fixed = 0;
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Rows with a missing page or item reference: the emulator cannot
|
||||||
|
// render them and every other validation below is meaningless, so delete.
|
||||||
|
const orphanIds = issues
|
||||||
|
.filter((i) => i.type === "missing_page" || i.type === "missing_item")
|
||||||
|
.map((i) => i.id);
|
||||||
|
if (orphanIds.length > 0) {
|
||||||
|
await tx.execute(
|
||||||
|
sql`DELETE FROM catalog_items_limited WHERE catalog_item_id IN (${sql.join([...new Set(orphanIds)], sql`, `)})`,
|
||||||
|
);
|
||||||
|
await tx.execute(
|
||||||
|
sql`DELETE FROM catalog_items WHERE id IN (${sql.join([...new Set(orphanIds)], sql`, `)})`,
|
||||||
|
);
|
||||||
|
removed = new Set(orphanIds).size;
|
||||||
|
detail.push(
|
||||||
|
`Deleted ${removed} offer(s) with missing page/item references`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// points currency -> duckets
|
||||||
|
const badPoints = issues.filter((i) => i.type === "points_currency");
|
||||||
|
if (badPoints.length > 0) {
|
||||||
|
await tx.execute(
|
||||||
|
sql`UPDATE catalog_items SET points_type = 0 WHERE id IN (${sql.join(
|
||||||
|
badPoints.map((i) => i.id),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
fixed += badPoints.length;
|
||||||
|
detail.push(
|
||||||
|
`Reset points currency to duckets on ${badPoints.length} offer(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// negative credits / points -> 0
|
||||||
|
const negativeCost = issues.filter((i) => i.type === "negative_cost");
|
||||||
|
if (negativeCost.length > 0) {
|
||||||
|
const ids = negativeCost.map((i) => i.id);
|
||||||
|
await tx.execute(
|
||||||
|
sql`UPDATE catalog_items SET cost_credits = CASE WHEN cost_credits < 0 THEN 0 ELSE cost_credits END, cost_points = CASE WHEN cost_points < 0 THEN 0 ELSE cost_points END WHERE id IN (${sql.join(ids, sql`, `)})`,
|
||||||
|
);
|
||||||
|
fixed += ids.length;
|
||||||
|
detail.push(`Clamped negative prices to 0 on ${ids.length} offer(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// amount <= 0 -> 1
|
||||||
|
const badAmount = issues.filter((i) => i.type === "nonpositive_amount");
|
||||||
|
if (badAmount.length > 0) {
|
||||||
|
await tx.execute(
|
||||||
|
sql`UPDATE catalog_items SET amount = 1 WHERE id IN (${sql.join(
|
||||||
|
badAmount.map((i) => i.id),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
fixed += badAmount.length;
|
||||||
|
detail.push(`Set amount to 1 on ${badAmount.length} offer(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// limited_stack < limited_sells -> limited_stack = limited_sells
|
||||||
|
const limited = issues.filter((i) => i.type === "limited_stack_vs_sells");
|
||||||
|
if (limited.length > 0) {
|
||||||
|
await tx.execute(
|
||||||
|
sql`UPDATE catalog_items SET limited_stack = limited_sells WHERE id IN (${sql.join(
|
||||||
|
limited.map((i) => i.id),
|
||||||
|
sql`, `,
|
||||||
|
)}) AND limited_stack < limited_sells`,
|
||||||
|
);
|
||||||
|
fixed += limited.length;
|
||||||
|
detail.push(
|
||||||
|
`Raised limited stack to the sold count on ${limited.length} offer(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// duplicate sibling order -> re-number sequentially per page
|
||||||
|
const dupOrders = issues.filter((i) => i.type === "duplicate_order");
|
||||||
|
if (dupOrders.length > 0) {
|
||||||
|
const [allRows] = (await tx.execute(sql`
|
||||||
|
SELECT id, page_id AS pageId, order_number AS orderNumber
|
||||||
|
FROM catalog_items ORDER BY page_id, order_number, id
|
||||||
|
`)) as unknown as [
|
||||||
|
Array<{ id: number; pageId: number | string; orderNumber: number }>,
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
|
let lastPage: number | null = null;
|
||||||
|
let next = 1;
|
||||||
|
for (const row of allRows) {
|
||||||
|
const page = Number(row.pageId);
|
||||||
|
if (page !== lastPage) {
|
||||||
|
lastPage = page;
|
||||||
|
next = 1;
|
||||||
|
}
|
||||||
|
const newOrder = next++;
|
||||||
|
if (newOrder !== Number(row.orderNumber)) {
|
||||||
|
await tx.execute(
|
||||||
|
sql`UPDATE catalog_items SET order_number = ${newOrder} WHERE id = ${Number(row.id)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fixed += dupOrders.length;
|
||||||
|
detail.push(`Re-numbered sibling order numbers on affected pages`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fixed, removed, detail };
|
||||||
|
}
|
||||||
|
|
||||||
/** Fix items_base.sprite_id -> id (only where furnidata agrees). */
|
/** Fix items_base.sprite_id -> id (only where furnidata agrees). */
|
||||||
export async function fixSpriteIds(): Promise<FixSpriteResult> {
|
export async function fixSpriteIds(): Promise<FixSpriteResult> {
|
||||||
return verifyAndFixSpriteIds();
|
return verifyAndFixSpriteIds();
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ describe("classifyCatalogItem", () => {
|
|||||||
expect(item({ item_name: "post_it" })).toBe("sticky");
|
expect(item({ item_name: "post_it" })).toBe("sticky");
|
||||||
expect(item({ item_name: "postit" })).toBe("sticky");
|
expect(item({ item_name: "postit" })).toBe("sticky");
|
||||||
});
|
});
|
||||||
|
it("classifies posters and currency furniture", () => {
|
||||||
|
expect(item({ item_name: "poster100" })).toBe("poster");
|
||||||
|
expect(item({ item_name: "poster5000" })).toBe("poster");
|
||||||
|
expect(item({ item_name: "funtastic_four_poster" })).toBe("poster");
|
||||||
|
expect(item({ item_name: "duckfountain" })).toBe("poster");
|
||||||
|
expect(item({ item_name: "Swimming" })).toBe("poster");
|
||||||
|
expect(item({ item_name: "CF_50000_Hucci" })).toBe("currency");
|
||||||
|
expect(item({ item_name: "DF_102_10_bluechip" })).toBe("currency");
|
||||||
|
expect(item({ item_name: "sofa" })).toBe("furni");
|
||||||
|
});
|
||||||
it("classifies everything else as furni", () => {
|
it("classifies everything else as furni", () => {
|
||||||
expect(item({})).toBe("furni");
|
expect(item({})).toBe("furni");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
* assets live in the pet asset pipeline (`public/nitro-assets/bundled/pet`),
|
* assets live in the pet asset pipeline (`public/nitro-assets/bundled/pet`),
|
||||||
* not the furniture bundle dirs.
|
* not the furniture bundle dirs.
|
||||||
* - **effect / bot / sticky** — system items with no furniture visuals at all.
|
* - **effect / bot / sticky** — system items with no furniture visuals at all.
|
||||||
|
* - **poster** — poster furniture (`poster*` classnames, wall posters, novelty
|
||||||
|
* poster furni). Renders through the poster imager, so no `.nitro` / `_icon.png`
|
||||||
|
* assets are expected.
|
||||||
|
* - **currency** — credit/pixel furniture (`CF_*`, `DF_*`). Renders through the
|
||||||
|
* currency imager (credits coin, badge-format exchange stickers), so no
|
||||||
|
* `.nitro` / `_icon.png` assets are expected.
|
||||||
* - **furni** — everything else; must ship a `.nitro` and `_icon.png`.
|
* - **furni** — everything else; must ship a `.nitro` and `_icon.png`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -19,6 +25,8 @@ export type CatalogItemKind =
|
|||||||
| "effect"
|
| "effect"
|
||||||
| "bot"
|
| "bot"
|
||||||
| "sticky"
|
| "sticky"
|
||||||
|
| "poster"
|
||||||
|
| "currency"
|
||||||
| "furni";
|
| "furni";
|
||||||
|
|
||||||
export interface CatalogItemLike {
|
export interface CatalogItemLike {
|
||||||
@@ -42,6 +50,24 @@ export function isPetInteraction(interactionType?: string | null): boolean {
|
|||||||
return /^pet\d+$/i.test((interactionType ?? "").trim());
|
return /^pet\d+$/i.test((interactionType ?? "").trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Poster furniture classnames that have no `.nitro` asset expectations. */
|
||||||
|
export function isPosterClassname(itemName: string): boolean {
|
||||||
|
const name = itemName.trim();
|
||||||
|
if (/^poster\d+$/i.test(name)) return true;
|
||||||
|
const POSTER_SPECIAL = new Set([
|
||||||
|
"funtastic_four_poster",
|
||||||
|
"duckfountain",
|
||||||
|
"duckfountain2",
|
||||||
|
"swimming",
|
||||||
|
]);
|
||||||
|
return POSTER_SPECIAL.has(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Credit/pixel furniture (`CF_*`, `DF_*`) ships no `.nitro` assets. */
|
||||||
|
export function isCurrencyClassname(itemName: string): boolean {
|
||||||
|
return /^(?:cf|df)_/i.test(itemName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** Classify an `items_base` row into a catalog item kind. */
|
/** Classify an `items_base` row into a catalog item kind. */
|
||||||
export function classifyCatalogItem(item: CatalogItemLike): CatalogItemKind {
|
export function classifyCatalogItem(item: CatalogItemLike): CatalogItemKind {
|
||||||
const name = item.item_name.trim();
|
const name = item.item_name.trim();
|
||||||
@@ -52,6 +78,8 @@ export function classifyCatalogItem(item: CatalogItemLike): CatalogItemKind {
|
|||||||
if (/^avatar_effect/i.test(name)) return "effect";
|
if (/^avatar_effect/i.test(name)) return "effect";
|
||||||
if (/^(bot_|rentable_bot_)/i.test(name)) return "bot";
|
if (/^(bot_|rentable_bot_)/i.test(name)) return "bot";
|
||||||
if (/^post[._]?it/i.test(name)) return "sticky";
|
if (/^post[._]?it/i.test(name)) return "sticky";
|
||||||
|
if (isPosterClassname(name)) return "poster";
|
||||||
|
if (isCurrencyClassname(name)) return "currency";
|
||||||
return "furni";
|
return "furni";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +90,8 @@ export const NO_ASSET_KINDS: ReadonlySet<CatalogItemKind> = new Set([
|
|||||||
"effect",
|
"effect",
|
||||||
"bot",
|
"bot",
|
||||||
"sticky",
|
"sticky",
|
||||||
|
"poster",
|
||||||
|
"currency",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Kinds that are not purchasable furniture and should not get catalog entries. */
|
/** Kinds that are not purchasable furniture and should not get catalog entries. */
|
||||||
|
|||||||
-3810
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user