style: format code with prettier
Local Build and Deploy / deploy (push) Successful in 49s

This commit is contained in:
openhands committed 2026-07-12 21:07:34 +02:00
1 parent e85e4d74ea
commit 2e4ed76121
378 files changed
+22404 -20686

No files matched your search

+16 -5
View File
@@ -3,16 +3,27 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const tables = [
"website_event_types", "website_events", "website_event_registrations",
"website_event_prizes", "website_event_winners", "website_polls",
"website_poll_questions", "website_poll_votes", "website_banners",
"custom_prefixes", "custom_prefix_blacklist", "custom_prefix_settings",
"website_event_types",
"website_events",
"website_event_registrations",
"website_event_prizes",
"website_event_winners",
"website_polls",
"website_poll_questions",
"website_poll_votes",
"website_banners",
"custom_prefixes",
"custom_prefix_blacklist",
"custom_prefix_settings",
];
describe("admin content module port", () => {
it("owns every required table through schema and migration contracts", () => {
const schema = readFileSync(resolve("prisma/schema.prisma"), "utf8");
const migration = readFileSync(resolve("prisma/migrations/0013_admin_events_polls_banners_prefixes.sql"), "utf8");
const migration = readFileSync(
resolve("prisma/migrations/0013_admin_events_polls_banners_prefixes.sql"),
"utf8",
);
for (const table of tables) {
expect(migration).toContain(`CREATE TABLE IF NOT EXISTS \`${table}\``);
if (!table.startsWith("custom_prefix")) expect(schema).toContain(`@@map("${table}")`);
+22 -22
View File
@@ -1,30 +1,30 @@
import type { ListParams } from '@/types'
import type { ListParams } from "@/types";
export const PER_PAGE_OPTIONS = [10, 20, 50] as const
export const DEFAULT_PER_PAGE = 20
export const PER_PAGE_OPTIONS = [10, 20, 50] as const;
export const DEFAULT_PER_PAGE = 20;
export function parseListParams(searchParams: URLSearchParams): ListParams {
const search = searchParams.get('search') || ''
const search = searchParams.get("search") || "";
const perPage = Math.min(
Math.max(parseInt(searchParams.get('perPage') || String(DEFAULT_PER_PAGE), 10), 1),
Math.max(parseInt(searchParams.get("perPage") || String(DEFAULT_PER_PAGE), 10), 1),
100,
)
const page = Math.max(parseInt(searchParams.get('page') || '1', 10), 1)
const sort = searchParams.get('sort') || undefined
const order = (searchParams.get('order') === 'asc' ? 'asc' : 'desc') as 'asc' | 'desc'
);
const page = Math.max(parseInt(searchParams.get("page") || "1", 10), 1);
const sort = searchParams.get("sort") || undefined;
const order = (searchParams.get("order") === "asc" ? "asc" : "desc") as "asc" | "desc";
return { search, perPage, page, sort, order }
return { search, perPage, page, sort, order };
}
export function calcPagination(total: number, page: number, perPage: number) {
const lastPage = Math.max(Math.ceil(total / perPage), 1)
const lastPage = Math.max(Math.ceil(total / perPage), 1);
return {
total,
page: Math.min(page, lastPage),
perPage,
lastPage,
offset: (Math.min(page, lastPage) - 1) * perPage,
}
};
}
/** Generate CSV content from rows */
@@ -32,23 +32,23 @@ export function generateCsv(
rows: Record<string, unknown>[],
columns: { key: string; label: string }[],
): string {
const BOM = '\uFEFF'
const header = columns.map((c) => escapeCsv(c.label)).join(',')
const BOM = "\uFEFF";
const header = columns.map((c) => escapeCsv(c.label)).join(",");
const body = rows
.map((row) => columns.map((c) => escapeCsv(String(row[c.key] ?? ''))).join(','))
.join('\n')
.map((row) => columns.map((c) => escapeCsv(String(row[c.key] ?? ""))).join(","))
.join("\n");
return `${BOM + header}\n${body}`
return `${BOM + header}\n${body}`;
}
function escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value
return value;
}
export function formatTimestamp(ts: number): string {
if (!ts) return 'N/A'
return new Date(ts * 1000).toLocaleString()
if (!ts) return "N/A";
return new Date(ts * 1000).toLocaleString();
}
+52 -57
View File
@@ -1,28 +1,28 @@
import { redirect } from 'next/navigation'
import { calcPagination, parseListParams } from './admin-helpers'
import { canAccess, getAdminContext, type PermissionSet } from './permissions'
import { prisma } from './prisma'
import { redirect } from "next/navigation";
import { calcPagination, parseListParams } from "./admin-helpers";
import { canAccess, getAdminContext, type PermissionSet } from "./permissions";
import { prisma } from "./prisma";
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
type PrismaModel = any
type PrismaModel = any;
interface AdminListConfig<TRow> {
/** Permission slug required to view this page */
permission: string
permission: string;
/** Prisma model name (e.g. 'user', 'ban', 'room') */
model: string
model: string;
/** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */
searchFields: string[]
searchFields: string[];
/** Default sort when no sort param provided. Default: { id: 'desc' } */
defaultSort?: Record<string, 'asc' | 'desc'>
defaultSort?: Record<string, "asc" | "desc">;
/** Default items per page. Default: 20 */
defaultPerPage?: number
defaultPerPage?: number;
/** Prisma include clause for relations */
include?: Record<string, unknown>
include?: Record<string, unknown>;
/** Prisma select clause (mutually exclusive with include) */
select?: Record<string, unknown>
select?: Record<string, unknown>;
/** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */
baseWhere?: Record<string, unknown>
baseWhere?: Record<string, unknown>;
/**
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
* the raw string value from the URL and returns the WHERE fragment to merge.
@@ -34,20 +34,20 @@ interface AdminListConfig<TRow> {
* filter_online: (v) => ({ online: v }),
* }
*/
filterMap?: Record<string, (value: string) => Record<string, unknown> | null | undefined>
filterMap?: Record<string, (value: string) => Record<string, unknown> | null | undefined>;
/** Transform raw Prisma row to the shape needed by the table component */
mapRow?: (row: PrismaModel) => TRow
mapRow?: (row: PrismaModel) => TRow;
}
interface AdminListResult<TRow> {
rows: TRow[]
total: number
page: number
perPage: number
lastPage: number
locale: string
permissions: PermissionSet
rank: number
rows: TRow[];
total: number;
page: number;
perPage: number;
lastPage: number;
locale: string;
permissions: PermissionSet;
rank: number;
}
/**
@@ -56,16 +56,16 @@ interface AdminListResult<TRow> {
* e.g. 'username' => { username: { contains: search } }
*/
function buildFieldCondition(fieldPath: string, search: string): Record<string, unknown> {
const parts = fieldPath.split('.')
const parts = fieldPath.split(".");
if (parts.length === 1) {
return { [parts[0]]: { contains: search } }
return { [parts[0]]: { contains: search } };
}
// Build nested object from right to left
let result: Record<string, unknown> = { [parts[parts.length - 1]]: { contains: search } }
let result: Record<string, unknown> = { [parts[parts.length - 1]]: { contains: search } };
for (let i = parts.length - 2; i >= 0; i--) {
result = { [parts[i]]: result }
result = { [parts[i]]: result };
}
return result
return result;
}
/**
@@ -78,35 +78,33 @@ export async function fetchAdminList<TRow = PrismaModel>(
paramsPromise: Promise<{ locale: string }>,
searchParamsPromise: Promise<Record<string, string>>,
): Promise<AdminListResult<TRow>> {
const { locale } = await paramsPromise
const { session, permissions } = await getAdminContext()
const { locale } = await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, config.permission, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const rawParams = await searchParamsPromise
const sp = new URLSearchParams(rawParams)
const parsed = parseListParams(sp)
const perPage = config.defaultPerPage
? Number(rawParams.perPage) || config.defaultPerPage
: parsed.perPage
const { search, page, sort, order } = parsed
const rawParams = await searchParamsPromise;
const sp = new URLSearchParams(rawParams);
const parsed = parseListParams(sp);
const perPage = config.defaultPerPage ? Number(rawParams.perPage) || config.defaultPerPage : parsed.perPage;
const { search, page, sort, order } = parsed;
// Build WHERE clause
const searchWhere =
search && config.searchFields.length > 0
? { OR: config.searchFields.map((field) => buildFieldCondition(field, search)) }
: {}
: {};
// Apply URL filter_* params via filterMap
const filterWhere: Record<string, unknown> = {}
const filterWhere: Record<string, unknown> = {};
if (config.filterMap) {
for (const [paramKey, build] of Object.entries(config.filterMap)) {
const value = rawParams[paramKey]
if (!value) continue
const fragment = build(value)
if (fragment) Object.assign(filterWhere, fragment)
const value = rawParams[paramKey];
if (!value) continue;
const fragment = build(value);
if (fragment) Object.assign(filterWhere, fragment);
}
}
@@ -114,31 +112,28 @@ export async function fetchAdminList<TRow = PrismaModel>(
...(config.baseWhere ?? {}),
...filterWhere,
...searchWhere,
}
};
// Build orderBy
const orderBy = sort ? { [sort]: order } : (config.defaultSort ?? { id: 'desc' as const })
const orderBy = sort ? { [sort]: order } : (config.defaultSort ?? { id: "desc" as const });
// Access Prisma model dynamically
const delegate = (prisma as PrismaModel)[config.model]
const delegate = (prisma as PrismaModel)[config.model];
const queryArgs: PrismaModel = {
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}
};
if (config.include) queryArgs.include = config.include
if (config.select) queryArgs.select = config.select
if (config.include) queryArgs.include = config.include;
if (config.select) queryArgs.select = config.select;
const [rawRows, total] = await Promise.all([
delegate.findMany(queryArgs),
delegate.count({ where }),
])
const [rawRows, total] = await Promise.all([delegate.findMany(queryArgs), delegate.count({ where })]);
const pagination = calcPagination(total, page, perPage)
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows
const pagination = calcPagination(total, page, perPage);
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows;
return {
rows,
@@ -146,5 +141,5 @@ export async function fetchAdminList<TRow = PrismaModel>(
locale,
permissions,
rank: session.user.rank,
}
};
}
+37 -37
View File
@@ -1,43 +1,43 @@
import { existsSync, readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const ROUTES: Array<[string, string]> = [
['moderation', 'PERMS.MODERATION_VIEW'],
['moderation/actions', 'PERMS.MODERATION_EDIT'],
['moderation/cfh', 'PERMS.MODERATION_VIEW'],
['moderation/cfh/[id]', 'PERMS.MODERATION_VIEW'],
['moderation/team', 'PERMS.MODERATION_VIEW'],
['logs/audit', 'PERMS.LOGS_VIEW'],
['logs/chat', 'PERMS.LOGS_VIEW'],
['logs/commands', 'PERMS.LOGS_VIEW'],
['logs/trades', 'PERMS.LOGS_VIEW'],
['analytics', 'PERMS.ANALYTICS_VIEW'],
['analytics/activity', 'PERMS.ANALYTICS_VIEW'],
['analytics/economy', 'PERMS.ANALYTICS_VIEW'],
['devops', 'PERMS.DEVOPS_VIEW'],
['devops/errors', 'PERMS.DEVOPS_VIEW'],
['online', 'PERMS.USERS_VIEW'],
]
["moderation", "PERMS.MODERATION_VIEW"],
["moderation/actions", "PERMS.MODERATION_EDIT"],
["moderation/cfh", "PERMS.MODERATION_VIEW"],
["moderation/cfh/[id]", "PERMS.MODERATION_VIEW"],
["moderation/team", "PERMS.MODERATION_VIEW"],
["logs/audit", "PERMS.LOGS_VIEW"],
["logs/chat", "PERMS.LOGS_VIEW"],
["logs/commands", "PERMS.LOGS_VIEW"],
["logs/trades", "PERMS.LOGS_VIEW"],
["analytics", "PERMS.ANALYTICS_VIEW"],
["analytics/activity", "PERMS.ANALYTICS_VIEW"],
["analytics/economy", "PERMS.ANALYTICS_VIEW"],
["devops", "PERMS.DEVOPS_VIEW"],
["devops/errors", "PERMS.DEVOPS_VIEW"],
["online", "PERMS.USERS_VIEW"],
];
describe('admin operations route contract', () => {
it.each(ROUTES)('provides and guards /admin/%s', (route, permission) => {
const path = `src/app/admin/${route}/page.tsx`
expect(existsSync(path), path).toBe(true)
expect(readFileSync(path, 'utf8'), path).toContain(permission)
})
describe("admin operations route contract", () => {
it.each(ROUTES)("provides and guards /admin/%s", (route, permission) => {
const path = `src/app/admin/${route}/page.tsx`;
expect(existsSync(path), path).toBe(true);
expect(readFileSync(path, "utf8"), path).toContain(permission);
});
it.each([
['analytics/export', 'PERMS.ANALYTICS_EXPORT'],
['devops/health', 'PERMS.DEVOPS_VIEW'],
])('provides and guards /api/admin/%s', (route, permission) => {
const path = `src/app/api/admin/${route}/route.ts`
expect(existsSync(path), path).toBe(true)
expect(readFileSync(path, 'utf8'), path).toContain(permission)
})
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
["devops/health", "PERMS.DEVOPS_VIEW"],
])("provides and guards /api/admin/%s", (route, permission) => {
const path = `src/app/api/admin/${route}/route.ts`;
expect(existsSync(path), path).toBe(true);
expect(readFileSync(path, "utf8"), path).toContain(permission);
});
it('guards moderation mutations separately from page navigation', () => {
const source = readFileSync('src/actions/moderation.ts', 'utf8')
expect(source).toContain('PERMS.MODERATION_EDIT')
expect(source).toContain('adminAction')
})
})
it("guards moderation mutations separately from page navigation", () => {
const source = readFileSync("src/actions/moderation.ts", "utf8");
expect(source).toContain("PERMS.MODERATION_EDIT");
expect(source).toContain("adminAction");
});
});
+7 -3
View File
@@ -24,8 +24,10 @@ const DATA_COLOR_ALLOWLIST = [
"src/app/admin/theme/page.tsx",
];
const PUBLIC_STRUCTURAL_TOKEN = /var\(--(?:color-(?:background|surface|dropdown|navbar|navbar-text|text|text-muted|primary|primary-hover)|border-subtle|border-color)\)/g;
const HARDCODED_UI_PALETTE = /(?:text|bg|border|ring|from|to|via)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[1-9]00|50)(?:\/\d+)?/g;
const PUBLIC_STRUCTURAL_TOKEN =
/var\(--(?:color-(?:background|surface|dropdown|navbar|navbar-text|text|text-muted|primary|primary-hover)|border-subtle|border-color)\)/g;
const HARDCODED_UI_PALETTE =
/(?:text|bg|border|ring|from|to|via)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[1-9]00|50)(?:\/\d+)?/g;
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
@@ -42,7 +44,9 @@ describe("admin theme source audit", () => {
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
if (GRAPHICAL_ALLOWLIST.includes(normalized)) continue;
const source = readFileSync(file, "utf8");
const risky = source.match(/(?:text-(?:gray|slate)-(?:400|500|600|700|800|900)|text-white(?:\/\d+)?|\)\]0)/g);
const risky = source.match(
/(?:text-(?:gray|slate)-(?:400|500|600|700|800|900)|text-white(?:\/\d+)?|\)\]0)/g,
);
if (risky) violations.push(`${normalized}: ${[...new Set(risky)].join(", ")}`);
}
}
+14 -3
View File
@@ -9,7 +9,10 @@ export interface AuthorizationEvent {
error?: unknown;
}
const clean = (value: string) => value.replace(/(token|password|secret|cookie|authorization|select|insert|update|delete)[^\s]*/gi, "[REDACTED]").slice(0, 160);
const clean = (value: string) =>
value
.replace(/(token|password|secret|cookie|authorization|select|insert|update|delete)[^\s]*/gi, "[REDACTED]")
.slice(0, 160);
export function authorizationActivity(event: AuthorizationEvent) {
const description = [
@@ -18,6 +21,14 @@ export function authorizationActivity(event: AuthorizationEvent) {
event.permission ? `permission=${clean(event.permission)}` : null,
`source=${clean(event.source)}`,
`reason=${clean(event.reason)}`,
].filter(Boolean).join("; ");
return { staffId: event.userId, action: event.kind, description, targetType: "user", targetId: event.userId };
]
.filter(Boolean)
.join("; ");
return {
staffId: event.userId,
action: event.kind,
description,
targetType: "user",
targetId: event.userId,
};
}
+16 -2
View File
@@ -3,14 +3,28 @@ import { authorizationActivity } from "@/lib/admin/authorization-event";
describe("authorizationActivity", () => {
it("creates a safe rank-aware denial record", () => {
const record = authorizationActivity({ kind: "permission.denied", userId: 42, username: "admin", rank: 11, permission: "admin.logs.view", source: "/admin/logs", reason: "missing permission" });
const record = authorizationActivity({
kind: "permission.denied",
userId: 42,
username: "admin",
rank: 11,
permission: "admin.logs.view",
source: "/admin/logs",
reason: "missing permission",
});
expect(record.action).toBe("permission.denied");
expect(record.description).toContain("rank=11");
expect(record.description).toContain("permission=admin.logs.view");
});
it("redacts secrets and technical details", () => {
const record = authorizationActivity({ kind: "permission.load_error", userId: 42, rank: 11, source: "permissions", reason: "token=abc password=hunter2 SELECT * FROM users" });
const record = authorizationActivity({
kind: "permission.load_error",
userId: 42,
rank: 11,
source: "permissions",
reason: "token=abc password=hunter2 SELECT * FROM users",
});
expect(record.description).not.toMatch(/abc|hunter2|SELECT/i);
});
});
+12 -2
View File
@@ -4,6 +4,16 @@ import { authorizationActivity, type AuthorizationEvent } from "@/lib/admin/auth
export async function logAuthorizationEvent(event: AuthorizationEvent): Promise<void> {
const correlationId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
await logStaffActivity({ ...authorizationActivity(event), description: `${authorizationActivity(event).description}; correlation=${correlationId}` });
if (event.error) logServerError(event.kind, event.error, { correlationId, userId: event.userId, rank: event.rank, permission: event.permission ?? null, source: event.source });
await logStaffActivity({
...authorizationActivity(event),
description: `${authorizationActivity(event).description}; correlation=${correlationId}`,
});
if (event.error)
logServerError(event.kind, event.error, {
correlationId,
userId: event.userId,
rank: event.rank,
permission: event.permission ?? null,
source: event.source,
});
}
+27 -5
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { decideAuthorization, isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
describe("isDynamicSuperAdmin", () => {
it.each([[7, 7], [11, 11], [2000, 2000]])("accepts highest rank %i", (rank, highest) => {
it.each([
[7, 7],
[11, 11],
[2000, 2000],
])("accepts highest rank %i", (rank, highest) => {
expect(isDynamicSuperAdmin(rank, highest)).toBe(true);
});
it("demotes the previous highest rank", () => expect(isDynamicSuperAdmin(2000, 2001)).toBe(false));
@@ -11,8 +15,26 @@ describe("isDynamicSuperAdmin", () => {
describe("decideAuthorization", () => {
const actor = { id: 1, username: "admin", rank: 11 };
it("allows the dynamically highest rank", () => expect(decideAuthorization({ actor, highestRank: 11, permission: "admin.any", hasPermission: false }).allowed).toBe(true));
it("allows explicit ACL permission below highest", () => expect(decideAuthorization({ actor, highestRank: 12, permission: "admin.news.view", hasPermission: true }).allowed).toBe(true));
it("denies invalid ranks", () => expect(decideAuthorization({ actor: { ...actor, rank: 0 }, highestRank: 11, permission: "admin.any", hasPermission: true })).toMatchObject({ allowed: false, reason: "invalid_rank" }));
it("denies missing permission", () => expect(decideAuthorization({ actor, highestRank: 12, permission: "admin.any", hasPermission: false })).toMatchObject({ allowed: false, reason: "permission_denied" }));
it("allows the dynamically highest rank", () =>
expect(
decideAuthorization({ actor, highestRank: 11, permission: "admin.any", hasPermission: false }).allowed,
).toBe(true));
it("allows explicit ACL permission below highest", () =>
expect(
decideAuthorization({ actor, highestRank: 12, permission: "admin.news.view", hasPermission: true })
.allowed,
).toBe(true));
it("denies invalid ranks", () =>
expect(
decideAuthorization({
actor: { ...actor, rank: 0 },
highestRank: 11,
permission: "admin.any",
hasPermission: true,
}),
).toMatchObject({ allowed: false, reason: "invalid_rank" }));
it("denies missing permission", () =>
expect(
decideAuthorization({ actor, highestRank: 12, permission: "admin.any", hasPermission: false }),
).toMatchObject({ allowed: false, reason: "permission_denied" }));
});
+8 -4
View File
@@ -1,8 +1,11 @@
export interface AuthorizationActor { id: number; username: string; rank: number }
export interface AuthorizationActor {
id: number;
username: string;
rank: number;
}
export type AuthorizationDenialReason = "invalid_rank" | "permission_denied" | "no_ranks";
export type AuthorizationDecision =
| { allowed: true; superAdmin: boolean }
| { allowed: false; reason: AuthorizationDenialReason };
{ allowed: true; superAdmin: boolean } | { allowed: false; reason: AuthorizationDenialReason };
export function isDynamicSuperAdmin(rank: number, highestRank: number | null): boolean {
return Number.isInteger(rank) && rank > 0 && highestRank !== null && rank === highestRank;
@@ -14,7 +17,8 @@ export function decideAuthorization(input: {
permission?: string;
hasPermission: boolean;
}): AuthorizationDecision {
if (!Number.isInteger(input.actor.rank) || input.actor.rank <= 0) return { allowed: false, reason: "invalid_rank" };
if (!Number.isInteger(input.actor.rank) || input.actor.rank <= 0)
return { allowed: false, reason: "invalid_rank" };
if (input.highestRank === null) return { allowed: false, reason: "no_ranks" };
if (isDynamicSuperAdmin(input.actor.rank, input.highestRank)) return { allowed: true, superAdmin: true };
if (!input.permission || input.hasPermission) return { allowed: true, superAdmin: false };
+3 -1
View File
@@ -3,7 +3,9 @@ import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
describe("buildStaffActivityWhere", () => {
it("filters authorization events by prefix", () => {
expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({ action: { startsWith: "permission." } });
expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({
action: { startsWith: "permission." },
});
});
it("combines staff and search filters", () => {
const result = buildStaffActivityWhere({ q: "rank", staffId: 11, authorizationOnly: true });
+12 -6
View File
@@ -1,14 +1,20 @@
import type { Prisma } from "@/generated/prisma/client";
export interface StaffActivityFilters { q?: string; staffId?: number | null; action?: string | null; authorizationOnly?: boolean }
export interface StaffActivityFilters {
q?: string;
staffId?: number | null;
action?: string | null;
authorizationOnly?: boolean;
}
export function buildStaffActivityWhere(filters: StaffActivityFilters): Prisma.StaffActivitiesWhereInput {
const where: Prisma.StaffActivitiesWhereInput = {};
if (filters.q?.trim()) where.OR = [
{ action: { contains: filters.q.trim() } },
{ description: { contains: filters.q.trim() } },
{ ipAddress: { contains: filters.q.trim() } },
];
if (filters.q?.trim())
where.OR = [
{ action: { contains: filters.q.trim() } },
{ description: { contains: filters.q.trim() } },
{ ipAddress: { contains: filters.q.trim() } },
];
if (filters.staffId) where.userId = BigInt(filters.staffId);
if (filters.authorizationOnly) where.action = { startsWith: "permission." };
else if (filters.action) where.action = { contains: filters.action };
+1 -4
View File
@@ -4,10 +4,7 @@ export interface AdminMutationNotice {
message: string;
}
export function adminMutationNotice(params: {
saved?: string;
error?: string;
}): AdminMutationNotice | null {
export function adminMutationNotice(params: { saved?: string; error?: string }): AdminMutationNotice | null {
if (params.error) {
return {
tone: "danger",
+10 -3
View File
@@ -10,8 +10,15 @@ function db(user: { id: number; username: string; rank: number } | null, highest
describe("resolveAuthorizationState", () => {
it("uses current database rank and highest rank 2000", async () => {
await expect(resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 2000 }, 2000))).resolves.toEqual({ actor: { id: 7, username: "root", rank: 2000 }, highestRank: 2000 });
await expect(
resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 2000 }, 2000)),
).resolves.toEqual({ actor: { id: 7, username: "root", rank: 2000 }, highestRank: 2000 });
});
it("returns null for a deleted user", async () => expect(resolveAuthorizationState(7, db(null, 2000))).resolves.toBeNull());
it("fails closed when there are no ranks", async () => expect(resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 1 }, null))).resolves.toEqual({ actor: { id: 7, username: "root", rank: 1 }, highestRank: null }));
it("returns null for a deleted user", async () =>
expect(resolveAuthorizationState(7, db(null, 2000))).resolves.toBeNull());
it("fails closed when there are no ranks", async () =>
expect(resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 1 }, null))).resolves.toEqual({
actor: { id: 7, username: "root", rank: 1 },
highestRank: null,
}));
});
+10 -2
View File
@@ -1,11 +1,19 @@
import type { AuthorizationActor } from "@/lib/admin/authorization-policy";
export interface RankAuthorityDb {
user: { findUnique(args: { where: { id: number }; select: { id: true; username: true; rank: true } }): Promise<{ id: number; username: string; rank: number } | null> };
user: {
findUnique(args: {
where: { id: number };
select: { id: true; username: true; rank: true };
}): Promise<{ id: number; username: string; rank: number } | null>;
};
highestRank(): Promise<number | null>;
}
export async function resolveAuthorizationState(userId: number, db: RankAuthorityDb): Promise<{ actor: AuthorizationActor; highestRank: number | null } | null> {
export async function resolveAuthorizationState(
userId: number,
db: RankAuthorityDb,
): Promise<{ actor: AuthorizationActor; highestRank: number | null } | null> {
const [user, highestRank] = await Promise.all([
db.user.findUnique({ where: { id: userId }, select: { id: true, username: true, rank: true } }),
db.highestRank(),
+25 -19
View File
@@ -1,38 +1,44 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { logAuthorizationEvent } from '@/lib/admin/authorization-events'
import { canAccess, getApiAdminContext } from '@/lib/permissions'
import { logServerError } from '@/lib/server-log'
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
import { canAccess, getApiAdminContext } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
type AdminContext = NonNullable<Awaited<ReturnType<typeof getApiAdminContext>>>
type RouteContext = { params?: Promise<Record<string, string | string[]>> }
type AdminContext = NonNullable<Awaited<ReturnType<typeof getApiAdminContext>>>;
type RouteContext = { params?: Promise<Record<string, string | string[]>> };
type AdminHandler = (
request: NextRequest,
context: AdminContext,
routeContext: RouteContext,
) => Promise<Response> | Response
) => Promise<Response> | Response;
export function withAdmin(options: { permission?: string }, handler: AdminHandler) {
return async (request: NextRequest, routeContext: RouteContext = {}) => {
const context = await getApiAdminContext()
if (!context) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
if (options.permission && !canAccess(context.permissions, options.permission, context.session.user.rank)) {
const context = await getApiAdminContext();
if (!context) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
if (
options.permission &&
!canAccess(context.permissions, options.permission, context.session.user.rank)
) {
await logAuthorizationEvent({
kind: 'permission.denied',
kind: "permission.denied",
userId: context.session.user.id,
username: context.session.user.username,
rank: context.session.user.rank,
permission: options.permission,
source: request.nextUrl.pathname,
reason: 'API permission check denied',
})
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
reason: "API permission check denied",
});
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
try {
return await handler(request, context, routeContext)
return await handler(request, context, routeContext);
} catch (error) {
logServerError('admin.api_failed', error, { path: request.nextUrl.pathname, userId: context.session.user.id })
return NextResponse.json({ ok: false, error: 'Internal server error' }, { status: 500 })
logServerError("admin.api_failed", error, {
path: request.nextUrl.pathname,
userId: context.session.user.id,
});
return NextResponse.json({ ok: false, error: "Internal server error" }, { status: 500 });
}
}
};
}
+17 -20
View File
@@ -1,51 +1,48 @@
import { NextResponse } from 'next/server'
import { ZodError, type z } from 'zod'
import { NextResponse } from "next/server";
import { ZodError, type z } from "zod";
/** Throwable API error with HTTP status code */
export class ApiError extends Error {
status: number
status: number;
constructor(message: string, status: number = 400) {
super(message)
this.name = 'ApiError'
this.status = status
super(message);
this.name = "ApiError";
this.status = status;
}
}
/** Standard success response: { ok: true, ...data } */
export function apiOk(data?: Record<string, unknown>) {
return NextResponse.json({ ok: true, ...data })
return NextResponse.json({ ok: true, ...data });
}
/** Standard error response: { error: message } with given status */
export function apiError(message: string, status: number = 400) {
return NextResponse.json({ error: message }, { status })
return NextResponse.json({ error: message }, { status });
}
/** Validation error from Zod: { error: fieldErrors } with 400 */
export function apiValidationError(zodError: z.ZodError) {
return NextResponse.json({ error: zodError.flatten().fieldErrors }, { status: 400 })
return NextResponse.json({ error: zodError.flatten().fieldErrors }, { status: 400 });
}
/** Centralized error handler for API routes */
export function handleApiError(error: unknown): Response {
if (error instanceof ApiError) {
return apiError(error.message, error.status)
return apiError(error.message, error.status);
}
if (error instanceof ZodError) {
return apiValidationError(error)
return apiValidationError(error);
}
// Prisma P2025 "Record not found"
if (
error instanceof Error &&
(error.constructor.name === 'PrismaClientKnownRequestError' ||
error.name === 'PrismaClientKnownRequestError') &&
(error as Error & { code?: string }).code === 'P2025'
(error.constructor.name === "PrismaClientKnownRequestError" ||
error.name === "PrismaClientKnownRequestError") &&
(error as Error & { code?: string }).code === "P2025"
) {
return apiError('Not found', 404)
return apiError("Not found", 404);
}
console.error(
'[API error]',
error instanceof Error ? { message: error.message, name: error.name } : error,
)
return apiError('Internal server error', 500)
console.error("[API error]", error instanceof Error ? { message: error.message, name: error.name } : error);
return apiError("Internal server error", 500);
}
+3 -6
View File
@@ -19,10 +19,7 @@ describe("databaseUserId", () => {
expect(databaseUserId(42n)).toBe(42);
});
it.each([0n, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n])(
"rejects unsafe database id %s",
(value) => {
expect(databaseUserId(value)).toBeNull();
},
);
it.each([0n, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n])("rejects unsafe database id %s", (value) => {
expect(databaseUserId(value)).toBeNull();
});
});
+1 -1
View File
@@ -1 +1 @@
export * from './catalog-categories/index'
export * from "./catalog-categories/index";
File diff suppressed because it is too large. Load diff
+23 -23
View File
@@ -5,50 +5,50 @@
// ── Release Eras ───────────────────────────────────────────────────
export interface ReleaseEra {
key: string
label: string
labelEn: string
revisionMin: number
revisionMax: number
key: string;
label: string;
labelEn: string;
revisionMin: number;
revisionMax: number;
}
export const RELEASE_ERAS: ReleaseEra[] = [
{
key: 'classic',
label: 'Classici 2000-2003',
labelEn: 'Classic 2000-2003',
key: "classic",
label: "Classici 2000-2003",
labelEn: "Classic 2000-2003",
revisionMin: 0,
revisionMax: 19999,
},
{
key: 'mid',
label: 'Era HC 2004-2006',
labelEn: 'HC Era 2004-2006',
key: "mid",
label: "Era HC 2004-2006",
labelEn: "HC Era 2004-2006",
revisionMin: 20000,
revisionMax: 39999,
},
{
key: 'modern',
label: 'Moderni 2007-2009',
labelEn: 'Modern 2007-2009',
key: "modern",
label: "Moderni 2007-2009",
labelEn: "Modern 2007-2009",
revisionMin: 40000,
revisionMax: 59999,
},
{
key: 'recent',
label: 'Recenti 2010-2012',
labelEn: 'Recent 2010-2012',
key: "recent",
label: "Recenti 2010-2012",
labelEn: "Recent 2010-2012",
revisionMin: 60000,
revisionMax: 79999,
},
{
key: 'latest',
label: 'Ultimi 2013+',
labelEn: 'Latest 2013+',
key: "latest",
label: "Ultimi 2013+",
labelEn: "Latest 2013+",
revisionMin: 80000,
revisionMax: Infinity,
},
]
];
/**
* Match a revision number to a release era.
@@ -56,8 +56,8 @@ export const RELEASE_ERAS: ReleaseEra[] = [
export function matchEra(revision: number): ReleaseEra {
for (const era of RELEASE_ERAS) {
if (revision >= era.revisionMin && revision <= era.revisionMax) {
return era
return era;
}
}
return RELEASE_ERAS[RELEASE_ERAS.length - 1]
return RELEASE_ERAS[RELEASE_ERAS.length - 1];
}
+25 -25
View File
@@ -11,10 +11,10 @@
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
*/
export * from './categories'
export * from './eras'
export * from "./categories";
export * from "./eras";
import { SUPREME_CATEGORIES, type SupremeCategory } from './categories'
import { SUPREME_CATEGORIES, type SupremeCategory } from "./categories";
// ── Name Normalization ──────────────────────────────────────────
@@ -24,13 +24,13 @@ import { SUPREME_CATEGORIES, type SupremeCategory } from './categories'
* and lowercases the result.
*/
export function normalizeName(itemName: string): string {
return itemName.replace(/\*\d+$/, '').toLowerCase()
return itemName.replace(/\*\d+$/, "").toLowerCase();
}
// ── Category Detection ──────────────────────────────────────────
export const UNCATEGORIZED_KEY = 'uncategorized'
export const AUTO_PREFIX_MIN_ITEMS = 5
export const UNCATEGORIZED_KEY = "uncategorized";
export const AUTO_PREFIX_MIN_ITEMS = 5;
/**
* Find the supreme category for an item by classname and interaction type.
@@ -40,44 +40,44 @@ export const AUTO_PREFIX_MIN_ITEMS = 5
* Item names are normalized (strip *N suffix, lowercase) before matching.
*/
export function matchCategory(itemName: string, interactionType?: string): SupremeCategory {
const lower = normalizeName(itemName)
const lower = normalizeName(itemName);
// Pass 1: Interaction type (most specific)
for (const cat of SUPREME_CATEGORIES) {
if (interactionType && cat.interactionTypes?.includes(interactionType)) {
return cat
return cat;
}
}
// Pass 2: Keywords anywhere in name (user priority)
for (const cat of SUPREME_CATEGORIES) {
if (cat.key === UNCATEGORIZED_KEY) continue
if (cat.key === UNCATEGORIZED_KEY) continue;
for (const kw of cat.keywords) {
if (lower.includes(kw)) return cat
if (lower.includes(kw)) return cat;
}
}
// Pass 3: Regex prefix patterns (fallback)
for (const cat of SUPREME_CATEGORIES) {
if (cat.key === UNCATEGORIZED_KEY) continue
if (cat.key === UNCATEGORIZED_KEY) continue;
for (const pattern of cat.patterns) {
if (pattern.test(lower)) return cat
if (pattern.test(lower)) return cat;
}
}
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1]
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1];
}
// ── Auto-Prefix Grouping ────────────────────────────────────────────
export interface AutoDetectedCategory {
key: string
prefix: string
label: string
labelEn: string
icon: number
layout: string
autoDetected: true
key: string;
prefix: string;
label: string;
labelEn: string;
icon: number;
layout: string;
autoDetected: true;
}
/**
@@ -88,9 +88,9 @@ export interface AutoDetectedCategory {
* - Lowercase the result; return null if prefix is empty or 1 character
*/
export function extractPrefix(itemName: string): string | null {
const normalized = normalizeName(itemName)
const idx = normalized.indexOf('_')
if (idx > 1) return normalized.substring(0, idx)
const match = normalized.match(/^([a-z]{2,})/)
return match ? match[1] : null
const normalized = normalizeName(itemName);
const idx = normalized.indexOf("_");
if (idx > 1) return normalized.substring(0, idx);
const match = normalized.match(/^([a-z]{2,})/);
return match ? match[1] : null;
}
+41 -41
View File
@@ -1,47 +1,47 @@
/** All known catalog page layouts for Habbo/Arcturus. */
export const CATALOG_LAYOUTS = [
'default_3x3',
'frontpage',
'spaces_new',
'recycler',
'trophies',
'pets',
'pets2',
'pets3',
'soundmachine',
'guilds',
'info_loyalty',
'info_duckets',
'loyalty_vip_buy',
'single_bundle',
'club_buy',
'marketplace',
'badge_display',
'room_bundle',
'sold_ltd_items',
] as const
"default_3x3",
"frontpage",
"spaces_new",
"recycler",
"trophies",
"pets",
"pets2",
"pets3",
"soundmachine",
"guilds",
"info_loyalty",
"info_duckets",
"loyalty_vip_buy",
"single_bundle",
"club_buy",
"marketplace",
"badge_display",
"room_bundle",
"sold_ltd_items",
] as const;
export type CatalogLayout = (typeof CATALOG_LAYOUTS)[number]
export type CatalogLayout = (typeof CATALOG_LAYOUTS)[number];
/** Short human-readable description for each layout. Shown in the layout selector. */
export const LAYOUT_DESCRIPTIONS: Record<CatalogLayout, string> = {
default_3x3: 'Standard 3-column grid of items',
frontpage: 'Featured landing page with large teasers',
spaces_new: 'Wide 4-column grid used for themed spaces',
recycler: 'Ecotron recycler exchange page',
trophies: 'Trophy crafting form',
pets: 'Legacy pet purchase page',
pets2: 'Pet purchase page (v2)',
pets3: 'Pet purchase page (v3)',
soundmachine: 'Soundmachine track picker',
guilds: 'Guild/group creation page',
info_loyalty: 'Informational loyalty page (no items)',
info_duckets: 'Informational duckets page (no items)',
loyalty_vip_buy: 'HC/VIP loyalty purchase',
single_bundle: 'Single prominent bundle tile',
club_buy: 'HC subscription purchase page',
marketplace: 'Marketplace entry page',
badge_display: 'Badge showcase page',
room_bundle: 'Room bundle purchase page',
sold_ltd_items: 'Sold limited editions archive',
}
default_3x3: "Standard 3-column grid of items",
frontpage: "Featured landing page with large teasers",
spaces_new: "Wide 4-column grid used for themed spaces",
recycler: "Ecotron recycler exchange page",
trophies: "Trophy crafting form",
pets: "Legacy pet purchase page",
pets2: "Pet purchase page (v2)",
pets3: "Pet purchase page (v3)",
soundmachine: "Soundmachine track picker",
guilds: "Guild/group creation page",
info_loyalty: "Informational loyalty page (no items)",
info_duckets: "Informational duckets page (no items)",
loyalty_vip_buy: "HC/VIP loyalty purchase",
single_bundle: "Single prominent bundle tile",
club_buy: "HC subscription purchase page",
marketplace: "Marketplace entry page",
badge_display: "Badge showcase page",
room_bundle: "Room bundle purchase page",
sold_ltd_items: "Sold limited editions archive",
};
+188 -188
View File
@@ -5,220 +5,220 @@
*/
export const CATALOG_IT: Record<string, string> = {
// ── Main categories ───────────────────────────────────
frontpage: 'Pagina Principale',
'front page': 'Pagina Principale',
furniture: 'Mobili',
furni: 'Mobili',
rare: 'Rari',
rares: 'Rari',
'super rares': 'Super Rari',
'ultra rares': 'Ultra Rari',
limited: 'Limitati',
'limited edition': 'Edizione Limitata',
ltd: 'Limitati',
'new': 'Novità',
'new furni': 'Nuovi Mobili',
newest: 'Novità',
frontpage: "Pagina Principale",
"front page": "Pagina Principale",
furniture: "Mobili",
furni: "Mobili",
rare: "Rari",
rares: "Rari",
"super rares": "Super Rari",
"ultra rares": "Ultra Rari",
limited: "Limitati",
"limited edition": "Edizione Limitata",
ltd: "Limitati",
new: "Novità",
"new furni": "Nuovi Mobili",
newest: "Novità",
// ── Rooms & Spaces ────────────────────────────────────
rooms: 'Stanze',
'room bundles': 'Pacchetti Stanza',
'room bundle': 'Pacchetto Stanza',
spaces: 'Spazi',
walls: 'Pareti',
wall: 'Parete',
floors: 'Pavimenti',
floor: 'Pavimento',
landscapes: 'Paesaggi',
landscape: 'Paesaggio',
wallpaper: 'Carta da Parati',
wallpapers: 'Carte da Parati',
rooms: "Stanze",
"room bundles": "Pacchetti Stanza",
"room bundle": "Pacchetto Stanza",
spaces: "Spazi",
walls: "Pareti",
wall: "Parete",
floors: "Pavimenti",
floor: "Pavimento",
landscapes: "Paesaggi",
landscape: "Paesaggio",
wallpaper: "Carta da Parati",
wallpapers: "Carte da Parati",
// ── Pets ──────────────────────────────────────────────
pets: 'Animali',
'pet accessories': 'Accessori Animali',
'pet food': 'Cibo Animali',
horses: 'Cavalli',
dogs: 'Cani',
cats: 'Gatti',
crocodiles: 'Coccodrilli',
terriers: 'Terrier',
bears: 'Orsi',
pigs: 'Maiali',
lions: 'Leoni',
rhinos: 'Rinoceronti',
spiders: 'Ragni',
turtles: 'Tartarughe',
chickens: 'Galline',
frogs: 'Rane',
dragons: 'Draghi',
monkeys: 'Scimmie',
gnomes: 'Gnomi',
monsters: 'Mostri',
'monster plants': 'Piante Mostro',
butterflies: 'Farfalle',
bunnies: 'Coniglietti',
pigeons: 'Piccioni',
pets: "Animali",
"pet accessories": "Accessori Animali",
"pet food": "Cibo Animali",
horses: "Cavalli",
dogs: "Cani",
cats: "Gatti",
crocodiles: "Coccodrilli",
terriers: "Terrier",
bears: "Orsi",
pigs: "Maiali",
lions: "Leoni",
rhinos: "Rinoceronti",
spiders: "Ragni",
turtles: "Tartarughe",
chickens: "Galline",
frogs: "Rane",
dragons: "Draghi",
monkeys: "Scimmie",
gnomes: "Gnomi",
monsters: "Mostri",
"monster plants": "Piante Mostro",
butterflies: "Farfalle",
bunnies: "Coniglietti",
pigeons: "Piccioni",
// ── Economy ───────────────────────────────────────────
credits: 'Crediti',
diamonds: 'Diamanti',
duckets: 'Duckets',
pixels: 'Pixels',
marketplace: 'Mercatino',
recycler: 'Riciclatore',
ecotron: 'Ecotron',
credits: "Crediti",
diamonds: "Diamanti",
duckets: "Duckets",
pixels: "Pixels",
marketplace: "Mercatino",
recycler: "Riciclatore",
ecotron: "Ecotron",
// ── Habbo Club ────────────────────────────────────────
'habbo club': 'Club Habbo',
hc: 'Club Habbo',
vip: 'VIP',
'club furni': 'Mobili Club',
'club furniture': 'Mobili Club',
'club offers': 'Offerte Club',
membership: 'Abbonamento',
"habbo club": "Club Habbo",
hc: "Club Habbo",
vip: "VIP",
"club furni": "Mobili Club",
"club furniture": "Mobili Club",
"club offers": "Offerte Club",
membership: "Abbonamento",
// ── Building ──────────────────────────────────────────
building: 'Costruzione',
'builders club': 'Club Costruttori',
wired: 'Wired',
'wired furni': 'Mobili Wired',
teleports: 'Teletrasporti',
rollers: 'Rulli',
gates: 'Cancelli',
switches: 'Interruttori',
building: "Costruzione",
"builders club": "Club Costruttori",
wired: "Wired",
"wired furni": "Mobili Wired",
teleports: "Teletrasporti",
rollers: "Rulli",
gates: "Cancelli",
switches: "Interruttori",
// ── Decorations / Themes ──────────────────────────────
decorations: 'Decorazioni',
decoration: 'Decorazione',
plants: 'Piante',
lighting: 'Illuminazione',
lights: 'Luci',
candles: 'Candele',
kitchen: 'Cucina',
bathroom: 'Bagno',
bedroom: 'Camera da Letto',
'living room': 'Soggiorno',
garden: 'Giardino',
outdoor: 'Esterno',
office: 'Ufficio',
study: 'Studio',
music: 'Musica',
sound: 'Suoni',
'sound machine': 'Jukebox',
decorations: "Decorazioni",
decoration: "Decorazione",
plants: "Piante",
lighting: "Illuminazione",
lights: "Luci",
candles: "Candele",
kitchen: "Cucina",
bathroom: "Bagno",
bedroom: "Camera da Letto",
"living room": "Soggiorno",
garden: "Giardino",
outdoor: "Esterno",
office: "Ufficio",
study: "Studio",
music: "Musica",
sound: "Suoni",
"sound machine": "Jukebox",
// ── Collections / Series ──────────────────────────────
gothic: 'Gotico',
executive: 'Executive',
mode: 'Moda',
iced: 'Ghiacciato',
pura: 'Pura',
lodge: 'Baita',
plastic: 'Plastica',
area: 'Area',
asian: 'Asiatico',
candy: 'Caramelle',
chrome: 'Cromo',
country: 'Country',
diner: 'Tavola Calda',
fairy: 'Fatato',
greek: 'Greco',
haunted: 'Stregato',
hollywood: 'Hollywood',
japanese: 'Giapponese',
jungle: 'Giungla',
love: 'Amore',
marble: 'Marmo',
medieval: 'Medievale',
neon: 'Neon',
prairie: 'Prateria',
romantic: 'Romantico',
'sci-fi': 'Fantascienza',
sport: 'Sport',
sports: 'Sport',
street: 'Strada',
tropical: 'Tropicale',
victorian: 'Vittoriano',
vintage: 'Vintage',
winter: 'Inverno',
summer: 'Estate',
spring: 'Primavera',
autumn: 'Autunno',
fall: 'Autunno',
christmas: 'Natale',
xmas: 'Natale',
easter: 'Pasqua',
halloween: 'Halloween',
valentine: 'San Valentino',
valentines: 'San Valentino',
'st patricks': 'San Patrizio',
gothic: "Gotico",
executive: "Executive",
mode: "Moda",
iced: "Ghiacciato",
pura: "Pura",
lodge: "Baita",
plastic: "Plastica",
area: "Area",
asian: "Asiatico",
candy: "Caramelle",
chrome: "Cromo",
country: "Country",
diner: "Tavola Calda",
fairy: "Fatato",
greek: "Greco",
haunted: "Stregato",
hollywood: "Hollywood",
japanese: "Giapponese",
jungle: "Giungla",
love: "Amore",
marble: "Marmo",
medieval: "Medievale",
neon: "Neon",
prairie: "Prateria",
romantic: "Romantico",
"sci-fi": "Fantascienza",
sport: "Sport",
sports: "Sport",
street: "Strada",
tropical: "Tropicale",
victorian: "Vittoriano",
vintage: "Vintage",
winter: "Inverno",
summer: "Estate",
spring: "Primavera",
autumn: "Autunno",
fall: "Autunno",
christmas: "Natale",
xmas: "Natale",
easter: "Pasqua",
halloween: "Halloween",
valentine: "San Valentino",
valentines: "San Valentino",
"st patricks": "San Patrizio",
// ── Games & Activities ────────────────────────────────
games: 'Giochi',
game: 'Gioco',
'battle banzai': 'Battaglia Banzai',
freeze: 'Freeze',
football: 'Calcio',
snowstorm: 'Tempesta di Neve',
'roller skating': 'Pattinaggio',
trophies: 'Trofei',
trophy: 'Trofeo',
prizes: 'Premi',
rewards: 'Ricompense',
games: "Giochi",
game: "Gioco",
"battle banzai": "Battaglia Banzai",
freeze: "Freeze",
football: "Calcio",
snowstorm: "Tempesta di Neve",
"roller skating": "Pattinaggio",
trophies: "Trofei",
trophy: "Trofeo",
prizes: "Premi",
rewards: "Ricompense",
// ── Badges & Clothing ────────────────────────────────
badges: 'Distintivi',
badge: 'Distintivo',
clothing: 'Abbigliamento',
clothes: 'Vestiti',
effects: 'Effetti',
dances: 'Balli',
badges: "Distintivi",
badge: "Distintivo",
clothing: "Abbigliamento",
clothes: "Vestiti",
effects: "Effetti",
dances: "Balli",
// ── Bots & Features ──────────────────────────────────
bots: 'Bot',
bot: 'Bot',
groups: 'Gruppi',
guilds: 'Gilde',
guild: 'Gilda',
'group furni': 'Mobili Gruppo',
bots: "Bot",
bot: "Bot",
groups: "Gruppi",
guilds: "Gilde",
guild: "Gilda",
"group furni": "Mobili Gruppo",
// ── Misc ──────────────────────────────────────────────
offers: 'Offerte',
specials: 'Speciali',
special: 'Speciale',
deals: 'Occasioni',
sale: 'Saldi',
bundles: 'Pacchetti',
bundle: 'Pacchetto',
promotions: 'Promozioni',
promo: 'Promozione',
info: 'Informazioni',
help: 'Aiuto',
loyalty: 'Fedeltà',
'loyalty rewards': 'Premi Fedeltà',
gift: 'Regalo',
gifts: 'Regali',
'gift shop': 'Negozio Regali',
'credit furni': 'Mobili Crediti',
exchange: 'Cambio',
'sold out': 'Esaurito',
'coming soon': 'In Arrivo',
all: 'Tutto',
search: 'Cerca',
featured: 'In Evidenza',
popular: 'Popolari',
'top picks': 'Le Migliori',
trending: 'Tendenze',
collections: 'Collezioni',
collection: 'Collezione',
'imported furni': 'Mobili Importati',
}
offers: "Offerte",
specials: "Speciali",
special: "Speciale",
deals: "Occasioni",
sale: "Saldi",
bundles: "Pacchetti",
bundle: "Pacchetto",
promotions: "Promozioni",
promo: "Promozione",
info: "Informazioni",
help: "Aiuto",
loyalty: "Fedeltà",
"loyalty rewards": "Premi Fedeltà",
gift: "Regalo",
gifts: "Regali",
"gift shop": "Negozio Regali",
"credit furni": "Mobili Crediti",
exchange: "Cambio",
"sold out": "Esaurito",
"coming soon": "In Arrivo",
all: "Tutto",
search: "Cerca",
featured: "In Evidenza",
popular: "Popolari",
"top picks": "Le Migliori",
trending: "Tendenze",
collections: "Collezioni",
collection: "Collezione",
"imported furni": "Mobili Importati",
};
/**
* Get Italian translation for a catalog caption.
* Returns the translated name if found, otherwise null.
*/
export function translateCaption(caption: string): string | null {
const key = caption.toLowerCase().trim()
return CATALOG_IT[key] ?? null
const key = caption.toLowerCase().trim();
return CATALOG_IT[key] ?? null;
}
+19 -19
View File
@@ -10,35 +10,35 @@
*/
export interface SoundtrackOption {
id: number
code: string
name: string
author: string
length: number
id: number;
code: string;
name: string;
author: string;
length: number;
}
let cachedOptions: SoundtrackOption[] | null = null
let inflight: Promise<SoundtrackOption[]> | null = null
let cachedOptions: SoundtrackOption[] | null = null;
let inflight: Promise<SoundtrackOption[]> | null = null;
export async function loadSongPickerOptions(): Promise<SoundtrackOption[]> {
if (cachedOptions) return cachedOptions
if (inflight) return inflight
if (cachedOptions) return cachedOptions;
if (inflight) return inflight;
inflight = (async () => {
const res = await fetch('/api/admin/sounds', { cache: 'no-store' })
if (!res.ok) throw new Error('Failed to load soundtracks')
const json = (await res.json()) as { ok: boolean; items?: SoundtrackOption[] }
cachedOptions = json.items ?? []
inflight = null
return cachedOptions
})()
return inflight
const res = await fetch("/api/admin/sounds", { cache: "no-store" });
if (!res.ok) throw new Error("Failed to load soundtracks");
const json = (await res.json()) as { ok: boolean; items?: SoundtrackOption[] };
cachedOptions = json.items ?? [];
inflight = null;
return cachedOptions;
})();
return inflight;
}
export function getCachedSongPickerOptions(): SoundtrackOption[] | null {
return cachedOptions
return cachedOptions;
}
/** Clear the cache so new uploads show up on next open. */
export function invalidateSongPickerCache(): void {
cachedOptions = null
cachedOptions = null;
}
+45 -45
View File
@@ -3,95 +3,95 @@
// for the saveClientTranslations server action: only `id`s defined here can be
// written to.
export type ClientTranslationFormat = 'json5' | 'json'
export type ClientTranslationFormat = "json5" | "json";
export interface ClientTranslationFile {
id: string
id: string;
/** Path relative to the project root. */
relPath: string
format: ClientTranslationFormat
language: 'it' | 'en' | 'shared'
readOnly: boolean
relPath: string;
format: ClientTranslationFormat;
language: "it" | "en" | "shared";
readOnly: boolean;
/**
* True when the on-disk file commonly contains comments that the json5
* serializer cannot preserve. Used to surface a warning in the UI.
*/
hasComments: boolean
hasComments: boolean;
/** Free-form note shown in the UI (e.g. why a file is read-only). */
note?: string
note?: string;
}
export const CLIENT_TRANSLATION_FILES: ClientTranslationFile[] = [
{
id: 'ui-texts-it',
relPath: 'public/nitro-assets/config/UITexts.json5',
format: 'json5',
language: 'it',
id: "ui-texts-it",
relPath: "public/nitro-assets/config/UITexts.json5",
format: "json5",
language: "it",
readOnly: false,
hasComments: true,
note: 'Override del client (sovrascrive ExternalTexts).',
note: "Override del client (sovrascrive ExternalTexts).",
},
{
id: 'ui-texts-en',
relPath: 'public/nitro-assets/config/UITexts_en.json5',
format: 'json5',
language: 'en',
id: "ui-texts-en",
relPath: "public/nitro-assets/config/UITexts_en.json5",
format: "json5",
language: "en",
readOnly: true,
hasComments: true,
note: 'Riferimento EN — non viene caricato dal client.',
note: "Riferimento EN — non viene caricato dal client.",
},
{
id: 'external-texts',
relPath: 'public/nitro-assets/config/ExternalTexts.json5',
format: 'json5',
language: 'shared',
id: "external-texts",
relPath: "public/nitro-assets/config/ExternalTexts.json5",
format: "json5",
language: "shared",
readOnly: false,
hasComments: false,
},
{
id: 'external-texts-badges',
relPath: 'public/nitro-assets/config/ExternalTexts_Badges.json5',
format: 'json5',
language: 'shared',
id: "external-texts-badges",
relPath: "public/nitro-assets/config/ExternalTexts_Badges.json5",
format: "json5",
language: "shared",
readOnly: false,
hasComments: false,
note: 'File grande (~30k voci): usa la ricerca.',
note: "File grande (~30k voci): usa la ricerca.",
},
{
id: 'catalog-texts',
relPath: 'public/nitro-assets/config/CatalogTexts.json5',
format: 'json5',
language: 'shared',
id: "catalog-texts",
relPath: "public/nitro-assets/config/CatalogTexts.json5",
format: "json5",
language: "shared",
readOnly: false,
hasComments: false,
},
{
id: 'gamedata-external-texts',
relPath: 'public/nitro-assets/gamedata/ExternalTexts.json',
format: 'json',
language: 'shared',
id: "gamedata-external-texts",
relPath: "public/nitro-assets/gamedata/ExternalTexts.json",
format: "json",
language: "shared",
readOnly: true,
hasComments: false,
note: 'Copia "live" servita al client. Generata dal build, modifiche manuali sovrascritte.',
},
{
id: 'badge-texts-en',
relPath: 'public/nitro3/localization/badge-texts-en.json',
format: 'json',
language: 'en',
id: "badge-texts-en",
relPath: "public/nitro3/localization/badge-texts-en.json",
format: "json",
language: "en",
readOnly: false,
hasComments: false,
},
{
id: 'badge-texts-it',
relPath: 'public/nitro3/localization/badge-texts-it.json',
format: 'json',
language: 'it',
id: "badge-texts-it",
relPath: "public/nitro3/localization/badge-texts-it.json",
format: "json",
language: "it",
readOnly: false,
hasComments: false,
},
]
];
export function getClientTranslationFile(id: string): ClientTranslationFile | undefined {
return CLIENT_TRANSLATION_FILES.find((f) => f.id === id)
return CLIENT_TRANSLATION_FILES.find((f) => f.id === id);
}
+14 -14
View File
@@ -1,42 +1,42 @@
const CLASSNAME_CHAR_RE = /^[a-z0-9_*\-.]+$/
const CLASSNAME_CHAR_RE = /^[a-z0-9_*\-.]+$/;
export function getBaseClassname(classname: string): string {
const star = classname.indexOf('*')
return star >= 0 ? classname.substring(0, star) : classname
const star = classname.indexOf("*");
return star >= 0 ? classname.substring(0, star) : classname;
}
export function getSafeClassnameStem(classname: string): string {
return classname.replace(/\*/g, '_')
return classname.replace(/\*/g, "_");
}
export function getIconFileName(classname: string): string {
return `${getSafeClassnameStem(classname)}_icon.png`
return `${getSafeClassnameStem(classname)}_icon.png`;
}
export function getSwfFileName(classname: string): string {
return `${getBaseClassname(classname)}.swf`
return `${getBaseClassname(classname)}.swf`;
}
export function getNitroFileName(classname: string): string {
return `${getBaseClassname(classname)}.nitro`
return `${getBaseClassname(classname)}.nitro`;
}
export function normalizeClassname(raw: string): string {
return raw.trim().toLowerCase()
return raw.trim().toLowerCase();
}
export function isValidClassname(classname: string): boolean {
if (!classname) return false
return CLASSNAME_CHAR_RE.test(classname)
if (!classname) return false;
return CLASSNAME_CHAR_RE.test(classname);
}
export const LOCAL_ICON_URL_PREFIX = '/swf/dcr/hof_furni/icons/'
export const LOCAL_ICON_URL_PREFIX = "/swf/dcr/hof_furni/icons/";
export function getLocalIconUrl(classname: string): string {
return `${LOCAL_ICON_URL_PREFIX}${encodeURIComponent(getIconFileName(classname))}`
return `${LOCAL_ICON_URL_PREFIX}${encodeURIComponent(getIconFileName(classname))}`;
}
export function getHabboCdnIconUrl(classname: string, revision: number): string {
const base = getBaseClassname(classname)
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`
const base = getBaseClassname(classname);
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
}
+9 -16
View File
@@ -5,11 +5,11 @@
* Override with NEXT_PUBLIC_IMAGER_URL when needed (e.g. CDN in front).
*/
const IMAGER_URL = process.env.NEXT_PUBLIC_IMAGER_URL || '/api/imaging/avatar'
const IMAGER_URL = process.env.NEXT_PUBLIC_IMAGER_URL || "/api/imaging/avatar";
export type { AvatarOptions } from '@/types/admin'
export type { AvatarOptions } from "@/types/admin";
import type { AvatarOptions } from '@/types/admin'
import type { AvatarOptions } from "@/types/admin";
/**
* Build an avatar image URL served by the self-hosted imager.
@@ -20,25 +20,18 @@ import type { AvatarOptions } from '@/types/admin'
* getAvatarUrl(look, { size: 'm', gesture: 'sml', direction: 2 })
*/
export function getAvatarUrl(figure: string, options: AvatarOptions = {}): string {
const {
size = 'm',
direction = 2,
headDirection = 3,
headOnly = false,
gesture,
action,
} = options
const { size = "m", direction = 2, headDirection = 3, headOnly = false, gesture, action } = options;
const params = new URLSearchParams({
figure,
direction: String(direction),
head_direction: String(headDirection),
size,
})
});
if (headOnly) params.set('headonly', '1')
if (gesture) params.set('gesture', gesture)
if (action) params.set('action', action)
if (headOnly) params.set("headonly", "1");
if (gesture) params.set("gesture", gesture);
if (action) params.set("action", action);
return `${IMAGER_URL}?${params.toString()}`
return `${IMAGER_URL}?${params.toString()}`;
}
+21 -5
View File
@@ -2,11 +2,27 @@ import { existsSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const ROUTES = [
"badges", "badges/edit", "clone", "clone/batch", "clone/icon",
"clothing", "clothing/batch", "clothing/sets", "clothing/sets/batch",
"effects", "effects/batch", "furni", "furni/batch", "furni/batch-regen",
"furni/nitro-editor", "furni/resync", "pets", "pets/batch", "pets/icon",
"repair", "repair/audit",
"badges",
"badges/edit",
"clone",
"clone/batch",
"clone/icon",
"clothing",
"clothing/batch",
"clothing/sets",
"clothing/sets/batch",
"effects",
"effects/batch",
"furni",
"furni/batch",
"furni/batch-regen",
"furni/nitro-editor",
"furni/resync",
"pets",
"pets/batch",
"pets/icon",
"repair",
"repair/audit",
];
describe("admin import backend contract", () => {
+28 -28
View File
@@ -11,22 +11,22 @@
export interface PatchResult {
/** The new file content, with surgical edits applied. */
content: string
content: string;
/** Keys that we could not patch surgically (missing or unusual format). */
unpatchedKeys: string[]
unpatchedKeys: string[];
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeForQuote(value: string, quote: '"' | "'"): string {
// Escape backslashes first, then the chosen quote. Newlines/tabs are
// preserved as escape sequences so we don't break the JSON5 parser.
let out = value.replace(/\\/g, '\\\\')
out = out.replace(new RegExp(quote, 'g'), `\\${quote}`)
out = out.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')
return out
let out = value.replace(/\\/g, "\\\\");
out = out.replace(new RegExp(quote, "g"), `\\${quote}`);
out = out.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
return out;
}
/**
@@ -39,40 +39,40 @@ export function patchJson5(
original: Record<string, string>,
updated: Record<string, string>,
): PatchResult {
let result = raw
const unpatchedKeys: string[] = []
let result = raw;
const unpatchedKeys: string[] = [];
for (const [key, newVal] of Object.entries(updated)) {
if (!(key in original)) {
// Key did not exist on disk — append/merge logic is the caller's job.
unpatchedKeys.push(key)
continue
unpatchedKeys.push(key);
continue;
}
if (original[key] === newVal) continue
if (original[key] === newVal) continue;
const keyPattern = `(['"]?)${escapeRegExp(key)}\\1\\s*:\\s*(["'])((?:\\\\.|(?!\\2).)*)\\2`
const re = new RegExp(keyPattern)
const idx = result.search(re)
const keyPattern = `(['"]?)${escapeRegExp(key)}\\1\\s*:\\s*(["'])((?:\\\\.|(?!\\2).)*)\\2`;
const re = new RegExp(keyPattern);
const idx = result.search(re);
if (idx < 0) {
unpatchedKeys.push(key)
continue
unpatchedKeys.push(key);
continue;
}
const match = re.exec(result)
const match = re.exec(result);
if (!match) {
unpatchedKeys.push(key)
continue
unpatchedKeys.push(key);
continue;
}
const valueQuote = match[2] as '"' | "'"
const oldValueLen = match[3].length
const fullLen = match[0].length
const valueQuote = match[2] as '"' | "'";
const oldValueLen = match[3].length;
const fullLen = match[0].length;
// prefix keeps everything up to and including the opening value quote
const prefix = match[0].slice(0, fullLen - oldValueLen - 1)
const escaped = escapeForQuote(newVal, valueQuote)
const replacement = prefix + escaped + valueQuote
const prefix = match[0].slice(0, fullLen - oldValueLen - 1);
const escaped = escapeForQuote(newVal, valueQuote);
const replacement = prefix + escaped + valueQuote;
result = result.slice(0, idx) + replacement + result.slice(idx + fullLen)
result = result.slice(0, idx) + replacement + result.slice(idx + fullLen);
}
return { content: result, unpatchedKeys }
return { content: result, unpatchedKeys };
}
+19 -5
View File
@@ -7,8 +7,21 @@ export interface MissingLocalImport {
}
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
const resolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
const importPattern = /(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|require\(\s*["']([^"']+)["']\s*\)/g;
const resolutionSuffixes = [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".css",
"/index.ts",
"/index.tsx",
"/index.js",
"/index.jsx",
];
const importPattern =
/(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|require\(\s*["']([^"']+)["']\s*\)/g;
async function collectSourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
@@ -49,7 +62,8 @@ export async function findMissingLocalImports(root: string): Promise<MissingLoca
const source = await readFile(importer, "utf8");
for (const match of source.matchAll(importPattern)) {
const specifier = match[1] ?? match[2] ?? match[3];
if (!specifier?.startsWith("@/") && !specifier?.startsWith("./") && !specifier?.startsWith("../")) continue;
if (!specifier?.startsWith("@/") && !specifier?.startsWith("./") && !specifier?.startsWith("../"))
continue;
const base = specifier.startsWith("@/")
? resolve(src, specifier.slice(2))
: resolve(dirname(importer), specifier);
@@ -62,7 +76,7 @@ export async function findMissingLocalImports(root: string): Promise<MissingLoca
}
}
return missing.sort((a, b) =>
a.importer.localeCompare(b.importer) || a.specifier.localeCompare(b.specifier),
return missing.sort(
(a, b) => a.importer.localeCompare(b.importer) || a.specifier.localeCompare(b.specifier),
);
}
+36 -40
View File
@@ -8,77 +8,73 @@
* All suggestions are non-forced — just ranked higher in the combobox.
*/
const STORAGE_KEY = 'catalog_move_suggestions'
const STORAGE_KEY = "catalog_move_suggestions";
interface MoveRecord {
targetPageId: number
count: number
targetPageId: number;
count: number;
}
interface SuggestionStore {
/** interaction type → target pages with count */
byType: Record<string, MoveRecord[]>
byType: Record<string, MoveRecord[]>;
/** item name prefix (first segment before _) → target pages with count */
byPrefix: Record<string, MoveRecord[]>
byPrefix: Record<string, MoveRecord[]>;
/** last N recent targets */
recent: number[]
recent: number[];
}
function load(): SuggestionStore {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw)
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return { byType: {}, byPrefix: {}, recent: [] }
return { byType: {}, byPrefix: {}, recent: [] };
}
function save(store: SuggestionStore) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {}
}
function extractPrefix(itemName: string): string {
if (!itemName) return ''
const idx = itemName.indexOf('_')
return idx > 0 ? itemName.substring(0, idx).toLowerCase() : itemName.toLowerCase()
if (!itemName) return "";
const idx = itemName.indexOf("_");
return idx > 0 ? itemName.substring(0, idx).toLowerCase() : itemName.toLowerCase();
}
function incrementRecord(records: MoveRecord[], targetPageId: number): MoveRecord[] {
const existing = records.find((r) => r.targetPageId === targetPageId)
const existing = records.find((r) => r.targetPageId === targetPageId);
if (existing) {
return records
.map((r) => (r.targetPageId === targetPageId ? { ...r, count: r.count + 1 } : r))
.sort((a, b) => b.count - a.count)
.sort((a, b) => b.count - a.count);
}
return [...records, { targetPageId, count: 1 }].sort((a, b) => b.count - a.count).slice(0, 10)
return [...records, { targetPageId, count: 1 }].sort((a, b) => b.count - a.count).slice(0, 10);
}
/**
* Record a move for learning. Call this after successfully moving an item.
*/
export function recordMove(opts: {
interactionType?: string
itemName?: string
targetPageId: number
}) {
const store = load()
const { interactionType, itemName, targetPageId } = opts
export function recordMove(opts: { interactionType?: string; itemName?: string; targetPageId: number }) {
const store = load();
const { interactionType, itemName, targetPageId } = opts;
if (interactionType) {
const key = interactionType.toLowerCase()
store.byType[key] = incrementRecord(store.byType[key] || [], targetPageId)
const key = interactionType.toLowerCase();
store.byType[key] = incrementRecord(store.byType[key] || [], targetPageId);
}
const prefix = extractPrefix(itemName ?? '')
const prefix = extractPrefix(itemName ?? "");
if (prefix && prefix.length > 1) {
store.byPrefix[prefix] = incrementRecord(store.byPrefix[prefix] || [], targetPageId)
store.byPrefix[prefix] = incrementRecord(store.byPrefix[prefix] || [], targetPageId);
}
// Recent (deduplicate, keep last 10)
store.recent = [targetPageId, ...store.recent.filter((id) => id !== targetPageId)].slice(0, 10)
store.recent = [targetPageId, ...store.recent.filter((id) => id !== targetPageId)].slice(0, 10);
save(store)
save(store);
}
/**
@@ -86,34 +82,34 @@ export function recordMove(opts: {
* Returns unique page IDs ordered by best match.
*/
export function getSuggestions(opts: { interactionType?: string; itemName?: string }): number[] {
const store = load()
const { interactionType, itemName } = opts
const scores = new Map<number, number>()
const store = load();
const { interactionType, itemName } = opts;
const scores = new Map<number, number>();
// By interaction type (highest weight)
if (interactionType) {
const records = store.byType[interactionType.toLowerCase()] || []
const records = store.byType[interactionType.toLowerCase()] || [];
for (const r of records) {
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 3)
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 3);
}
}
// By name prefix
const prefix = extractPrefix(itemName ?? '')
const prefix = extractPrefix(itemName ?? "");
if (prefix && prefix.length > 1) {
const records = store.byPrefix[prefix] || []
const records = store.byPrefix[prefix] || [];
for (const r of records) {
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 2)
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 2);
}
}
// Recent (lowest weight)
for (let i = 0; i < store.recent.length; i++) {
const id = store.recent[i]
scores.set(id, (scores.get(id) ?? 0) + (10 - i))
const id = store.recent[i];
scores.set(id, (scores.get(id) ?? 0) + (10 - i));
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.map(([id]) => id)
.map(([id]) => id);
}
+54 -54
View File
@@ -3,66 +3,66 @@
// (modules.ts → sidebar.tsx) without pulling in prisma/server-only deps.
export const PERMS = {
ADMIN_DASHBOARD: 'admin.dashboard',
USERS_VIEW: 'admin.users.view',
USERS_EDIT: 'admin.users.edit',
USERS_BAN: 'admin.users.ban',
USERS_RESET_PASSWORD: 'admin.users.reset_password',
ROOMS_VIEW: 'admin.room.view',
ROOMS_EDIT: 'admin.room.edit',
ROOMS_DELETE: 'admin.room.delete',
BANS_VIEW: 'admin.bans.view',
NEWS_VIEW: 'admin.news.view',
NEWS_EDIT: 'admin.news.edit',
LOGS_VIEW: 'admin.logs.view',
SETTINGS_VIEW: 'admin.settings.view',
SETTINGS_EDIT: 'admin.settings.edit',
PERMISSIONS_MANAGE: 'admin.permissions.manage',
SHOP_VIEW: 'admin.shop.view',
SHOP_EDIT: 'admin.shop.edit',
WORDFILTER_VIEW: 'admin.wordfilter.view',
WORDFILTER_EDIT: 'admin.wordfilter.edit',
CATALOG_VIEW: 'admin.catalog.view',
CATALOG_EDIT: 'admin.catalog.edit',
RCON_EXECUTE: 'admin.rcon.execute',
EXPORT: 'admin.export',
PREFIXES_VIEW: 'admin.prefixes.view',
PREFIXES_EDIT: 'admin.prefixes.edit',
TICKETS_VIEW: 'admin.tickets.view',
TICKETS_EDIT: 'admin.tickets.edit',
MODERATION_VIEW: 'admin.moderation.view',
MODERATION_EDIT: 'admin.moderation.edit',
ADMIN_DASHBOARD: "admin.dashboard",
USERS_VIEW: "admin.users.view",
USERS_EDIT: "admin.users.edit",
USERS_BAN: "admin.users.ban",
USERS_RESET_PASSWORD: "admin.users.reset_password",
ROOMS_VIEW: "admin.room.view",
ROOMS_EDIT: "admin.room.edit",
ROOMS_DELETE: "admin.room.delete",
BANS_VIEW: "admin.bans.view",
NEWS_VIEW: "admin.news.view",
NEWS_EDIT: "admin.news.edit",
LOGS_VIEW: "admin.logs.view",
SETTINGS_VIEW: "admin.settings.view",
SETTINGS_EDIT: "admin.settings.edit",
PERMISSIONS_MANAGE: "admin.permissions.manage",
SHOP_VIEW: "admin.shop.view",
SHOP_EDIT: "admin.shop.edit",
WORDFILTER_VIEW: "admin.wordfilter.view",
WORDFILTER_EDIT: "admin.wordfilter.edit",
CATALOG_VIEW: "admin.catalog.view",
CATALOG_EDIT: "admin.catalog.edit",
RCON_EXECUTE: "admin.rcon.execute",
EXPORT: "admin.export",
PREFIXES_VIEW: "admin.prefixes.view",
PREFIXES_EDIT: "admin.prefixes.edit",
TICKETS_VIEW: "admin.tickets.view",
TICKETS_EDIT: "admin.tickets.edit",
MODERATION_VIEW: "admin.moderation.view",
MODERATION_EDIT: "admin.moderation.edit",
// ── Events Module ──
EVENTS_VIEW: 'admin.events.view',
EVENTS_EDIT: 'admin.events.edit',
EVENTS_VIEW: "admin.events.view",
EVENTS_EDIT: "admin.events.edit",
// ── Analytics Module ──
ANALYTICS_VIEW: 'admin.analytics.view',
ANALYTICS_EXPORT: 'admin.analytics.export',
ANALYTICS_VIEW: "admin.analytics.view",
ANALYTICS_EXPORT: "admin.analytics.export",
// ── DevOps Module ──
DEVOPS_VIEW: 'admin.devops.view',
DEVOPS_EDIT: 'admin.devops.edit',
DEVOPS_VIEW: "admin.devops.view",
DEVOPS_EDIT: "admin.devops.edit",
// ── Polls Module ──
POLLS_VIEW: 'admin.polls.view',
POLLS_EDIT: 'admin.polls.edit',
POLLS_VIEW: "admin.polls.view",
POLLS_EDIT: "admin.polls.edit",
// ── Notifications Module ──
NOTIFICATIONS_VIEW: 'admin.notifications.view',
NOTIFICATIONS_EDIT: 'admin.notifications.edit',
NOTIFICATIONS_VIEW: "admin.notifications.view",
NOTIFICATIONS_EDIT: "admin.notifications.edit",
// ── CMS Pages Module ──
PAGES_VIEW: 'admin.pages.view',
PAGES_EDIT: 'admin.pages.edit',
PAGES_VIEW: "admin.pages.view",
PAGES_EDIT: "admin.pages.edit",
// ── Banners Module ──
BANNERS_VIEW: 'admin.banners.view',
BANNERS_EDIT: 'admin.banners.edit',
BANNERS_VIEW: "admin.banners.view",
BANNERS_EDIT: "admin.banners.edit",
// ── Assets Module ──
ASSETS_IMPORT: 'admin.assets.import',
ASSETS_IMPORT: "admin.assets.import",
// ── Mod Panel Permissions ──
MOD_DASHBOARD: 'mod.dashboard',
MOD_CFH_VIEW: 'mod.cfh.view',
MOD_CFH_EDIT: 'mod.cfh.edit',
MOD_ACTIONS: 'mod.actions',
MOD_TEAM_VIEW: 'mod.team.view',
MOD_TICKETS_VIEW: 'mod.tickets.view',
MOD_TICKETS_EDIT: 'mod.tickets.edit',
MOD_USERS_VIEW: 'mod.users.view',
MOD_BANS_VIEW: 'mod.bans.view',
} as const
MOD_DASHBOARD: "mod.dashboard",
MOD_CFH_VIEW: "mod.cfh.view",
MOD_CFH_EDIT: "mod.cfh.edit",
MOD_ACTIONS: "mod.actions",
MOD_TEAM_VIEW: "mod.team.view",
MOD_TICKETS_VIEW: "mod.tickets.view",
MOD_TICKETS_EDIT: "mod.tickets.edit",
MOD_USERS_VIEW: "mod.users.view",
MOD_BANS_VIEW: "mod.bans.view",
} as const;
+91 -70
View File
@@ -1,23 +1,23 @@
import { unstable_cache } from 'next/cache'
import { redirect } from 'next/navigation'
import { cache } from 'react'
import { auth } from './auth'
import { sessionUserId } from './auth/session-user'
import { prisma } from './prisma'
import { logAuthorizationEvent } from './admin/authorization-events'
import { isDynamicSuperAdmin } from './admin/authorization-policy'
import { resolveAuthorizationState } from './admin/rank-authority'
import { unstable_cache } from "next/cache";
import { redirect } from "next/navigation";
import { cache } from "react";
import { auth } from "./auth";
import { sessionUserId } from "./auth/session-user";
import { prisma } from "./prisma";
import { logAuthorizationEvent } from "./admin/authorization-events";
import { isDynamicSuperAdmin } from "./admin/authorization-policy";
import { resolveAuthorizationState } from "./admin/rank-authority";
// Re-export PERMS from the standalone file (safe for client components)
export { PERMS } from './permission-slugs'
export { PERMS } from "./permission-slugs";
import { PERMS } from './permission-slugs'
import { PERMS } from "./permission-slugs";
// ── Permission Set ──────────────────────────────────────────────────
export type { PermissionSet } from '@/types/admin'
export type { PermissionSet } from "@/types/admin";
import type { PermissionSet } from '@/types/admin'
import type { PermissionSet } from "@/types/admin";
function createEmptySet(): PermissionSet {
return {
@@ -25,7 +25,7 @@ function createEmptySet(): PermissionSet {
hasAny: () => false,
hasAll: () => false,
isSuperAdmin: false,
}
};
}
/**
@@ -48,12 +48,12 @@ const getCachedPermissionSlugs = unstable_cache(
FROM acl_roles ar
WHERE ar.slug = ${`rank_${rank}`}
)
`
return rows.map((r) => r.slug)
`;
return rows.map((r) => r.slug);
},
['user-permissions'],
{ revalidate: 60, tags: ['permissions'] },
)
["user-permissions"],
{ revalidate: 60, tags: ["permissions"] },
);
/**
* Load permission slugs for a database-refreshed user rank. The dynamically
@@ -73,25 +73,32 @@ export const loadUserPermissions = cache(async function loadUserPermissions(
hasAny: () => true,
hasAll: () => true,
isSuperAdmin: true,
}
};
}
const slugArray = await getCachedPermissionSlugs(userId, rank)
if (slugArray.length === 0) return createEmptySet()
const slugArray = await getCachedPermissionSlugs(userId, rank);
if (slugArray.length === 0) return createEmptySet();
const slugs = new Set(slugArray)
const slugs = new Set(slugArray);
return {
has: (perm: string) => slugs.has(perm),
hasAny: (...perms: string[]) => perms.some((p) => slugs.has(p)),
hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)),
isSuperAdmin: false,
}
};
} catch (error) {
await logAuthorizationEvent({ kind: 'permission.load_error', userId, rank, source: 'loadUserPermissions', reason: 'ACL query failed', error })
await logAuthorizationEvent({
kind: "permission.load_error",
userId,
rank,
source: "loadUserPermissions",
reason: "ACL query failed",
error,
});
// Fail-closed: return empty set on any error
return createEmptySet()
return createEmptySet();
}
})
});
const getCurrentAuthorizationState = cache(async (userId: number) =>
resolveAuthorizationState(userId, {
@@ -99,11 +106,11 @@ const getCurrentAuthorizationState = cache(async (userId: number) =>
highestRank: async () => {
const rows = await prisma.$queryRaw<{ highest_rank: number | bigint | null }[]>`
SELECT MAX(id) AS highest_rank FROM permission_ranks
`
return rows[0]?.highest_rank == null ? null : Number(rows[0].highest_rank)
`;
return rows[0]?.highest_rank == null ? null : Number(rows[0].highest_rank);
},
}),
)
);
// ── Context Helpers ─────────────────────────────────────────────────
@@ -112,17 +119,23 @@ const getCurrentAuthorizationState = cache(async (userId: number) =>
* Redirects to login if not authenticated.
*/
export async function getAdminContext() {
const session = await auth()
const session = await auth();
if (!session?.user) {
redirect('/login')
redirect("/login");
}
const userId = sessionUserId(session.user.id)
if (!userId) redirect('/login')
const state = await getCurrentAuthorizationState(userId)
if (!state) redirect('/login')
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
return { session: { ...session, user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank } }, permissions }
const userId = sessionUserId(session.user.id);
if (!userId) redirect("/login");
const state = await getCurrentAuthorizationState(userId);
if (!state) redirect("/login");
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
return {
session: {
...session,
user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank },
},
permissions,
};
}
/**
@@ -130,15 +143,21 @@ export async function getAdminContext() {
* Returns null if not authenticated (caller handles 401).
*/
export async function getApiAdminContext() {
const session = await auth()
if (!session?.user) return null
const session = await auth();
if (!session?.user) return null;
const userId = sessionUserId(session.user.id)
if (!userId) return null
const state = await getCurrentAuthorizationState(userId)
if (!state) return null
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
return { session: { ...session, user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank } }, permissions }
const userId = sessionUserId(session.user.id);
if (!userId) return null;
const state = await getCurrentAuthorizationState(userId);
if (!state) return null;
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
return {
session: {
...session,
user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank },
},
permissions,
};
}
/**
@@ -146,7 +165,7 @@ export async function getApiAdminContext() {
* All fallbacks are represented as ACL role permissions by migration 0011.
*/
export function canAccess(permissions: PermissionSet, slug: string, _rank?: number): boolean {
return permissions.has(slug)
return permissions.has(slug);
}
/**
@@ -154,32 +173,34 @@ export function canAccess(permissions: PermissionSet, slug: string, _rank?: numb
* Redirects to login if unauthenticated and to / without moderator ACL access.
*/
export async function getModContext() {
const session = await auth()
const session = await auth();
if (!session?.user) {
redirect('/login')
redirect("/login");
}
const userId = sessionUserId(session.user.id)
if (!userId) redirect('/login')
const state = await getCurrentAuthorizationState(userId)
if (!state) redirect('/')
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirect('/')
return { session: { ...session, user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank } }, permissions }
const userId = sessionUserId(session.user.id);
if (!userId) redirect("/login");
const state = await getCurrentAuthorizationState(userId);
if (!state) redirect("/");
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirect("/");
return {
session: {
...session,
user: { ...session.user, id: userId, username: state.actor.username, rank: state.actor.rank },
},
permissions,
};
}
// ── Legacy single-check functions (kept for backward compatibility) ──
/** Check if a user has a CMS permission using their current database rank. */
export async function checkPermission(
userId: number,
_rank: number,
permission: string,
): Promise<boolean> {
const state = await getCurrentAuthorizationState(userId)
if (!state) return false
const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
return perms.has(permission)
export async function checkPermission(userId: number, _rank: number, permission: string): Promise<boolean> {
const state = await getCurrentAuthorizationState(userId);
if (!state) return false;
const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
return perms.has(permission);
}
/** Check multiple permissions (user needs ALL of them) */
@@ -188,13 +209,13 @@ export async function checkAllPermissions(
_rank: number,
permissions: string[],
): Promise<boolean> {
const state = await getCurrentAuthorizationState(userId)
if (!state) return false
const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank)
return perms.hasAll(...permissions)
const state = await getCurrentAuthorizationState(userId);
if (!state) return false;
const perms = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
return perms.hasAll(...permissions);
}
/** Check if user has admin access */
export async function hasAdminAccess(userId: number, rank: number): Promise<boolean> {
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD)
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD);
}
+15 -16
View File
@@ -1,27 +1,26 @@
import { ZodError } from 'zod'
import { ZodError } from "zod";
// ── Action Result Type ───────────────────────────────────────────────
export type ActionResult<T = Record<string, unknown>> =
| { ok: true; data?: T }
| { ok: false; error: string; fieldErrors?: Record<string, string[]> }
{ ok: true; data?: T } | { ok: false; error: string; fieldErrors?: Record<string, string[]> };
// ── Helper to build results ──────────────────────────────────────────
export function actionOk<T = Record<string, unknown>>(data?: T): ActionResult<T> {
return { ok: true, data: data ?? ({} as T) }
return { ok: true, data: data ?? ({} as T) };
}
export function actionError(message: string): ActionResult<never> {
return { ok: false, error: message }
return { ok: false, error: message };
}
// ── Throwable error ──────────────────────────────────────────────────
export class ActionError extends Error {
constructor(message: string) {
super(message)
this.name = 'ActionError'
super(message);
this.name = "ActionError";
}
}
@@ -31,21 +30,21 @@ export function handleActionError(error: unknown): ActionResult<never> {
if (error instanceof ZodError) {
return {
ok: false,
error: 'Validation failed',
error: "Validation failed",
fieldErrors: error.flatten().fieldErrors as Record<string, string[]>,
}
};
}
if (error instanceof Error && error.name === 'ActionError') {
return { ok: false, error: error.message }
if (error instanceof Error && error.name === "ActionError") {
return { ok: false, error: error.message };
}
// Prisma P2025
if (
error instanceof Error &&
error.constructor.name === 'PrismaClientKnownRequestError' &&
(error as Error & { code?: string }).code === 'P2025'
error.constructor.name === "PrismaClientKnownRequestError" &&
(error as Error & { code?: string }).code === "P2025"
) {
return { ok: false, error: 'Not found' }
return { ok: false, error: "Not found" };
}
console.error('[Action error]', error)
return { ok: false, error: 'Internal server error' }
console.error("[Action error]", error);
return { ok: false, error: "Internal server error" };
}
+44 -40
View File
@@ -1,21 +1,21 @@
import type { z } from 'zod'
import { auth } from '@/lib/auth'
import { canAccess, getApiAdminContext } from '@/lib/permissions'
import { type ActionResult, actionError, handleActionError } from '@/lib/safe-action-shared'
import { logAuthorizationEvent } from '@/lib/admin/authorization-events'
import type { z } from "zod";
import { auth } from "@/lib/auth";
import { canAccess, getApiAdminContext } from "@/lib/permissions";
import { type ActionResult, actionError, handleActionError } from "@/lib/safe-action-shared";
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
export type { ActionResult }
export type { ActionResult };
// ── Admin action wrapper ─────────────────────────────────────────────
interface AdminActionOptions<TSchema extends z.ZodType | undefined = undefined> {
permission?: string
schema?: TSchema
permission?: string;
schema?: TSchema;
}
type AdminActionContext<TSchema extends z.ZodType | undefined> = {
session: { user: { id: number; username: string; rank: number; look: string; mail: string } }
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object)
session: { user: { id: number; username: string; rank: number; look: string; mail: string } };
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object);
/**
* Create a server action with admin auth + optional permission + optional Zod validation.
@@ -38,54 +38,58 @@ export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
): Promise<ActionResult> => {
try {
const apiCtx = await getApiAdminContext()
if (!apiCtx) return actionError('Unauthorized')
const apiCtx = await getApiAdminContext();
if (!apiCtx) return actionError("Unauthorized");
if (options.permission) {
if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) {
await logAuthorizationEvent({
kind: 'permission.denied', userId: apiCtx.session.user.id,
username: apiCtx.session.user.name ?? undefined, rank: apiCtx.session.user.rank,
permission: options.permission, source: 'adminAction', reason: 'Permission check denied',
})
return actionError('Unauthorized')
kind: "permission.denied",
userId: apiCtx.session.user.id,
username: apiCtx.session.user.name ?? undefined,
rank: apiCtx.session.user.rank,
permission: options.permission,
source: "adminAction",
reason: "Permission check denied",
});
return actionError("Unauthorized");
}
}
let data: unknown
let data: unknown;
if (options.schema) {
const parsed = options.schema.safeParse(input)
const parsed = options.schema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: 'Validation failed',
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
}
};
}
data = parsed.data
data = parsed.data;
}
const ctx = {
session: apiCtx.session,
...(options.schema ? { data } : {}),
} as AdminActionContext<TSchema>
} as AdminActionContext<TSchema>;
return await handler(ctx)
return await handler(ctx);
} catch (error) {
return handleActionError(error)
return handleActionError(error);
}
}
};
}
// ── Auth action wrapper (no permissions) ─────────────────────────────
interface AuthActionOptions<TSchema extends z.ZodType | undefined = undefined> {
schema?: TSchema
schema?: TSchema;
}
type AuthActionContext<TSchema extends z.ZodType | undefined> = {
session: { user: { id: number; username: string; rank: number; look: string; mail: string } }
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object)
session: { user: { id: number; username: string; rank: number; look: string; mail: string } };
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object);
/**
* Create a server action with auth only (no permission check).
@@ -99,30 +103,30 @@ export function authAction<TSchema extends z.ZodType | undefined = undefined>(
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
): Promise<ActionResult> => {
try {
const session = await auth()
if (!session?.user) return actionError('Unauthorized')
const session = await auth();
if (!session?.user) return actionError("Unauthorized");
let data: unknown
let data: unknown;
if (options.schema) {
const parsed = options.schema.safeParse(input)
const parsed = options.schema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: 'Validation failed',
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
}
};
}
data = parsed.data
data = parsed.data;
}
const ctx = {
session,
...(options.schema ? { data } : {}),
} as AuthActionContext<TSchema>
} as AuthActionContext<TSchema>;
return await handler(ctx)
return await handler(ctx);
} catch (error) {
return handleActionError(error)
return handleActionError(error);
}
}
};
}
+8 -6
View File
@@ -3,12 +3,14 @@ import { serverErrorRecord } from "@/lib/server-log";
describe("serverErrorRecord", () => {
it("keeps operational context while redacting sensitive fields", () => {
expect(serverErrorRecord("paypal.capture_failed", new Error("gateway timeout"), {
orderId: "ORDER-123",
userId: 42,
authorization: "Bearer secret",
apiToken: "secret-token",
})).toEqual({
expect(
serverErrorRecord("paypal.capture_failed", new Error("gateway timeout"), {
orderId: "ORDER-123",
userId: 42,
authorization: "Bearer secret",
apiToken: "secret-token",
}),
).toEqual({
level: "error",
event: "paypal.capture_failed",
message: "gateway timeout",
+6 -4
View File
@@ -19,10 +19,12 @@ export function serverErrorRecord(
level: "error",
event,
message: error instanceof Error ? error.message.slice(0, 500) : "Unknown server error",
context: Object.fromEntries(Object.entries(context).map(([key, value]) => [
key,
SENSITIVE_KEY.test(key) ? "[REDACTED]" : logScalar(value),
])),
context: Object.fromEntries(
Object.entries(context).map(([key, value]) => [
key,
SENSITIVE_KEY.test(key) ? "[REDACTED]" : logScalar(value),
]),
),
};
}
+37 -37
View File
@@ -1,56 +1,56 @@
import { prisma } from '../prisma'
import { prisma } from "../prisma";
interface AuditEntry {
userId: number
action: string
target: string
targetId?: number
before?: Record<string, unknown>
after?: Record<string, unknown>
userId: number;
action: string;
target: string;
targetId?: number;
before?: Record<string, unknown>;
after?: Record<string, unknown>;
}
const SENSITIVE_KEY_RE =
/password|secret|token|otp|recovery|authTicket|two_factor|two_factor_secret|api_key/i
const REDACTED = '[Redacted]'
/password|secret|token|otp|recovery|authTicket|two_factor|two_factor_secret|api_key/i;
const REDACTED = "[Redacted]";
function sanitizeAuditPayload(value: unknown, depth = 0): unknown {
if (depth > 6 || value == null) return value
if (Array.isArray(value)) return value.map((v) => sanitizeAuditPayload(v, depth + 1))
if (typeof value !== 'object') return value
if (depth > 6 || value == null) return value;
if (Array.isArray(value)) return value.map((v) => sanitizeAuditPayload(v, depth + 1));
if (typeof value !== "object") return value;
const out: Record<string, unknown> = {}
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
out[key] = SENSITIVE_KEY_RE.test(key) ? REDACTED : sanitizeAuditPayload(val, depth + 1)
out[key] = SENSITIVE_KEY_RE.test(key) ? REDACTED : sanitizeAuditPayload(val, depth + 1);
}
return out
return out;
}
function computeDiff(
before?: Record<string, unknown>,
after?: Record<string, unknown>,
): Record<string, { from: unknown; to: unknown }> | null {
if (!before || !after) return null
if (!before || !after) return null;
const diff: Record<string, { from: unknown; to: unknown }> = {}
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
const diff: Record<string, { from: unknown; to: unknown }> = {};
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of allKeys) {
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
diff[key] = { from: before[key], to: after[key] }
diff[key] = { from: before[key], to: after[key] };
}
}
return Object.keys(diff).length > 0 ? diff : null
return Object.keys(diff).length > 0 ? diff : null;
}
export async function logAudit(entry: AuditEntry): Promise<void> {
const sanitizedBefore = entry.before
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
: undefined
: undefined;
const sanitizedAfter = entry.after
? (sanitizeAuditPayload(entry.after) as Record<string, unknown>)
: undefined
const diff = computeDiff(sanitizedBefore, sanitizedAfter)
: undefined;
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
await prisma.adminAuditLog.create({
data: {
@@ -63,46 +63,46 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
diff: diff ? JSON.stringify(diff) : null,
createdAt: new Date().toISOString(),
},
})
});
}
interface GetLogsOptions {
search?: string
page?: number
perPage?: number
search?: string;
page?: number;
perPage?: number;
}
export async function getAuditLogs(options: GetLogsOptions = {}) {
const { search, page = 1, perPage = 20 } = options
const skip = (page - 1) * perPage
const { search, page = 1, perPage = 20 } = options;
const skip = (page - 1) * perPage;
const where = search
? {
OR: [{ action: { contains: search } }, { target: { contains: search } }],
}
: {}
: {};
const [rows, total] = await Promise.all([
prisma.adminAuditLog.findMany({
where,
orderBy: { id: 'desc' },
orderBy: { id: "desc" },
skip,
take: perPage,
}),
prisma.adminAuditLog.count({ where }),
])
]);
const userIds = [...new Set(rows.map((r) => r.userId))]
const userIds = [...new Set(rows.map((r) => r.userId))];
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
})
const userMap = new Map(users.map((u) => [u.id, u.username]))
});
const userMap = new Map(users.map((u) => [u.id, u.username]));
const enrichedRows = rows.map((r) => ({
...r,
username: userMap.get(r.userId) ?? `User #${r.userId}`,
}))
}));
return {
rows: enrichedRows,
@@ -110,5 +110,5 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
page,
perPage,
lastPage: Math.ceil(total / perPage),
}
};
}
+134 -134
View File
@@ -1,167 +1,167 @@
import { promises as fs } from 'node:fs'
import { Prisma } from '@/generated/prisma/client'
import { prisma } from '@/lib/prisma'
import { getFurnitureDataPath } from '@/lib/services/furni-data'
import { promises as fs } from "node:fs";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { getFurnitureDataPath } from "@/lib/services/furni-data";
// Cache FurnitureData.json in memory with TTL to avoid repeated disk I/O
let furniDataCache: {
roomitemtypes?: {
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>
}
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>;
};
wallitemtypes?: {
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>
}
} | null = null
let furniDataCacheTime = 0
const FURNI_CACHE_TTL = 30_000 // 30 seconds
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>;
};
} | null = null;
let furniDataCacheTime = 0;
const FURNI_CACHE_TTL = 30_000; // 30 seconds
async function getFurnitureData() {
if (furniDataCache && Date.now() - furniDataCacheTime < FURNI_CACHE_TTL) return furniDataCache
const furniDataPath = await getFurnitureDataPath()
if (furniDataCache && Date.now() - furniDataCacheTime < FURNI_CACHE_TTL) return furniDataCache;
const furniDataPath = await getFurnitureDataPath();
try {
const raw = await fs.readFile(furniDataPath, 'utf-8')
furniDataCache = JSON.parse(raw)
furniDataCacheTime = Date.now()
return furniDataCache
const raw = await fs.readFile(furniDataPath, "utf-8");
furniDataCache = JSON.parse(raw);
furniDataCacheTime = Date.now();
return furniDataCache;
} catch {
return null
return null;
}
}
/** Invalidate the FurnitureData cache after writes */
export function invalidateFurniDataCache() {
furniDataCache = null
furniDataCacheTime = 0
furniDataCache = null;
furniDataCacheTime = 0;
}
interface RawItem {
id: number
itemIds: string
pageId: number
offerId: number
songId: number
orderNumber: number
catalogName: string
costCredits: number
costPoints: number
pointsType: number
amount: number
limitedSells: number
limitedStack: number
extradata: string
haveOffer: string
clubOnly: string
id: number;
itemIds: string;
pageId: number;
offerId: number;
songId: number;
orderNumber: number;
catalogName: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedSells: number;
limitedStack: number;
extradata: string;
haveOffer: string;
clubOnly: string;
}
interface BaseItem {
id: number
spriteId: number
publicName: string
itemName: string
type: string
width: number
length: number
stackHeight: number
allowStack: number
allowSit: number
allowLay: number
allowWalk: number
allowGift: number
allowTrade: number
allowRecycle: number
allowMarketplaceSell: number
allowInventoryStack: number
interactionType: string
interactionModesCount: number
vendingIds: string
customparams: string
effectIdMale: number
effectIdFemale: number
clothingOnWalk: string
id: number;
spriteId: number;
publicName: string;
itemName: string;
type: string;
width: number;
length: number;
stackHeight: number;
allowStack: number;
allowSit: number;
allowLay: number;
allowWalk: number;
allowGift: number;
allowTrade: number;
allowRecycle: number;
allowMarketplaceSell: number;
allowInventoryStack: number;
interactionType: string;
interactionModesCount: number;
vendingIds: string;
customparams: string;
effectIdMale: number;
effectIdFemale: number;
clothingOnWalk: string;
}
export interface CatalogItemEnriched {
id: number
catalogName: string
itemIds: string
costCredits: number
costPoints: number
pointsType: number
amount: number
limitedSells: number
limitedStack: number
orderNumber: number
offerId: number
songId: number
haveOffer: string
clubOnly: string
extradata: string
baseName: string
baseItemName: string
spriteId: number
baseItem: BaseItem | null
id: number;
catalogName: string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedSells: number;
limitedStack: number;
orderNumber: number;
offerId: number;
songId: number;
haveOffer: string;
clubOnly: string;
extradata: string;
baseName: string;
baseItemName: string;
spriteId: number;
baseItem: BaseItem | null;
}
export interface CatalogItemsData {
items: CatalogItemEnriched[]
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[]
catalogNameMap: Record<number, string>
furniDataIdList: number[]
furniDescriptionMap: Record<number, string>
furniRevisionMap: Record<number, { classname: string; revision: number }>
interactionTypes: string[]
allPages: { id: number; caption: string }[]
items: CatalogItemEnriched[];
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[];
catalogNameMap: Record<number, string>;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
interactionTypes: string[];
allPages: { id: number; caption: string }[];
}
export async function loadCatalogItemsData(pageId: number): Promise<CatalogItemsData> {
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
const pageIdStr = String(pageId)
const pageIdStr = String(pageId);
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
SELECT * FROM catalog_items WHERE page_id = ${pageIdStr} ORDER BY id ASC
`
`;
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
id: Number(r.id),
itemIds: String(r.item_ids ?? ''),
itemIds: String(r.item_ids ?? ""),
pageId: Number(r.page_id) || 0,
offerId: Number(r.offer_id) || -1,
songId: Number(r.song_id) || 0,
orderNumber: Number(r.order_number) || 99,
catalogName: String(r.catalog_name ?? ''),
catalogName: String(r.catalog_name ?? ""),
costCredits: Number(r.cost_credits) || 0,
costPoints: Number(r.cost_points) || 0,
pointsType: Number(r.points_type) || 0,
amount: Number(r.amount) || 1,
limitedSells: Number(r.limited_sells) || 0,
limitedStack: Number(r.limited_stack) || 0,
extradata: String(r.extradata ?? ''),
haveOffer: String(r.have_offer ?? '1'),
clubOnly: String(r.club_only ?? '0'),
}))
extradata: String(r.extradata ?? ""),
haveOffer: String(r.have_offer ?? "1"),
clubOnly: String(r.club_only ?? "0"),
}));
const [allPages, interactionTypesRaw] = await Promise.all([
prisma.catalogPages.findMany({
orderBy: { caption: 'asc' },
orderBy: { caption: "asc" },
select: { id: true, caption: true },
}),
prisma.$queryRaw<Array<{ interaction_type: string }>>`
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
`,
])
]);
const interactionTypes = interactionTypesRaw.map((r) => String(r.interaction_type))
const interactionTypes = interactionTypesRaw.map((r) => String(r.interaction_type));
// Resolve item names from items_base for enrichment + translate
const itemIdStrings = items.map((i) => i.itemIds).filter(Boolean)
const itemIdStrings = items.map((i) => i.itemIds).filter(Boolean);
const baseItemIds = [
...new Set(
itemIdStrings.flatMap((s) =>
s
.split(';')
.split(";")
.map(Number)
.filter((n) => n > 0),
),
),
]
];
// Raw query to work around ENUM('0','1') columns returning numeric values.
// baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join.
@@ -180,9 +180,9 @@ export async function loadCatalogItemsData(pageId: number): Promise<CatalogItems
).map((r) => ({
id: Number(r.id),
spriteId: Number(r.sprite_id),
publicName: String(r.public_name ?? ''),
itemName: String(r.item_name ?? ''),
type: String(r.type ?? 's'),
publicName: String(r.public_name ?? ""),
itemName: String(r.item_name ?? ""),
type: String(r.type ?? "s"),
width: Number(r.width) || 1,
length: Number(r.length) || 1,
stackHeight: Number(r.stack_height) || 0,
@@ -195,36 +195,36 @@ export async function loadCatalogItemsData(pageId: number): Promise<CatalogItems
allowRecycle: Number(r.allow_recycle ?? 0),
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
interactionType: String(r.interaction_type ?? 'default'),
interactionType: String(r.interaction_type ?? "default"),
interactionModesCount: Number(r.interaction_modes_count) || 2,
vendingIds: String(r.vending_ids ?? '0'),
customparams: String(r.customparams ?? ''),
vendingIds: String(r.vending_ids ?? "0"),
customparams: String(r.customparams ?? ""),
effectIdMale: Number(r.effect_id_male) || 0,
effectIdFemale: Number(r.effect_id_female) || 0,
clothingOnWalk: String(r.clothing_on_walk ?? ''),
clothingOnWalk: String(r.clothing_on_walk ?? ""),
}))
: []
: [];
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]))
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
// Build mapping: baseItemId -> catalogName
const catalogNameMap: Record<number, string> = {}
const catalogNameMap: Record<number, string> = {};
for (const item of items) {
const firstBaseId = item.itemIds
.split(';')
.split(";")
.map(Number)
.find((n) => n > 0)
.find((n) => n > 0);
if (firstBaseId && !catalogNameMap[firstBaseId]) {
catalogNameMap[firstBaseId] = item.catalogName
catalogNameMap[firstBaseId] = item.catalogName;
}
}
const catalogItems: CatalogItemEnriched[] = items.map((item) => {
const firstBaseId = item.itemIds
.split(';')
.split(";")
.map(Number)
.find((n) => n > 0)
const base = firstBaseId ? baseItemMap[firstBaseId] : undefined
.find((n) => n > 0);
const base = firstBaseId ? baseItemMap[firstBaseId] : undefined;
return {
id: item.id,
catalogName: item.catalogName,
@@ -241,36 +241,36 @@ export async function loadCatalogItemsData(pageId: number): Promise<CatalogItems
haveOffer: item.haveOffer,
clubOnly: item.clubOnly,
extradata: item.extradata,
baseName: base?.publicName || base?.itemName || '',
baseItemName: base?.itemName || '',
baseName: base?.publicName || base?.itemName || "",
baseItemName: base?.itemName || "",
spriteId: base?.spriteId ?? 0,
baseItem: base ? { ...base } : null,
}
})
};
});
// Build spriteId -> items_base.id mapping for FurnitureData.json lookup
const spriteToBaseId = new Map<number, number>()
const spriteToBaseId = new Map<number, number>();
for (const b of baseItems) {
spriteToBaseId.set(b.spriteId, b.id)
spriteToBaseId.set(b.spriteId, b.id);
}
// Read FurnitureData.json (cached in memory)
const foundBaseIds = new Set<number>()
const furniDescriptionMap: Record<number, string> = {}
const furniRevisionMap: Record<number, { classname: string; revision: number }> = {}
const furniData = await getFurnitureData()
const foundBaseIds = new Set<number>();
const furniDescriptionMap: Record<number, string> = {};
const furniRevisionMap: Record<number, { classname: string; revision: number }> = {};
const furniData = await getFurnitureData();
if (furniData) {
for (const section of ['roomitemtypes', 'wallitemtypes'] as const) {
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
if (furniData[section]?.furnitype) {
for (const item of furniData[section].furnitype) {
const baseId = spriteToBaseId.get(item.id)
const baseId = spriteToBaseId.get(item.id);
if (baseId !== undefined) {
foundBaseIds.add(baseId)
furniDescriptionMap[baseId] = item.description ?? ''
foundBaseIds.add(baseId);
furniDescriptionMap[baseId] = item.description ?? "";
furniRevisionMap[baseId] = {
classname: item.classname ?? '',
classname: item.classname ?? "",
revision: item.revision ?? 0,
}
};
}
}
}
@@ -292,5 +292,5 @@ export async function loadCatalogItemsData(pageId: number): Promise<CatalogItems
furniRevisionMap,
interactionTypes,
allPages,
}
};
}
+91 -93
View File
@@ -1,8 +1,8 @@
import { prisma } from '@/lib/prisma'
import { prisma } from "@/lib/prisma";
export type { NestedTreeNode, TreeNode } from '@/types/catalog'
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
import type { TreeNode } from '@/types/catalog'
import type { TreeNode } from "@/types/catalog";
/**
* Get the full catalog tree recursively using iterative Prisma queries.
@@ -12,36 +12,36 @@ import type { TreeNode } from '@/types/catalog'
export async function getTreeFlat(): Promise<TreeNode[]> {
const [allPages, itemCounts] = await Promise.all([
prisma.catalogPages.findMany({
orderBy: { orderNum: 'asc' },
orderBy: { orderNum: "asc" },
}),
prisma.catalogItems.groupBy({
by: ['pageId'],
by: ["pageId"],
_count: true,
}),
])
]);
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]))
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]));
// Count children per page
const childCountMap = new Map<number, number>()
const childCountMap = new Map<number, number>();
for (const p of allPages) {
childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1)
childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1);
}
// Compute depth from parent hierarchy
const pageMap = new Map(allPages.map((p) => [p.id, p]))
const depthCache = new Map<number, number>()
const pageMap = new Map(allPages.map((p) => [p.id, p]));
const depthCache = new Map<number, number>();
function computeDepth(pageId: number): number {
if (depthCache.has(pageId)) return depthCache.get(pageId)!
const page = pageMap.get(pageId)
if (depthCache.has(pageId)) return depthCache.get(pageId)!;
const page = pageMap.get(pageId);
if (!page || page.parentId <= 0) {
depthCache.set(pageId, 0)
return 0
depthCache.set(pageId, 0);
return 0;
}
const d = computeDepth(page.parentId) + 1
depthCache.set(pageId, d)
return d
const d = computeDepth(page.parentId) + 1;
depthCache.set(pageId, d);
return d;
}
return allPages.map((p) => ({
@@ -57,65 +57,65 @@ export async function getTreeFlat(): Promise<TreeNode[]> {
pageLayout: p.pageLayout,
childCount: childCountMap.get(p.id) ?? 0,
itemCount: itemCountMap.get(p.id) ?? 0,
}))
}));
}
/**
* Build a nested tree structure from flat nodes.
*/
import type { NestedTreeNode } from '@/types/catalog'
import type { NestedTreeNode } from "@/types/catalog";
export function buildNestedTree(flat: TreeNode[], rootParentId = -1): NestedTreeNode[] {
const childrenMap = new Map<number, NestedTreeNode[]>()
const nodeMap = new Map<number, NestedTreeNode>()
const childrenMap = new Map<number, NestedTreeNode[]>();
const nodeMap = new Map<number, NestedTreeNode>();
for (const node of flat) {
const nested: NestedTreeNode = { ...node, children: [] }
nodeMap.set(node.id, nested)
if (!childrenMap.has(node.parentId)) childrenMap.set(node.parentId, [])
childrenMap.get(node.parentId)!.push(nested)
const nested: NestedTreeNode = { ...node, children: [] };
nodeMap.set(node.id, nested);
if (!childrenMap.has(node.parentId)) childrenMap.set(node.parentId, []);
childrenMap.get(node.parentId)!.push(nested);
}
// Attach children
for (const node of nodeMap.values()) {
node.children = childrenMap.get(node.id) ?? []
node.children = childrenMap.get(node.id) ?? [];
}
return childrenMap.get(rootParentId) ?? []
return childrenMap.get(rootParentId) ?? [];
}
/**
* Create a new catalog page.
*/
export async function createPage(data: {
parentId: number
caption: string
captionSave?: string
pageLayout?: string
iconImage?: number
iconColor?: number
minRank?: number
orderNum?: number
visible?: boolean
enabled?: boolean
parentId: number;
caption: string;
captionSave?: string;
pageLayout?: string;
iconImage?: number;
iconColor?: number;
minRank?: number;
orderNum?: number;
visible?: boolean;
enabled?: boolean;
}): Promise<{ id: number }> {
// Auto-generate captionSave if not provided
const captionSave =
data.captionSave ||
data.caption
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.substring(0, 25)
.replace(/[^a-z0-9_]/g, "_")
.substring(0, 25);
// Get next orderNum if not provided
let orderNum = data.orderNum
let orderNum = data.orderNum;
if (orderNum === undefined) {
const lastSibling = await prisma.catalogPages.findFirst({
where: { parentId: data.parentId },
orderBy: { orderNum: 'desc' },
orderBy: { orderNum: "desc" },
select: { orderNum: true },
})
orderNum = (lastSibling?.orderNum ?? 0) + 1
});
orderNum = (lastSibling?.orderNum ?? 0) + 1;
}
const created = await prisma.catalogPages.create({
@@ -123,26 +123,26 @@ export async function createPage(data: {
parentId: data.parentId,
caption: data.caption,
captionSave,
pageLayout: data.pageLayout ?? 'default_3x3',
pageLayout: data.pageLayout ?? "default_3x3",
iconImage: data.iconImage ?? 1,
iconColor: data.iconColor ?? 1,
minRank: data.minRank ?? 1,
orderNum,
visible: (data.visible ?? true) ? '1' : '0',
enabled: (data.enabled ?? true) ? '1' : '0',
pageHeadline: '',
pageTeaser: '',
pageSpecial: '',
pageText1: '',
pageText2: '',
pageTextDetails: '',
pageTextTeaser: '',
includes: '',
visible: (data.visible ?? true) ? "1" : "0",
enabled: (data.enabled ?? true) ? "1" : "0",
pageHeadline: "",
pageTeaser: "",
pageSpecial: "",
pageText1: "",
pageText2: "",
pageTextDetails: "",
pageTextTeaser: "",
includes: "",
},
select: { id: true },
})
});
return created
return created;
}
/**
@@ -152,24 +152,24 @@ export async function createPage(data: {
export async function movePage(pageId: number, newParentId: number): Promise<void> {
// Walk up from newParentId to root — if we hit pageId, it's circular
if (newParentId > 0) {
let currentId = newParentId
let currentId = newParentId;
for (let i = 0; i < 50; i++) {
if (currentId === pageId) {
throw new Error('Cannot move page: would create a circular hierarchy')
throw new Error("Cannot move page: would create a circular hierarchy");
}
const parent = await prisma.catalogPages.findUnique({
where: { id: currentId },
select: { parentId: true },
})
if (!parent || parent.parentId <= 0) break
currentId = parent.parentId
});
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await prisma.catalogPages.update({
where: { id: pageId },
data: { parentId: newParentId },
})
});
}
/**
@@ -179,81 +179,79 @@ export async function movePage(pageId: number, newParentId: number): Promise<voi
*/
export async function deletePage(
pageId: number,
mode: 'cascade' | 'reparent' = 'reparent',
mode: "cascade" | "reparent" = "reparent",
): Promise<{ deletedPages: number; movedChildren: number }> {
const page = await prisma.catalogPages.findUnique({
where: { id: pageId },
select: { parentId: true },
})
if (!page) return { deletedPages: 0, movedChildren: 0 }
});
if (!page) return { deletedPages: 0, movedChildren: 0 };
if (mode === 'reparent') {
if (mode === "reparent") {
// Move children to page's parent
const result = await prisma.catalogPages.updateMany({
where: { parentId: pageId },
data: { parentId: page.parentId },
})
});
// Delete items in this page
await prisma.catalogItems.deleteMany({ where: { pageId } })
await prisma.catalogItems.deleteMany({ where: { pageId } });
// Delete the page
await prisma.catalogPages.delete({ where: { id: pageId } })
await prisma.catalogPages.delete({ where: { id: pageId } });
return { deletedPages: 1, movedChildren: result.count }
return { deletedPages: 1, movedChildren: result.count };
}
// Cascade: delete all descendants
const deleted = await cascadeDelete(pageId)
return { deletedPages: deleted, movedChildren: 0 }
const deleted = await cascadeDelete(pageId);
return { deletedPages: deleted, movedChildren: 0 };
}
async function cascadeDelete(pageId: number): Promise<number> {
// Collect all descendant IDs iteratively to avoid N+1 recursive queries
const toDelete: number[] = [pageId]
const queue: number[] = [pageId]
const toDelete: number[] = [pageId];
const queue: number[] = [pageId];
while (queue.length > 0) {
const children = await prisma.catalogPages.findMany({
where: { parentId: { in: queue } },
select: { id: true },
})
queue.length = 0
});
queue.length = 0;
for (const child of children) {
toDelete.push(child.id)
queue.push(child.id)
toDelete.push(child.id);
queue.push(child.id);
}
}
// Delete all items and pages in bulk (children first, then parents)
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } })
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } });
// Delete in reverse order (deepest first) to avoid FK issues
for (let i = toDelete.length - 1; i >= 0; i--) {
await prisma.catalogPages.delete({ where: { id: toDelete[i] } })
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
}
return toDelete.length
return toDelete.length;
}
/**
* Get ancestors (breadcrumb) from root to the given page.
*/
export async function getAncestors(
pageId: number,
): Promise<Array<{ id: number; caption: string }>> {
const ancestors: Array<{ id: number; caption: string }> = []
let currentId = pageId
export async function getAncestors(pageId: number): Promise<Array<{ id: number; caption: string }>> {
const ancestors: Array<{ id: number; caption: string }> = [];
let currentId = pageId;
// Safety limit to prevent infinite loops
for (let i = 0; i < 20; i++) {
const page = await prisma.catalogPages.findUnique({
where: { id: currentId },
select: { id: true, caption: true, parentId: true },
})
if (!page) break
ancestors.unshift({ id: page.id, caption: page.caption })
if (page.parentId <= 0) break
currentId = page.parentId
});
if (!page) break;
ancestors.unshift({ id: page.id, caption: page.caption });
if (page.parentId <= 0) break;
currentId = page.parentId;
}
return ancestors
return ancestors;
}
+31 -31
View File
@@ -1,19 +1,19 @@
import { cropRgba, decodePng } from '@/lib/services/imager/png-decode'
import { encodePng, parseNitroBundle } from '@/lib/services/swf/nitro-builder'
import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
interface FurniAsset {
x?: number
y?: number
source?: string
x?: number;
y?: number;
source?: string;
}
interface FurniFrame {
frame: { x: number; y: number; w: number; h: number }
rotated?: boolean
frame: { x: number; y: number; w: number; h: number };
rotated?: boolean;
}
interface FurniNitroJson {
name?: string
assets?: Record<string, FurniAsset>
spritesheet?: { frames?: Record<string, FurniFrame> }
name?: string;
assets?: Record<string, FurniAsset>;
spritesheet?: { frames?: Record<string, FurniFrame> };
}
/**
@@ -23,41 +23,41 @@ interface FurniNitroJson {
* `.nitro` instead of serving a separate `{classname}_icon.png`.
*/
export function extractFurniIconPng(nitro: Buffer): Buffer | null {
let json: FurniNitroJson
let png: Buffer
let json: FurniNitroJson;
let png: Buffer;
try {
const parsed = parseNitroBundle(nitro)
json = parsed.json as FurniNitroJson
png = parsed.png
const parsed = parseNitroBundle(nitro);
json = parsed.json as FurniNitroJson;
png = parsed.png;
} catch {
return null
return null;
}
const name = json.name
if (!name || !json.assets) return null
const name = json.name;
if (!name || !json.assets) return null;
// The icon asset is conventionally `${name}_icon_a`; fall back to any
// `${name}_icon*` the bundle exposes.
const iconKey = json.assets[`${name}_icon_a`]
? `${name}_icon_a`
: Object.keys(json.assets).find((k) => k.startsWith(`${name}_icon`))
if (!iconKey) return null
: Object.keys(json.assets).find((k) => k.startsWith(`${name}_icon`));
if (!iconKey) return null;
const asset = json.assets[iconKey]
const asset = json.assets[iconKey];
// An asset may alias another's pixels via `source`; the frame key is the
// bundle name prefixed onto the asset name. Different converters pack the
// key with or without a trailing `.png`, so try both.
const pixelAsset = asset.source ?? iconKey
const frames = json.spritesheet?.frames ?? {}
const frame = frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`]
if (!frame || frame.rotated) return null
const { x, y, w, h } = frame.frame
if (w <= 0 || h <= 0) return null
const pixelAsset = asset.source ?? iconKey;
const frames = json.spritesheet?.frames ?? {};
const frame = frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`];
if (!frame || frame.rotated) return null;
const { x, y, w, h } = frame.frame;
if (w <= 0 || h <= 0) return null;
try {
const sheet = decodePng(png)
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h)
return encodePng(w, h, px)
const sheet = decodePng(png);
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h);
return encodePng(w, h, px);
} catch {
return null
return null;
}
}
+92 -99
View File
@@ -1,151 +1,144 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from "vitest";
type AnyFn = (...args: any[]) => any
type AnyFn = (...args: any[]) => any;
const {
downloadFile,
appendFurniEntry,
parseNitroBundle,
execRaw,
queryRaw,
fsUnlink,
fsReadFile,
} = vi.hoisted(() => ({
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
parseNitroBundle: vi.fn<AnyFn>(() => ({ json: {}, png: Buffer.alloc(0) })),
execRaw: vi.fn<AnyFn>(async () => 1),
queryRaw: vi.fn<AnyFn>(async () => [] as unknown[]),
fsUnlink: vi.fn<AnyFn>(async () => {}),
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from('NITRO')),
}))
const { downloadFile, appendFurniEntry, parseNitroBundle, execRaw, queryRaw, fsUnlink, fsReadFile } =
vi.hoisted(() => ({
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
parseNitroBundle: vi.fn<AnyFn>(() => ({ json: {}, png: Buffer.alloc(0) })),
execRaw: vi.fn<AnyFn>(async () => 1),
queryRaw: vi.fn<AnyFn>(async () => [] as unknown[]),
fsUnlink: vi.fn<AnyFn>(async () => {}),
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")),
}));
vi.mock('@/lib/services/import/core/download', () => ({ downloadFile }))
vi.mock('@/lib/services/furni-data', () => ({ appendFurniEntry }))
vi.mock('@/lib/services/furni-import', () => ({
vi.mock("@/lib/services/import/core/download", () => ({ downloadFile }));
vi.mock("@/lib/services/furni-data", () => ({ appendFurniEntry }));
vi.mock("@/lib/services/furni-import", () => ({
ensureDirectories: vi.fn<AnyFn>(async () => {}),
getOrCreateCategoryPage: vi.fn<AnyFn>(async () => 99),
autoPriceFurni: vi.fn<AnyFn>(() => ({ credits: 5, points: 0, pointsType: 0 })),
allocateCatalogItemId: vi.fn<AnyFn>(async (fn: (n: number) => Promise<unknown>) => fn(1000)),
}))
vi.mock('@/lib/services/furni-asset-dirs', () => ({
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
getFurniAssetDirs: vi.fn<AnyFn>(async () => ({
nitroDir: '/tmp/nitro',
iconDir: '/tmp/icons',
swfDir: '/tmp/swf',
nitroDir: "/tmp/nitro",
iconDir: "/tmp/icons",
swfDir: "/tmp/swf",
})),
}))
vi.mock('@/lib/services/swf/nitro-builder', () => ({ parseNitroBundle }))
vi.mock('node:fs', async (orig) => {
const real = (await orig()) as typeof import('node:fs')
}));
vi.mock("@/lib/services/swf/nitro-builder", () => ({ parseNitroBundle }));
vi.mock("node:fs", async (orig) => {
const real = (await orig()) as typeof import("node:fs");
return {
...real,
promises: { ...real.promises, readFile: fsReadFile as AnyFn, unlink: fsUnlink as AnyFn },
}
})
vi.mock('@/lib/prisma', () => ({
};
});
vi.mock("@/lib/prisma", () => ({
prisma: {
$executeRaw: execRaw as AnyFn,
$queryRaw: queryRaw as AnyFn,
$executeRawUnsafe: execRaw as AnyFn,
$queryRawUnsafe: queryRaw as AnyFn,
},
}))
}));
import { cloneSingleFurni, parseFurnidata } from './clone-import'
import { cloneSingleFurni, parseFurnidata } from "./clone-import";
const SOURCE = {
id: 's',
name: 'X',
furnidataUrl: 'https://x/fd.json',
nitroBaseUrl: 'https://x/furni',
iconBaseUrl: 'https://x/icons',
}
id: "s",
name: "X",
furnidataUrl: "https://x/fd.json",
nitroBaseUrl: "https://x/furni",
iconBaseUrl: "https://x/icons",
};
const ENTRY = {
id: 5,
classname: 'bc_sofa',
name: 'BC Sofa',
description: 'd',
classname: "bc_sofa",
name: "BC Sofa",
description: "d",
xdim: 2,
ydim: 1,
canstandon: false,
cansiton: true,
canlayon: false,
customparams: '',
}
customparams: "",
};
describe('clone-import', () => {
it('parseFurnidata normalizes room + wall items with itemType', () => {
describe("clone-import", () => {
it("parseFurnidata normalizes room + wall items with itemType", () => {
const list = parseFurnidata({
roomitemtypes: { furnitype: [ENTRY] },
wallitemtypes: { furnitype: [{ ...ENTRY, classname: 'wall_x' }] },
})
expect(list.find((e) => e.classname === 'bc_sofa')?.itemType).toBe('s')
expect(list.find((e) => e.classname === 'wall_x')?.itemType).toBe('i')
})
wallitemtypes: { furnitype: [{ ...ENTRY, classname: "wall_x" }] },
});
expect(list.find((e) => e.classname === "bc_sofa")?.itemType).toBe("s");
expect(list.find((e) => e.classname === "wall_x")?.itemType).toBe("i");
});
it('cloneSingleFurni downloads .nitro+icon, inserts items_base, appends FurnitureData, makes catalog item', async () => {
it("cloneSingleFurni downloads .nitro+icon, inserts items_base, appends FurnitureData, makes catalog item", async () => {
// Reset mocks to clear state from other tests.
downloadFile.mockReset()
downloadFile.mockResolvedValue({ ok: true, size: 200 })
execRaw.mockReset()
execRaw.mockResolvedValue(1)
queryRaw.mockReset()
downloadFile.mockReset();
downloadFile.mockResolvedValue({ ok: true, size: 200 });
execRaw.mockReset();
execRaw.mockResolvedValue(1);
queryRaw.mockReset();
// First call: dedup check (no existing row); second call: allocateItemsBaseId MAX(id)+1.
queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ next: 42 }])
appendFurniEntry.mockReset()
appendFurniEntry.mockResolvedValue(undefined)
queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ next: 42 }]);
appendFurniEntry.mockReset();
appendFurniEntry.mockResolvedValue(undefined);
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: 's' } })
expect(r.ok).toBe(true)
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: "s" } });
expect(r.ok).toBe(true);
expect(downloadFile).toHaveBeenCalledWith(
'https://x/furni/bc_sofa.nitro',
"https://x/furni/bc_sofa.nitro",
expect.any(String),
expect.any(Object),
)
);
expect(downloadFile).toHaveBeenCalledWith(
'https://x/icons/bc_sofa_icon.png',
"https://x/icons/bc_sofa_icon.png",
expect.any(String),
expect.objectContaining({ validate: 'png' }),
)
expect(appendFurniEntry).toHaveBeenCalled()
expect(execRaw).toHaveBeenCalled() // items_base insert + catalog
})
expect.objectContaining({ validate: "png" }),
);
expect(appendFurniEntry).toHaveBeenCalled();
expect(execRaw).toHaveBeenCalled(); // items_base insert + catalog
});
it('skips when classname already exists (dedup)', async () => {
queryRaw.mockReset()
queryRaw.mockResolvedValueOnce([{ id: 5 }]) // existing items_base row
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: 's' } })
expect(r.ok).toBe(false)
expect(r.skipped).toBe(true)
})
it("skips when classname already exists (dedup)", async () => {
queryRaw.mockReset();
queryRaw.mockResolvedValueOnce([{ id: 5 }]); // existing items_base row
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: "s" } });
expect(r.ok).toBe(false);
expect(r.skipped).toBe(true);
});
it('rolls back: unlinks .nitro and icon, skips FurnitureData when items_base INSERT rejects', async () => {
downloadFile.mockReset()
downloadFile.mockResolvedValue({ ok: true, size: 200 })
fsUnlink.mockReset()
fsUnlink.mockResolvedValue(undefined)
appendFurniEntry.mockReset()
queryRaw.mockReset()
execRaw.mockReset()
it("rolls back: unlinks .nitro and icon, skips FurnitureData when items_base INSERT rejects", async () => {
downloadFile.mockReset();
downloadFile.mockResolvedValue({ ok: true, size: 200 });
fsUnlink.mockReset();
fsUnlink.mockResolvedValue(undefined);
appendFurniEntry.mockReset();
queryRaw.mockReset();
execRaw.mockReset();
// Dedup query: no existing row.
queryRaw.mockResolvedValueOnce([])
queryRaw.mockResolvedValueOnce([]);
// allocateItemsBaseId: MAX(id)+1 query returns next id.
queryRaw.mockResolvedValueOnce([{ next: 7 }])
queryRaw.mockResolvedValueOnce([{ next: 7 }]);
// items_base INSERT rejects.
execRaw.mockRejectedValueOnce(new Error('Duplicate entry'))
execRaw.mockRejectedValueOnce(new Error("Duplicate entry"));
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: 's' } })
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: "s" } });
// (a) .nitro must be unlinked.
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining('bc_sofa.nitro'))
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining("bc_sofa.nitro"));
// (b) icon must also be unlinked.
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining('bc_sofa_icon.png'))
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining("bc_sofa_icon.png"));
// (c) appendFurniEntry must NOT have been called.
expect(appendFurniEntry).not.toHaveBeenCalled()
expect(appendFurniEntry).not.toHaveBeenCalled();
// (d) result must be ok === false.
expect(r.ok).toBe(false)
expect(r.error).toMatch(/items_base insert failed/)
})
})
expect(r.ok).toBe(false);
expect(r.error).toMatch(/items_base insert failed/);
});
});
+152 -159
View File
@@ -1,94 +1,93 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { prisma } from '@/lib/prisma'
import { extractFurniIconPng } from '@/lib/services/clone-icon'
import type { CloneSource } from '@/lib/services/clone-sources'
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
import { appendFurniEntry } from '@/lib/services/furni-data'
import { promises as fs } from "node:fs";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import type { CloneSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry } from "@/lib/services/furni-data";
import {
allocateCatalogItemId,
autoPriceFurni,
ensureDirectories,
getOrCreateCategoryPage,
} from '@/lib/services/furni-import'
import { downloadFile } from '@/lib/services/import/core/download'
import { parseNitroBundle } from '@/lib/services/swf/nitro-builder'
} from "@/lib/services/furni-import";
import { downloadFile } from "@/lib/services/import/core/download";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
export interface SourceFurni {
id: number
classname: string
name: string
description: string
xdim: number
ydim: number
canstandon: boolean
cansiton: boolean
canlayon: boolean
customparams: string
itemType: 's' | 'i'
[k: string]: unknown
id: number;
classname: string;
name: string;
description: string;
xdim: number;
ydim: number;
canstandon: boolean;
cansiton: boolean;
canlayon: boolean;
customparams: string;
itemType: "s" | "i";
[k: string]: unknown;
}
export interface CloneResult {
ok: boolean
classname: string
skipped?: boolean
warnings: string[]
error?: string
ok: boolean;
classname: string;
skipped?: boolean;
warnings: string[];
error?: string;
}
interface FurniType {
furnitype?: Array<Record<string, unknown>>
furnitype?: Array<Record<string, unknown>>;
}
interface RemoteFurnidata {
roomitemtypes?: FurniType
wallitemtypes?: FurniType
furnitype?: Array<Record<string, unknown>>
roomitemtypes?: FurniType;
wallitemtypes?: FurniType;
furnitype?: Array<Record<string, unknown>>;
}
// Spread ...raw first so the coerced typed fields always win over raw values.
function toSourceFurni(raw: Record<string, unknown>, itemType: 's' | 'i'): SourceFurni {
function toSourceFurni(raw: Record<string, unknown>, itemType: "s" | "i"): SourceFurni {
return {
...raw,
id: Number(raw.id ?? 0),
classname: String(raw.classname ?? ''),
name: String(raw.name ?? raw.classname ?? ''),
description: String(raw.description ?? ''),
classname: String(raw.classname ?? ""),
name: String(raw.name ?? raw.classname ?? ""),
description: String(raw.description ?? ""),
xdim: Number(raw.xdim ?? 1),
ydim: Number(raw.ydim ?? 1),
canstandon: raw.canstandon === true,
cansiton: raw.cansiton === true,
canlayon: raw.canlayon === true,
customparams: String(raw.customparams ?? ''),
customparams: String(raw.customparams ?? ""),
itemType,
}
};
}
export function parseFurnidata(data: RemoteFurnidata): SourceFurni[] {
const out: SourceFurni[] = []
for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? [])
out.push(toSourceFurni(r, 's'))
for (const r of data.wallitemtypes?.furnitype ?? []) out.push(toSourceFurni(r, 'i'))
return out.filter((e) => e.classname)
const out: SourceFurni[] = [];
for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? []) out.push(toSourceFurni(r, "s"));
for (const r of data.wallitemtypes?.furnitype ?? []) out.push(toSourceFurni(r, "i"));
return out.filter((e) => e.classname);
}
const cache = new Map<string, { list: SourceFurni[]; ts: number }>()
const TTL = 5 * 60 * 1000
const cache = new Map<string, { list: SourceFurni[]; ts: number }>();
const TTL = 5 * 60 * 1000;
export async function fetchSourceFurnidata(url: string, now = Date.now()): Promise<SourceFurni[]> {
const hit = cache.get(url)
if (hit && now && now - hit.ts < TTL) return hit.list
const res = await fetch(url, { signal: AbortSignal.timeout(30000) })
if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`)
let list: SourceFurni[]
const hit = cache.get(url);
if (hit && now && now - hit.ts < TTL) return hit.list;
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`);
let list: SourceFurni[];
try {
const json = await res.json()
list = parseFurnidata(json)
const json = await res.json();
list = parseFurnidata(json);
} catch (err) {
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`)
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`);
}
cache.set(url, { list, ts: now })
return list
cache.set(url, { list, ts: now });
return list;
}
/**
@@ -96,94 +95,94 @@ export async function fetchSourceFurnidata(url: string, now = Date.now()): Promi
* concurrent clones in the same Node process cannot race on MAX(id)+1.
* Mirrors the `allocateCatalogItemId` pattern from furni-import.ts.
*/
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve()
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
async function allocateItemsBaseId<T>(insertFn: (nextId: number) => Promise<T>): Promise<T> {
const prev = itemsBaseIdAllocChain
let settle!: () => void
const prev = itemsBaseIdAllocChain;
let settle!: () => void;
itemsBaseIdAllocChain = new Promise<void>((r) => {
settle = r
})
await prev.catch(() => {})
settle = r;
});
await prev.catch(() => {});
try {
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`
const nextId = Number(idRow[0]?.next ?? 1)
return await insertFn(nextId)
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`;
const nextId = Number(idRow[0]?.next ?? 1);
return await insertFn(nextId);
} finally {
settle()
settle();
}
}
export async function cloneSingleFurni(params: {
source: CloneSource
entry: SourceFurni
onProgress?: (status: string) => void
source: CloneSource;
entry: SourceFurni;
onProgress?: (status: string) => void;
}): Promise<CloneResult> {
const { source, entry, onProgress } = params
const { classname, itemType } = entry
const warnings: string[] = []
const { source, entry, onProgress } = params;
const { classname, itemType } = entry;
const warnings: string[] = [];
// Path-traversal guard: classname comes from remote furnidata and is used to
// build file paths — reject anything that doesn't look like a safe furni name.
if (!/^[\w\-.*]+$/.test(classname)) {
return { ok: false, classname, warnings, error: 'invalid classname' }
return { ok: false, classname, warnings, error: "invalid classname" };
}
// Dedup by classname.
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`;
if (existing.length > 0) {
return { ok: false, classname, skipped: true, warnings, error: 'Already present' }
return { ok: false, classname, skipped: true, warnings, error: "Already present" };
}
await ensureDirectories()
const { iconDir, nitroDir } = await getFurniAssetDirs()
await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs();
// Download .nitro + icon directly from the source hotel.
onProgress?.('downloading')
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`)
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${classname}_icon.png`)
onProgress?.("downloading");
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`);
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${classname}_icon.png`);
const dl = await downloadFile(`${source.nitroBaseUrl}/${classname}.nitro`, nitroPath, {
maxRetries: 2,
})
});
if (!dl.ok) {
console.warn('[clone-import] nitro download failed for', classname)
return { ok: false, classname, warnings, error: 'nitro download failed' }
console.warn("[clone-import] nitro download failed for", classname);
return { ok: false, classname, warnings, error: "nitro download failed" };
}
// Validate it is a real Nitro bundle.
try {
parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath))
parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
} catch {
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {})
console.warn('[clone-import] invalid .nitro bundle for', classname)
return { ok: false, classname, warnings, error: 'invalid .nitro bundle' }
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
console.warn("[clone-import] invalid .nitro bundle for", classname);
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
}
const iconDl = await downloadFile(`${source.iconBaseUrl}/${classname}_icon.png`, iconPath, {
maxRetries: 1,
validate: 'png',
})
validate: "png",
});
if (!iconDl.ok) {
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
// extract the catalog icon from the bundle we just downloaded.
try {
const icon = extractFurniIconPng(await fs.readFile(/*turbopackIgnore: true*/ nitroPath))
const icon = extractFurniIconPng(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
if (icon) {
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon)
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon);
} else {
warnings.push('no icon (not in source or bundle)')
warnings.push("no icon (not in source or bundle)");
}
} catch {
warnings.push('icon extraction failed')
warnings.push("icon extraction failed");
}
}
onProgress?.('writing_db')
const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0
onProgress?.("writing_db");
const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
// allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon.
const allowStack = stackHeight > 0 ? '1' : '0'
let newId: number
const allowStack = stackHeight > 0 ? "1" : "0";
let newId: number;
try {
newId = await allocateItemsBaseId(async (nextId) => {
await prisma.$executeRaw`
@@ -192,117 +191,111 @@ export async function cloneSingleFurni(params: {
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
VALUES
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight},
${allowStack}, ${entry.cansiton ? '1' : '0'}, ${entry.canlayon ? '1' : '0'}, ${entry.canstandon ? '1' : '0'}, 'default', ${entry.customparams})`
return nextId
})
${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`;
return nextId;
});
} catch (err) {
// Rollback: remove both downloaded files so we don't leave orphaned assets.
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {})
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {})
console.warn('[clone-import] items_base insert failed for', classname, (err as Error).message)
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {});
console.warn("[clone-import] items_base insert failed for", classname, (err as Error).message);
return {
ok: false,
classname,
warnings,
error: `items_base insert failed: ${(err as Error).message}`,
}
};
}
// FurnitureData entry — reuse the source's furnitype object, with our id.
onProgress?.('writing_furnidata')
onProgress?.("writing_furnidata");
try {
await appendFurniEntry({ ...entry, id: newId } as Record<string, unknown>, itemType)
await appendFurniEntry({ ...entry, id: newId } as Record<string, unknown>, itemType);
} catch (err) {
console.warn(
'[clone-import] FurnitureData append failed for',
classname,
(err as Error).message,
)
warnings.push(`FurnitureData append failed: ${(err as Error).message}`)
console.warn("[clone-import] FurnitureData append failed for", classname, (err as Error).message);
warnings.push(`FurnitureData append failed: ${(err as Error).message}`);
}
// Catalog entry (category sub-page + auto price), serialized id allocation.
try {
const pageId = await getOrCreateCategoryPage(classname, itemType)
const price = autoPriceFurni(classname)
const pageId = await getOrCreateCategoryPage(classname, itemType);
const price = autoPriceFurni(classname);
await allocateCatalogItemId(async (nextCatalogId) => {
await prisma.$executeRaw`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`
return nextCatalogId
})
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`;
return nextCatalogId;
});
} catch (err) {
console.warn('[clone-import] catalog entry failed for', classname, (err as Error).message)
warnings.push(`catalog entry failed: ${(err as Error).message}`)
console.warn("[clone-import] catalog entry failed for", classname, (err as Error).message);
warnings.push(`catalog entry failed: ${(err as Error).message}`);
}
return { ok: true, classname, warnings }
return { ok: true, classname, warnings };
}
export async function getCloneList(params: {
source: CloneSource
search: string
page: number
perPage: number
filter?: 'all' | 'missing' | 'present'
source: CloneSource;
search: string;
page: number;
perPage: number;
filter?: "all" | "missing" | "present";
}): Promise<{
items: Array<SourceFurni & { present: boolean }>
meta: { page: number; perPage: number; total: number }
items: Array<SourceFurni & { present: boolean }>;
meta: { page: number; perPage: number; total: number };
}> {
const { source, search, page, perPage, filter = 'all' } = params
const all = await fetchSourceFurnidata(source.furnidataUrl)
const term = search.trim().toLowerCase()
const { source, search, page, perPage, filter = "all" } = params;
const all = await fetchSourceFurnidata(source.furnidataUrl);
const term = search.trim().toLowerCase();
const searched = term
? all.filter(
(e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term),
)
: all
? all.filter((e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term))
: all;
// Resolve present status across the whole searched list (not just the page),
// so the missing/present filter and the page total stay correct.
const allNames = searched.map((e) => e.classname)
const present = new Set<string>()
const allNames = searched.map((e) => e.classname);
const present = new Set<string>();
if (allNames.length) {
const placeholders = allNames.map(() => '?').join(',')
const placeholders = allNames.map(() => "?").join(",");
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
...allNames,
)
for (const r of rows) present.add(r.item_name)
);
for (const r of rows) present.add(r.item_name);
}
const filtered =
filter === 'missing'
filter === "missing"
? searched.filter((e) => !present.has(e.classname))
: filter === 'present'
: filter === "present"
? searched.filter((e) => present.has(e.classname))
: searched
: searched;
const total = filtered.length
const start = (Math.max(page, 1) - 1) * perPage
const slice = filtered.slice(start, start + perPage)
const total = filtered.length;
const start = (Math.max(page, 1) - 1) * perPage;
const slice = filtered.slice(start, start + perPage);
return {
items: slice.map((e) => ({ ...e, present: present.has(e.classname) })),
meta: { page: Math.max(page, 1), perPage, total },
}
};
}
export async function getCloneStats(
source: CloneSource,
): Promise<{ total: number; present: number; clonable: number }> {
const all = await fetchSourceFurnidata(source.furnidataUrl)
const classnames = all.map((e) => e.classname)
const have = new Set<string>()
const all = await fetchSourceFurnidata(source.furnidataUrl);
const classnames = all.map((e) => e.classname);
const have = new Set<string>();
if (classnames.length) {
const placeholders = classnames.map(() => '?').join(',')
const placeholders = classnames.map(() => "?").join(",");
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
...classnames,
)
for (const r of rows) have.add(r.item_name)
);
for (const r of rows) have.add(r.item_name);
}
const present = all.filter((e) => have.has(e.classname)).length
return { total: all.length, present, clonable: all.length - present }
const present = all.filter((e) => have.has(e.classname)).length;
return { total: all.length, present, clonable: all.length - present };
}
/**
@@ -310,16 +303,16 @@ export async function getCloneStats(
* in our items_base — used by the "clone all" action (cloned in chunks by the UI).
*/
export async function getClonableClassnames(source: CloneSource): Promise<string[]> {
const all = await fetchSourceFurnidata(source.furnidataUrl)
const classnames = all.map((e) => e.classname)
const have = new Set<string>()
const all = await fetchSourceFurnidata(source.furnidataUrl);
const classnames = all.map((e) => e.classname);
const have = new Set<string>();
if (classnames.length) {
const placeholders = classnames.map(() => '?').join(',')
const placeholders = classnames.map(() => "?").join(",");
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
...classnames,
)
for (const r of rows) have.add(r.item_name)
);
for (const r of rows) have.add(r.item_name);
}
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname)
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
}
+25 -27
View File
@@ -1,49 +1,47 @@
import { randomUUID } from 'node:crypto'
import { siteSettings } from '@/lib/services/site-settings'
import { randomUUID } from "node:crypto";
import { siteSettings } from "@/lib/services/site-settings";
export interface CloneSource {
id: string
name: string
furnidataUrl: string
nitroBaseUrl: string
iconBaseUrl: string
id: string;
name: string;
furnidataUrl: string;
nitroBaseUrl: string;
iconBaseUrl: string;
}
const KEY = 'clone_sources'
const KEY = "clone_sources";
export async function listSources(): Promise<CloneSource[]> {
const raw = (await siteSettings.get(KEY, '[]')) ?? '[]'
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
try {
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr : []
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return []
return [];
}
}
export async function upsertSource(
src: Omit<CloneSource, 'id'> & { id?: string },
): Promise<CloneSource> {
const sources = await listSources()
export async function upsertSource(src: Omit<CloneSource, "id"> & { id?: string }): Promise<CloneSource> {
const sources = await listSources();
const entry: CloneSource = {
id: src.id || randomUUID(),
name: src.name.trim(),
furnidataUrl: src.furnidataUrl.trim(),
nitroBaseUrl: src.nitroBaseUrl.trim().replace(/\/+$/, ''),
iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ''),
}
const idx = sources.findIndex((s) => s.id === entry.id)
if (idx >= 0) sources[idx] = entry
else sources.push(entry)
await siteSettings.update(KEY, JSON.stringify(sources))
return entry
nitroBaseUrl: src.nitroBaseUrl.trim().replace(/\/+$/, ""),
iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ""),
};
const idx = sources.findIndex((s) => s.id === entry.id);
if (idx >= 0) sources[idx] = entry;
else sources.push(entry);
await siteSettings.update(KEY, JSON.stringify(sources));
return entry;
}
export async function deleteSource(id: string): Promise<void> {
const sources = (await listSources()).filter((s) => s.id !== id)
await siteSettings.update(KEY, JSON.stringify(sources))
const sources = (await listSources()).filter((s) => s.id !== id);
await siteSettings.update(KEY, JSON.stringify(sources));
}
export async function getSource(id: string): Promise<CloneSource | null> {
return (await listSources()).find((s) => s.id === id) ?? null
return (await listSources()).find((s) => s.id === id) ?? null;
}
+32 -32
View File
@@ -1,65 +1,65 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from "vitest";
const mergeSet = vi.fn(async () => {})
const mergePalette = vi.fn(async () => {})
const mergeLibrary = vi.fn(async () => {})
const importSingleFigure = vi.fn(async () => ({ ok: true, lib: '', warnings: [] }))
const mergeSet = vi.fn(async () => {});
const mergePalette = vi.fn(async () => {});
const mergeLibrary = vi.fn(async () => {});
const importSingleFigure = vi.fn(async () => ({ ok: true, lib: "", warnings: [] }));
vi.mock('@/lib/services/figure-official', () => ({
vi.mock("@/lib/services/figure-official", () => ({
fetchOfficialFigureData: vi.fn(async () => ({
palettes: [{ id: 3, colors: [] }],
setTypes: [
{
type: 'ch',
type: "ch",
paletteId: 3,
sets: [
{
id: 5000,
gender: 'M',
gender: "M",
club: 0,
colorable: true,
selectable: true,
preselectable: false,
sellable: true,
parts: [{ id: 4316, type: 'ch', colorable: true, index: 0, colorindex: 1 }],
parts: [{ id: 4316, type: "ch", colorable: true, index: 0, colorindex: 1 }],
},
],
},
],
})),
fetchOfficialFigureMap: vi.fn(async () => [
{ id: 'shirt_M_x', revision: 1, parts: [{ id: 4316, type: 'ch' }] },
{ id: "shirt_M_x", revision: 1, parts: [{ id: 4316, type: "ch" }] },
]),
}))
vi.mock('@/lib/services/figuredata', () => ({
}));
vi.mock("@/lib/services/figuredata", () => ({
mergeSet: (...a: unknown[]) => mergeSet(...(a as Parameters<typeof mergeSet>)),
mergePalette: (...a: unknown[]) => mergePalette(...(a as Parameters<typeof mergePalette>)),
listLocalSetIds: vi.fn(async () => new Set<number>()),
}))
vi.mock('@/lib/services/figuremap', () => ({
}));
vi.mock("@/lib/services/figuremap", () => ({
mergeLibrary: (...a: unknown[]) => mergeLibrary(...(a as Parameters<typeof mergeLibrary>)),
listFigureLibraries: vi.fn(async () => []),
}))
vi.mock('@/lib/services/figure-import', () => ({
}));
vi.mock("@/lib/services/figure-import", () => ({
importSingleFigure: (...a: unknown[]) =>
importSingleFigure(...(a as Parameters<typeof importSingleFigure>)),
ensureFigureDirs: vi.fn(async () => {}),
}))
}));
import { importClothingSet } from './clothing-set-import'
import { importClothingSet } from "./clothing-set-import";
describe('importClothingSet', () => {
it('imports the libraries for the set parts, then merges set + palette', async () => {
const r = await importClothingSet({ setType: 'ch', setId: 5000 })
expect(r.ok).toBe(true)
expect(importSingleFigure).toHaveBeenCalledWith(expect.objectContaining({ lib: 'shirt_M_x' }))
expect(mergeLibrary).toHaveBeenCalledWith(expect.objectContaining({ id: 'shirt_M_x' }))
expect(mergeSet).toHaveBeenCalledWith('ch', 3, expect.objectContaining({ id: 5000 }))
expect(mergePalette).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }))
})
describe("importClothingSet", () => {
it("imports the libraries for the set parts, then merges set + palette", async () => {
const r = await importClothingSet({ setType: "ch", setId: 5000 });
expect(r.ok).toBe(true);
expect(importSingleFigure).toHaveBeenCalledWith(expect.objectContaining({ lib: "shirt_M_x" }));
expect(mergeLibrary).toHaveBeenCalledWith(expect.objectContaining({ id: "shirt_M_x" }));
expect(mergeSet).toHaveBeenCalledWith("ch", 3, expect.objectContaining({ id: 5000 }));
expect(mergePalette).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
});
it('fails when the set is not in official figuredata', async () => {
const r = await importClothingSet({ setType: 'ch', setId: 999999 })
expect(r.ok).toBe(false)
})
})
it("fails when the set is not in official figuredata", async () => {
const r = await importClothingSet({ setType: "ch", setId: 999999 });
expect(r.ok).toBe(false);
});
});
+81 -86
View File
@@ -1,56 +1,56 @@
import { ensureFigureDirs, importSingleFigure } from '@/lib/services/figure-import'
import { ensureFigureDirs, importSingleFigure } from "@/lib/services/figure-import";
import {
fetchOfficialFigureData,
fetchOfficialFigureMap,
type OfficialFigureData,
type OfficialLibrary,
type OfficialSet,
} from '@/lib/services/figure-official'
import { listLocalSetIds, mergePalette, mergeSet } from '@/lib/services/figuredata'
import { listFigureLibraries, mergeLibrary } from '@/lib/services/figuremap'
} from "@/lib/services/figure-official";
import { listLocalSetIds, mergePalette, mergeSet } from "@/lib/services/figuredata";
import { listFigureLibraries, mergeLibrary } from "@/lib/services/figuremap";
export interface ClothingSetItem {
setType: string
setId: number
gender: string
club: number
sellable: boolean
present: boolean
setType: string;
setId: number;
gender: string;
club: number;
sellable: boolean;
present: boolean;
}
export interface ClothingSetListResult {
items: ClothingSetItem[]
meta: { page: number; perPage: number; total: number }
items: ClothingSetItem[];
meta: { page: number; perPage: number; total: number };
}
export interface ClothingSetStats {
total: number
present: number
missing: number
total: number;
present: number;
missing: number;
}
export interface ImportClothingSetResult {
ok: boolean
setId?: number
warnings: string[]
error?: string
ok: boolean;
setId?: number;
warnings: string[];
error?: string;
}
function partKey(type: string, id: number): string {
return `${type}:${id}`
return `${type}:${id}`;
}
function buildPartIndex(officialLibs: OfficialLibrary[]): Map<string, OfficialLibrary> {
const index = new Map<string, OfficialLibrary>()
const index = new Map<string, OfficialLibrary>();
for (const lib of officialLibs) {
for (const part of lib.parts) {
const key = partKey(part.type, part.id)
const key = partKey(part.type, part.id);
if (!index.has(key)) {
index.set(key, lib)
index.set(key, lib);
}
}
}
return index
return index;
}
function mapOfficialSet(set: OfficialSet): Record<string, unknown> {
@@ -69,99 +69,96 @@ function mapOfficialSet(set: OfficialSet): Record<string, unknown> {
index: p.index,
colorindex: p.colorindex,
})),
}
};
if (set.hiddenLayers?.length) {
result.hiddenLayers = set.hiddenLayers
result.hiddenLayers = set.hiddenLayers;
}
return result
return result;
}
export async function importClothingSet(params: {
setType: string
setId: number
onProgress?: (status: string) => void
setType: string;
setId: number;
onProgress?: (status: string) => void;
}): Promise<ImportClothingSetResult> {
const { setType, setId, onProgress } = params
const warnings: string[] = []
const { setType, setId, onProgress } = params;
const warnings: string[] = [];
const [officialFd, officialLibs] = await Promise.all([
fetchOfficialFigureData(),
fetchOfficialFigureMap(),
])
const [officialFd, officialLibs] = await Promise.all([fetchOfficialFigureData(), fetchOfficialFigureMap()]);
const st = officialFd.setTypes.find((s) => s.type === setType)
const st = officialFd.setTypes.find((s) => s.type === setType);
if (!st) {
return { ok: false, warnings, error: `setType "${setType}" not found in official figuredata` }
return { ok: false, warnings, error: `setType "${setType}" not found in official figuredata` };
}
const set = st.sets.find((s) => s.id === setId)
const set = st.sets.find((s) => s.id === setId);
if (!set) {
return {
ok: false,
warnings,
error: `set id ${setId} not found in setType "${setType}" in official figuredata`,
}
};
}
const palette = officialFd.palettes.find((p) => p.id === st.paletteId)
const palette = officialFd.palettes.find((p) => p.id === st.paletteId);
const partIndex = buildPartIndex(officialLibs)
const requiredLibIds = new Set<string>()
const partIndex = buildPartIndex(officialLibs);
const requiredLibIds = new Set<string>();
for (const part of set.parts) {
const lib = partIndex.get(partKey(part.type, part.id))
const lib = partIndex.get(partKey(part.type, part.id));
if (lib) {
requiredLibIds.add(lib.id)
requiredLibIds.add(lib.id);
} else {
warnings.push(`No library found for part ${part.type}:${part.id}`)
warnings.push(`No library found for part ${part.type}:${part.id}`);
}
}
const localLibs = await listFigureLibraries()
const localLibIds = new Set(localLibs.map((l) => l.id))
const localLibs = await listFigureLibraries();
const localLibIds = new Set(localLibs.map((l) => l.id));
await ensureFigureDirs()
await ensureFigureDirs();
for (const libId of requiredLibIds) {
const officialLib = officialLibs.find((l) => l.id === libId)
if (!officialLib) continue
const officialLib = officialLibs.find((l) => l.id === libId);
if (!officialLib) continue;
if (!localLibIds.has(libId)) {
onProgress?.(`importing library ${libId}`)
const result = await importSingleFigure({ lib: libId, onProgress })
onProgress?.(`importing library ${libId}`);
const result = await importSingleFigure({ lib: libId, onProgress });
if (!result.ok) {
warnings.push(`Library import failed for ${libId}: ${result.error ?? 'unknown error'}`)
warnings.push(`Library import failed for ${libId}: ${result.error ?? "unknown error"}`);
}
if (result.warnings.length) {
warnings.push(...result.warnings)
warnings.push(...result.warnings);
}
}
await mergeLibrary(officialLib)
await mergeLibrary(officialLib);
}
const mappedSet = mapOfficialSet(set)
await mergeSet(setType, st.paletteId, mappedSet as never)
const mappedSet = mapOfficialSet(set);
await mergeSet(setType, st.paletteId, mappedSet as never);
if (palette) {
await mergePalette(palette as never)
await mergePalette(palette as never);
}
return { ok: true, setId, warnings }
return { ok: true, setId, warnings };
}
export async function getClothingSetList(params: {
search: string
page: number
perPage: number
setType?: string
search: string;
page: number;
perPage: number;
setType?: string;
}): Promise<ClothingSetListResult> {
const { search, page, perPage, setType } = params
const { search, page, perPage, setType } = params;
const officialFd: OfficialFigureData = await fetchOfficialFigureData()
const officialFd: OfficialFigureData = await fetchOfficialFigureData();
const allItems: ClothingSetItem[] = []
const allItems: ClothingSetItem[] = [];
for (const st of officialFd.setTypes) {
if (setType && st.type !== setType) continue
const presentIds = await listLocalSetIds(st.type)
if (setType && st.type !== setType) continue;
const presentIds = await listLocalSetIds(st.type);
for (const set of st.sets) {
allItems.push({
setType: st.type,
@@ -170,35 +167,33 @@ export async function getClothingSetList(params: {
club: set.club,
sellable: set.sellable,
present: presentIds.has(set.id),
})
});
}
}
const term = search.trim().toLowerCase()
const term = search.trim().toLowerCase();
const filtered = term
? allItems.filter(
(it) => String(it.setId).includes(term) || it.setType.toLowerCase().includes(term),
)
: allItems
? allItems.filter((it) => String(it.setId).includes(term) || it.setType.toLowerCase().includes(term))
: allItems;
const total = filtered.length
const start = (Math.max(page, 1) - 1) * perPage
const items = filtered.slice(start, start + perPage)
const total = filtered.length;
const start = (Math.max(page, 1) - 1) * perPage;
const items = filtered.slice(start, start + perPage);
return { items, meta: { page: Math.max(page, 1), perPage, total } }
return { items, meta: { page: Math.max(page, 1), perPage, total } };
}
export async function getClothingSetStats(): Promise<ClothingSetStats> {
const officialFd: OfficialFigureData = await fetchOfficialFigureData()
const officialFd: OfficialFigureData = await fetchOfficialFigureData();
let total = 0
let present = 0
let total = 0;
let present = 0;
for (const st of officialFd.setTypes) {
const presentIds = await listLocalSetIds(st.type)
total += st.sets.length
present += st.sets.filter((s) => presentIds.has(s.id)).length
const presentIds = await listLocalSetIds(st.type);
total += st.sets.length;
present += st.sets.filter((s) => presentIds.has(s.id)).length;
}
return { total, present, missing: total - present }
return { total, present, missing: total - present };
}
+34 -35
View File
@@ -1,55 +1,54 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from "vitest";
vi.mock('@/lib/services/import/core/download', () => ({
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
}))
vi.mock('@/lib/services/effect-source', () => ({
}));
vi.mock("@/lib/services/effect-source", () => ({
resolveEffectSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
listOfficialEffects: vi.fn(async () => []),
}))
const mergeEffect = vi.fn(async () => {})
vi.mock('@/lib/services/effectmap', () => ({
}));
const mergeEffect = vi.fn(async () => {});
vi.mock("@/lib/services/effectmap", () => ({
mergeEffect: (...a: unknown[]) => mergeEffect(...(a as Parameters<typeof mergeEffect>)),
removeEffect: vi.fn(async () => true),
listLocalEffects: vi.fn(async () => []),
}))
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from('nitro'), warnings: [] }))
vi.mock('@/lib/services/swf-to-nitro', () => ({
convertSwfToNitro: (...a: unknown[]) =>
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}))
vi.mock('node:fs', async (orig) => {
const real = (await orig()) as typeof import('node:fs')
}));
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from("nitro"), warnings: [] }));
vi.mock("@/lib/services/swf-to-nitro", () => ({
convertSwfToNitro: (...a: unknown[]) => convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}));
vi.mock("node:fs", async (orig) => {
const real = (await orig()) as typeof import("node:fs");
return {
...real,
existsSync: vi.fn(() => true),
promises: {
...real.promises,
readFile: vi.fn(async () => Buffer.from('CWS')),
readFile: vi.fn(async () => Buffer.from("CWS")),
writeFile: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
},
}
})
};
});
import { importSingleEffect } from './effect-import'
import { importSingleEffect } from "./effect-import";
describe('effect-import.importSingleEffect', () => {
it('downloads, converts, writes .nitro, merges EffectMap', async () => {
const r = await importSingleEffect({ id: '5', lib: 'Torch', type: 'fx', revision: 59220 })
expect(r.ok).toBe(true)
expect(convertSwfToNitro).toHaveBeenCalled()
expect(mergeEffect).toHaveBeenCalledWith({ id: '5', lib: 'Torch', type: 'fx', revision: 59220 })
})
describe("effect-import.importSingleEffect", () => {
it("downloads, converts, writes .nitro, merges EffectMap", async () => {
const r = await importSingleEffect({ id: "5", lib: "Torch", type: "fx", revision: 59220 });
expect(r.ok).toBe(true);
expect(convertSwfToNitro).toHaveBeenCalled();
expect(mergeEffect).toHaveBeenCalledWith({ id: "5", lib: "Torch", type: "fx", revision: 59220 });
});
it('does NOT merge EffectMap when conversion throws (no orphan entry)', async () => {
mergeEffect.mockClear()
it("does NOT merge EffectMap when conversion throws (no orphan entry)", async () => {
mergeEffect.mockClear();
convertSwfToNitro.mockImplementationOnce(() => {
throw new Error('bad swf')
})
const r = await importSingleEffect({ id: '6', lib: 'Jetpack', type: 'fx', revision: 59200 })
expect(r.ok).toBe(false)
expect(mergeEffect).not.toHaveBeenCalled()
})
})
throw new Error("bad swf");
});
const r = await importSingleEffect({ id: "6", lib: "Jetpack", type: "fx", revision: 59200 });
expect(r.ok).toBe(false);
expect(mergeEffect).not.toHaveBeenCalled();
});
});
+63 -68
View File
@@ -1,137 +1,132 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { listOfficialEffects, resolveEffectSwfUrl } from '@/lib/services/effect-source'
import { listLocalEffects, mergeEffect, removeEffect } from '@/lib/services/effectmap'
import { downloadFile } from '@/lib/services/import/core/download'
import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
import type { EffectListItem, EffectMapEntry, ImportEffectResult } from '@/types/effects'
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { listOfficialEffects, resolveEffectSwfUrl } from "@/lib/services/effect-source";
import { listLocalEffects, mergeEffect, removeEffect } from "@/lib/services/effectmap";
import { downloadFile } from "@/lib/services/import/core/download";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
import type { EffectListItem, EffectMapEntry, ImportEffectResult } from "@/types/effects";
export const EFFECT_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/effect',
)
export const EFFECT_SWF_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/swf/gordon',
)
"public/nitro-assets/bundled/effect",
);
export const EFFECT_SWF_DIR = path.join(/*turbopackIgnore: true*/ process.cwd(), "public/swf/gordon");
export async function ensureEffectDirs(): Promise<void> {
await Promise.all(
[EFFECT_NITRO_DIR, EFFECT_SWF_DIR]
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
.map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })),
)
);
}
function nitroPathFor(lib: string): string {
return path.join(/*turbopackIgnore: true*/ EFFECT_NITRO_DIR, `${lib}.nitro`)
return path.join(/*turbopackIgnore: true*/ EFFECT_NITRO_DIR, `${lib}.nitro`);
}
export async function importSingleEffect(params: {
id: string
lib: string
type: string
revision: number
onProgress?: (status: string) => void
id: string;
lib: string;
type: string;
revision: number;
onProgress?: (status: string) => void;
}): Promise<ImportEffectResult> {
const { id, lib, type, revision, onProgress } = params
const warnings: string[] = []
await ensureEffectDirs()
const { id, lib, type, revision, onProgress } = params;
const warnings: string[] = [];
await ensureEffectDirs();
const nitroPath = nitroPathFor(lib)
const nitroPath = nitroPathFor(lib);
// Download SWF
onProgress?.('downloading')
const swfPath = path.join(/*turbopackIgnore: true*/ EFFECT_SWF_DIR, `${lib}.swf`)
let swfUrl: string
onProgress?.("downloading");
const swfPath = path.join(/*turbopackIgnore: true*/ EFFECT_SWF_DIR, `${lib}.swf`);
let swfUrl: string;
try {
swfUrl = await resolveEffectSwfUrl(lib)
swfUrl = await resolveEffectSwfUrl(lib);
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `Could not resolve SWF URL: ${(err as Error).message}`,
}
};
}
const dl = await downloadFile(swfUrl, swfPath, { validate: 'swf' })
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` }
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` };
// Convert SWF → Nitro
onProgress?.('converting')
onProgress?.("converting");
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
const result = convertSwfToNitro(swfBuffer, lib)
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, result.bundle)
if (result.warnings?.length) warnings.push(...result.warnings)
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, result.bundle);
if (result.warnings?.length) warnings.push(...result.warnings);
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
}
};
}
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
onProgress?.('writing_map')
onProgress?.("writing_map");
try {
const entry: EffectMapEntry = { id, lib, type, revision }
await mergeEffect(entry)
const entry: EffectMapEntry = { id, lib, type, revision };
await mergeEffect(entry);
} catch (err) {
warnings.push(`EffectMap.json merge failed: ${(err as Error).message}`)
return { ok: false, lib, warnings, error: 'EffectMap.json merge failed' }
warnings.push(`EffectMap.json merge failed: ${(err as Error).message}`);
return { ok: false, lib, warnings, error: "EffectMap.json merge failed" };
}
return { ok: true, lib, warnings }
return { ok: true, lib, warnings };
}
export async function deleteImportedEffect(
idOrLib: string,
): Promise<{ removed: boolean; deletedFile: boolean }> {
const local = await listLocalEffects()
const entry = local.find((e) => e.id === idOrLib || e.lib === idOrLib)
const removed = await removeEffect(idOrLib)
let deletedFile = false
const local = await listLocalEffects();
const entry = local.find((e) => e.id === idOrLib || e.lib === idOrLib);
const removed = await removeEffect(idOrLib);
let deletedFile = false;
if (entry) {
const p = nitroPathFor(entry.lib)
const p = nitroPathFor(entry.lib);
if (existsSync(/*turbopackIgnore: true*/ p)) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {})
deletedFile = true
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
deletedFile = true;
}
}
return { removed, deletedFile }
return { removed, deletedFile };
}
export async function getEffectList(search: string): Promise<EffectListItem[]> {
const [official, local] = await Promise.all([listOfficialEffects(), listLocalEffects()])
const localByLib = new Map(local.map((e) => [e.lib, e]))
const term = search.trim().toLowerCase()
const [official, local] = await Promise.all([listOfficialEffects(), listLocalEffects()]);
const localByLib = new Map(local.map((e) => [e.lib, e]));
const term = search.trim().toLowerCase();
const merged = official.map((e) => {
const present = localByLib.has(e.lib)
const present = localByLib.has(e.lib);
return {
...e,
present,
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(e.lib)),
}
})
};
});
return term
? merged.filter(
(e) =>
e.lib.toLowerCase().includes(term) ||
e.id.toLowerCase().includes(term) ||
e.type.includes(term),
e.lib.toLowerCase().includes(term) || e.id.toLowerCase().includes(term) || e.type.includes(term),
)
: merged
: merged;
}
export async function getEffectStats(): Promise<{
official: number
imported: number
missing: number
official: number;
imported: number;
missing: number;
}> {
const [official, local] = await Promise.all([listOfficialEffects(), listLocalEffects()])
const localLibs = new Set(local.map((e) => e.lib))
const imported = official.filter((e) => localLibs.has(e.lib)).length
return { official: official.length, imported, missing: official.length - imported }
const [official, local] = await Promise.all([listOfficialEffects(), listLocalEffects()]);
const localLibs = new Set(local.map((e) => e.lib));
const imported = official.filter((e) => localLibs.has(e.lib)).length;
return { official: official.length, imported, missing: official.length - imported };
}
+28 -28
View File
@@ -1,48 +1,48 @@
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
import { siteSettings } from '@/lib/services/site-settings'
import type { EffectMapEntry } from '@/types/effects'
import { resolveGordonBuildUrl } from "@/lib/services/import/core/gordon";
import { siteSettings } from "@/lib/services/site-settings";
import type { EffectMapEntry } from "@/types/effects";
// Defaults to VALIDATE against a live fetch (spec Open Item #2). Override the
// SWF source with the `effect_swf_base_url` site setting if these drift.
const OFFICIAL_EFFECTMAP_URL = 'https://www.habbo.com/gamedata/effectmap/1'
const CACHE_TTL = 30 * 60 * 1000
const OFFICIAL_EFFECTMAP_URL = "https://www.habbo.com/gamedata/effectmap/1";
const CACHE_TTL = 30 * 60 * 1000;
let effectCache: { list: EffectMapEntry[]; ts: number } | null = null
let effectCache: { list: EffectMapEntry[]; ts: number } | null = null;
export function __clearEffectSourceCache(): void {
effectCache = null
effectCache = null;
}
function parseEffectMapXml(xml: string): EffectMapEntry[] {
const out: EffectMapEntry[] = []
const re = /<effect\b([^>]*)\/?>/g
const out: EffectMapEntry[] = [];
const re = /<effect\b([^>]*)\/?>/g;
for (const m of xml.matchAll(re)) {
const attrs = m[1]
const id = /\bid="([^"]*)"/.exec(attrs)?.[1]
const lib = /\blib="([^"]*)"/.exec(attrs)?.[1]
const type = /\btype="([^"]*)"/.exec(attrs)?.[1] ?? 'fx'
const revision = Number(/\brevision="([^"]*)"/.exec(attrs)?.[1] ?? '0')
if (id && lib) out.push({ id, lib, type, revision: Number.isFinite(revision) ? revision : 0 })
const attrs = m[1];
const id = /\bid="([^"]*)"/.exec(attrs)?.[1];
const lib = /\blib="([^"]*)"/.exec(attrs)?.[1];
const type = /\btype="([^"]*)"/.exec(attrs)?.[1] ?? "fx";
const revision = Number(/\brevision="([^"]*)"/.exec(attrs)?.[1] ?? "0");
if (id && lib) out.push({ id, lib, type, revision: Number.isFinite(revision) ? revision : 0 });
}
return out
return out;
}
export async function listOfficialEffects(): Promise<EffectMapEntry[]> {
const now = Date.now()
if (effectCache && now - effectCache.ts < CACHE_TTL) return effectCache.list
const res = await fetch(OFFICIAL_EFFECTMAP_URL, { signal: AbortSignal.timeout(20000) })
const now = Date.now();
if (effectCache && now - effectCache.ts < CACHE_TTL) return effectCache.list;
const res = await fetch(OFFICIAL_EFFECTMAP_URL, { signal: AbortSignal.timeout(20000) });
if (!res.ok) {
console.warn(`[effect-source] effectmap fetch failed: ${res.status}`)
return effectCache?.list ?? []
console.warn(`[effect-source] effectmap fetch failed: ${res.status}`);
return effectCache?.list ?? [];
}
const list = parseEffectMapXml(await res.text())
effectCache = { list, ts: now }
return list
const list = parseEffectMapXml(await res.text());
effectCache = { list, ts: now };
return list;
}
export async function resolveEffectSwfUrl(lib: string): Promise<string> {
const override = ((await siteSettings.get('effect_swf_base_url', '')) ?? '').trim()
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
const gordon = await resolveGordonBuildUrl()
return `${gordon}${lib}.swf`
const override = ((await siteSettings.get("effect_swf_base_url", "")) ?? "").trim();
if (override) return `${override.replace(/\/+$/, "")}/${lib}.swf`;
const gordon = await resolveGordonBuildUrl();
return `${gordon}${lib}.swf`;
}
+30 -30
View File
@@ -1,58 +1,58 @@
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
import { resolveGamedataFile } from "@/lib/services/import/core/asset-paths";
import {
readGamedataJson,
withGamedataLock,
writeGamedataJsonAtomic,
} from '@/lib/services/import/core/gamedata-json'
import { siteSettings } from '@/lib/services/site-settings'
import type { EffectMapEntry } from '@/types/effects'
} from "@/lib/services/import/core/gamedata-json";
import { siteSettings } from "@/lib/services/site-settings";
import type { EffectMapEntry } from "@/types/effects";
interface EffectMapFile {
effects: EffectMapEntry[]
effects: EffectMapEntry[];
}
// Test seam: allows the suite to point the resolver at a temp file.
let pathOverride: string | null = null
let pathOverride: string | null = null;
export function __setEffectMapPathForTests(p: string | null): void {
pathOverride = p
pathOverride = p;
}
export async function getEffectMapPath(): Promise<string> {
if (pathOverride) return pathOverride
const url = await siteSettings.get('effectmap_url', '/nitro-assets/gamedata/EffectMap.json')
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/EffectMap.json', 'EffectMap.json')
if (pathOverride) return pathOverride;
const url = await siteSettings.get("effectmap_url", "/nitro-assets/gamedata/EffectMap.json");
return resolveGamedataFile(url ?? "/nitro-assets/gamedata/EffectMap.json", "EffectMap.json");
}
export async function readEffectMap(): Promise<EffectMapFile> {
const file = await getEffectMapPath()
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
return { effects: Array.isArray(data.effects) ? data.effects : [] }
const file = await getEffectMapPath();
const data = await readGamedataJson<Partial<EffectMapFile>>(file);
return { effects: Array.isArray(data.effects) ? data.effects : [] };
}
export async function listLocalEffects(): Promise<EffectMapEntry[]> {
return (await readEffectMap()).effects
return (await readEffectMap()).effects;
}
export async function mergeEffect(entry: EffectMapEntry): Promise<void> {
const file = await getEffectMapPath()
const file = await getEffectMapPath();
await withGamedataLock(file, async () => {
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
const effects = Array.isArray(data.effects) ? data.effects : []
const idx = effects.findIndex((e) => e.id === entry.id)
if (idx >= 0) effects[idx] = entry
else effects.push(entry)
await writeGamedataJsonAtomic(file, { ...data, effects })
})
const data = await readGamedataJson<Partial<EffectMapFile>>(file);
const effects = Array.isArray(data.effects) ? data.effects : [];
const idx = effects.findIndex((e) => e.id === entry.id);
if (idx >= 0) effects[idx] = entry;
else effects.push(entry);
await writeGamedataJsonAtomic(file, { ...data, effects });
});
}
export async function removeEffect(idOrLib: string): Promise<boolean> {
const file = await getEffectMapPath()
const file = await getEffectMapPath();
return withGamedataLock(file, async () => {
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
const effects = Array.isArray(data.effects) ? data.effects : []
const next = effects.filter((e) => e.id !== idOrLib && e.lib !== idOrLib)
const removed = next.length < effects.length
if (removed) await writeGamedataJsonAtomic(file, { ...data, effects: next })
return removed
})
const data = await readGamedataJson<Partial<EffectMapFile>>(file);
const effects = Array.isArray(data.effects) ? data.effects : [];
const next = effects.filter((e) => e.id !== idOrLib && e.lib !== idOrLib);
const removed = next.length < effects.length;
if (removed) await writeGamedataJsonAtomic(file, { ...data, effects: next });
return removed;
});
}
+45 -46
View File
@@ -1,67 +1,66 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from "vitest";
vi.mock('@/lib/services/import/core/download', () => ({
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
}))
vi.mock('@/lib/services/figure-source', () => ({
}));
vi.mock("@/lib/services/figure-source", () => ({
resolveFigureSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
}))
vi.mock('@/lib/services/figuremap', () => ({
}));
vi.mock("@/lib/services/figuremap", () => ({
listFigureLibraries: vi.fn(async () => [
{ id: 'Hair_F_Bob', revision: 1 },
{ id: 'shirt_M_x', revision: 2 },
{ id: 'Hat_U_y', revision: 3 },
{ id: "Hair_F_Bob", revision: 1 },
{ id: "shirt_M_x", revision: 2 },
{ id: "Hat_U_y", revision: 3 },
]),
}))
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from('nitro'), warnings: [] }))
vi.mock('@/lib/services/swf-to-nitro', () => ({
convertSwfToNitro: (...a: unknown[]) =>
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}))
vi.mock('node:fs', async (orig) => {
const real = (await orig()) as typeof import('node:fs')
}));
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from("nitro"), warnings: [] }));
vi.mock("@/lib/services/swf-to-nitro", () => ({
convertSwfToNitro: (...a: unknown[]) => convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}));
vi.mock("node:fs", async (orig) => {
const real = (await orig()) as typeof import("node:fs");
return {
...real,
existsSync: vi.fn(() => false),
promises: {
...real.promises,
readFile: vi.fn(async () => Buffer.from('CWS')),
readFile: vi.fn(async () => Buffer.from("CWS")),
writeFile: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
},
}
})
};
});
import { getFigureList, importSingleFigure } from './figure-import'
import { getFigureList, importSingleFigure } from "./figure-import";
describe('figure-import', () => {
it('paginates the library list with present flags', async () => {
const { items, meta } = await getFigureList({ search: '', page: 1, perPage: 2 })
expect(items).toHaveLength(2)
expect(meta).toMatchObject({ page: 1, perPage: 2, total: 3 })
expect(items[0]).toMatchObject({ lib: 'Hair_F_Bob', nitroExists: false })
})
describe("figure-import", () => {
it("paginates the library list with present flags", async () => {
const { items, meta } = await getFigureList({ search: "", page: 1, perPage: 2 });
expect(items).toHaveLength(2);
expect(meta).toMatchObject({ page: 1, perPage: 2, total: 3 });
expect(items[0]).toMatchObject({ lib: "Hair_F_Bob", nitroExists: false });
});
it('filters by search', async () => {
const { items, meta } = await getFigureList({ search: 'shirt', page: 1, perPage: 50 })
expect(meta.total).toBe(1)
expect(items[0].lib).toBe('shirt_M_x')
})
it("filters by search", async () => {
const { items, meta } = await getFigureList({ search: "shirt", page: 1, perPage: 50 });
expect(meta.total).toBe(1);
expect(items[0].lib).toBe("shirt_M_x");
});
it('importSingleFigure downloads + converts + writes', async () => {
const r = await importSingleFigure({ lib: 'Hair_F_Bob' })
expect(r.ok).toBe(true)
expect(convertSwfToNitro).toHaveBeenCalled()
})
it("importSingleFigure downloads + converts + writes", async () => {
const r = await importSingleFigure({ lib: "Hair_F_Bob" });
expect(r.ok).toBe(true);
expect(convertSwfToNitro).toHaveBeenCalled();
});
it('fails cleanly when conversion throws', async () => {
it("fails cleanly when conversion throws", async () => {
convertSwfToNitro.mockImplementationOnce(() => {
throw new Error('bad swf')
})
const r = await importSingleFigure({ lib: 'shirt_M_x' })
expect(r.ok).toBe(false)
expect(r.error).toContain('conversion')
})
})
throw new Error("bad swf");
});
const r = await importSingleFigure({ lib: "shirt_M_x" });
expect(r.ok).toBe(false);
expect(r.error).toContain("conversion");
});
});
+60 -65
View File
@@ -1,128 +1,123 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { resolveFigureSwfUrl } from '@/lib/services/figure-source'
import { listFigureLibraries } from '@/lib/services/figuremap'
import { downloadFile } from '@/lib/services/import/core/download'
import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
import { listFigureLibraries } from "@/lib/services/figuremap";
import { downloadFile } from "@/lib/services/import/core/download";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
export interface FigureListItem {
lib: string
revision: number
nitroExists: boolean
lib: string;
revision: number;
nitroExists: boolean;
// First part the library provides ({type,id}) — used to render an avatar
// preview wearing it. Undefined if the library declares no parts.
previewType?: string
previewId?: number
previewType?: string;
previewId?: number;
}
export interface ImportFigureResult {
ok: boolean
lib: string
warnings: string[]
error?: string
ok: boolean;
lib: string;
warnings: string[];
error?: string;
}
export const FIGURE_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/figure',
)
export const FIGURE_SWF_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/swf/gordon',
)
"public/nitro-assets/bundled/figure",
);
export const FIGURE_SWF_DIR = path.join(/*turbopackIgnore: true*/ process.cwd(), "public/swf/gordon");
export async function ensureFigureDirs(): Promise<void> {
await Promise.all(
[FIGURE_NITRO_DIR, FIGURE_SWF_DIR]
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
.map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })),
)
);
}
function nitroPathFor(lib: string): string {
return path.join(/*turbopackIgnore: true*/ FIGURE_NITRO_DIR, `${lib}.nitro`)
return path.join(/*turbopackIgnore: true*/ FIGURE_NITRO_DIR, `${lib}.nitro`);
}
export async function importSingleFigure(params: {
lib: string
onProgress?: (status: string) => void
lib: string;
onProgress?: (status: string) => void;
}): Promise<ImportFigureResult> {
const { lib, onProgress } = params
const warnings: string[] = []
await ensureFigureDirs()
const { lib, onProgress } = params;
const warnings: string[] = [];
await ensureFigureDirs();
onProgress?.('downloading')
const swfPath = path.join(/*turbopackIgnore: true*/ FIGURE_SWF_DIR, `${lib}.swf`)
let swfUrl: string
onProgress?.("downloading");
const swfPath = path.join(/*turbopackIgnore: true*/ FIGURE_SWF_DIR, `${lib}.swf`);
let swfUrl: string;
try {
swfUrl = await resolveFigureSwfUrl(lib)
swfUrl = await resolveFigureSwfUrl(lib);
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `Could not resolve SWF URL: ${(err as Error).message}`,
}
};
}
const dl = await downloadFile(swfUrl, swfPath, { validate: 'swf' })
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` }
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` };
onProgress?.('converting')
onProgress?.("converting");
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
const result = convertSwfToNitro(swfBuffer, lib)
await fs.writeFile(/*turbopackIgnore: true*/ nitroPathFor(lib), result.bundle)
if (result.warnings?.length) warnings.push(...result.warnings)
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
await fs.writeFile(/*turbopackIgnore: true*/ nitroPathFor(lib), result.bundle);
if (result.warnings?.length) warnings.push(...result.warnings);
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
}
};
}
return { ok: true, lib, warnings }
return { ok: true, lib, warnings };
}
export async function deleteImportedFigure(lib: string): Promise<{ deletedFile: boolean }> {
const p = nitroPathFor(lib)
const p = nitroPathFor(lib);
if (existsSync(/*turbopackIgnore: true*/ p)) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {})
return { deletedFile: true }
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
return { deletedFile: true };
}
return { deletedFile: false }
return { deletedFile: false };
}
export async function getFigureList(params: {
search: string
page: number
perPage: number
search: string;
page: number;
perPage: number;
}): Promise<{ items: FigureListItem[]; meta: { page: number; perPage: number; total: number } }> {
const { search, page, perPage } = params
const libs = await listFigureLibraries()
const term = search.trim().toLowerCase()
const filtered = term ? libs.filter((l) => l.id.toLowerCase().includes(term)) : libs
const total = filtered.length
const start = (Math.max(page, 1) - 1) * perPage
const { search, page, perPage } = params;
const libs = await listFigureLibraries();
const term = search.trim().toLowerCase();
const filtered = term ? libs.filter((l) => l.id.toLowerCase().includes(term)) : libs;
const total = filtered.length;
const start = (Math.max(page, 1) - 1) * perPage;
const items = filtered.slice(start, start + perPage).map((l) => ({
lib: l.id,
revision: l.revision,
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(l.id)),
previewType: l.parts?.[0]?.type,
previewId: l.parts?.[0]?.id,
}))
return { items, meta: { page: Math.max(page, 1), perPage, total } }
}));
return { items, meta: { page: Math.max(page, 1), perPage, total } };
}
export async function getFigureStats(): Promise<{
total: number
imported: number
missing: number
total: number;
imported: number;
missing: number;
}> {
const libs = await listFigureLibraries()
const imported = libs.filter((l) =>
existsSync(/*turbopackIgnore: true*/ nitroPathFor(l.id)),
).length
return { total: libs.length, imported, missing: libs.length - imported }
const libs = await listFigureLibraries();
const imported = libs.filter((l) => existsSync(/*turbopackIgnore: true*/ nitroPathFor(l.id))).length;
return { total: libs.length, imported, missing: libs.length - imported };
}
+106 -106
View File
@@ -1,161 +1,161 @@
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
import { siteSettings } from '@/lib/services/site-settings'
import { resolveGordonBuildUrl } from "@/lib/services/import/core/gordon";
import { siteSettings } from "@/lib/services/site-settings";
export interface OfficialColor {
id: number
index: number
club: number
selectable: boolean
hexCode: string
id: number;
index: number;
club: number;
selectable: boolean;
hexCode: string;
}
export interface OfficialPalette {
id: number
colors: OfficialColor[]
id: number;
colors: OfficialColor[];
}
export interface OfficialPart {
id: number
type: string
colorable: boolean
index: number
colorindex: number
id: number;
type: string;
colorable: boolean;
index: number;
colorindex: number;
}
export interface OfficialSet {
id: number
gender: string
club: number
colorable: boolean
selectable: boolean
preselectable: boolean
sellable: boolean
parts: OfficialPart[]
hiddenLayers?: { partType: string }[]
id: number;
gender: string;
club: number;
colorable: boolean;
selectable: boolean;
preselectable: boolean;
sellable: boolean;
parts: OfficialPart[];
hiddenLayers?: { partType: string }[];
}
export interface OfficialSetType {
type: string
paletteId: number
mandM0: boolean
mandF0: boolean
mandM1: boolean
mandF1: boolean
sets: OfficialSet[]
type: string;
paletteId: number;
mandM0: boolean;
mandF0: boolean;
mandM1: boolean;
mandF1: boolean;
sets: OfficialSet[];
}
export interface OfficialFigureData {
palettes: OfficialPalette[]
setTypes: OfficialSetType[]
palettes: OfficialPalette[];
setTypes: OfficialSetType[];
}
export interface OfficialLibrary {
id: string
revision: number
parts: { id: number; type: string }[]
id: string;
revision: number;
parts: { id: number; type: string }[];
}
const CACHE_TTL = 30 * 60 * 1000
const DEFAULT_FIGUREDATA_URL = 'https://www.habbo.com/gamedata/figuredata/1'
let fdCache: { data: OfficialFigureData; ts: number } | null = null
let fmCache: { libs: OfficialLibrary[]; ts: number } | null = null
const CACHE_TTL = 30 * 60 * 1000;
const DEFAULT_FIGUREDATA_URL = "https://www.habbo.com/gamedata/figuredata/1";
let fdCache: { data: OfficialFigureData; ts: number } | null = null;
let fmCache: { libs: OfficialLibrary[]; ts: number } | null = null;
export function __clearFigureOfficialCache(): void {
fdCache = null
fmCache = null
fdCache = null;
fmCache = null;
}
const attr = (tag: string, name: string): string | undefined =>
new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1]
const bool = (v: string | undefined): boolean => v === '1' || v === 'true'
new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1];
const bool = (v: string | undefined): boolean => v === "1" || v === "true";
const num = (v: string | undefined): number => {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
export async function fetchOfficialFigureData(): Promise<OfficialFigureData> {
const now = Date.now()
if (fdCache && now - fdCache.ts < CACHE_TTL) return fdCache.data
const url = await siteSettings.get('figuredata_source_url', DEFAULT_FIGUREDATA_URL)
const res = await fetch(url ?? DEFAULT_FIGUREDATA_URL, { signal: AbortSignal.timeout(30000) })
if (!res.ok) throw new Error(`figuredata fetch failed: ${res.status}`)
const xml = await res.text()
const now = Date.now();
if (fdCache && now - fdCache.ts < CACHE_TTL) return fdCache.data;
const url = await siteSettings.get("figuredata_source_url", DEFAULT_FIGUREDATA_URL);
const res = await fetch(url ?? DEFAULT_FIGUREDATA_URL, { signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`figuredata fetch failed: ${res.status}`);
const xml = await res.text();
const palettes: OfficialPalette[] = []
const palettes: OfficialPalette[] = [];
for (const pm of xml.matchAll(/<palette\b([^>]*)>([\s\S]*?)<\/palette>/g)) {
const id = num(attr(pm[1], 'id'))
const colors: OfficialColor[] = []
const id = num(attr(pm[1], "id"));
const colors: OfficialColor[] = [];
for (const cm of pm[2].matchAll(/<color\b([^>]*)>([^<]*)<\/color>/g)) {
colors.push({
id: num(attr(cm[1], 'id')),
index: num(attr(cm[1], 'index')),
club: num(attr(cm[1], 'club')),
selectable: bool(attr(cm[1], 'selectable')),
id: num(attr(cm[1], "id")),
index: num(attr(cm[1], "index")),
club: num(attr(cm[1], "club")),
selectable: bool(attr(cm[1], "selectable")),
hexCode: cm[2].trim(),
})
});
}
palettes.push({ id, colors })
palettes.push({ id, colors });
}
const setTypes: OfficialSetType[] = []
const setTypes: OfficialSetType[] = [];
for (const sm of xml.matchAll(/<settype\b([^>]*)>([\s\S]*?)<\/settype>/g)) {
const a = sm[1]
const sets: OfficialSet[] = []
const a = sm[1];
const sets: OfficialSet[] = [];
for (const setM of sm[2].matchAll(/<set\b([^>]*)>([\s\S]*?)<\/set>/g)) {
const sa = setM[1]
const parts: OfficialPart[] = []
const sa = setM[1];
const parts: OfficialPart[] = [];
for (const pm of setM[2].matchAll(/<part\b([^>]*)\/?>/g)) {
parts.push({
id: num(attr(pm[1], 'id')),
type: attr(pm[1], 'type') ?? '',
colorable: bool(attr(pm[1], 'colorable')),
index: num(attr(pm[1], 'index')),
colorindex: num(attr(pm[1], 'colorindex')),
})
id: num(attr(pm[1], "id")),
type: attr(pm[1], "type") ?? "",
colorable: bool(attr(pm[1], "colorable")),
index: num(attr(pm[1], "index")),
colorindex: num(attr(pm[1], "colorindex")),
});
}
const hiddenLayers: { partType: string }[] = []
const hiddenLayers: { partType: string }[] = [];
for (const hm of setM[2].matchAll(/<layer\b([^>]*)\/?>/g)) {
const pt = attr(hm[1], 'parttype')
if (pt) hiddenLayers.push({ partType: pt })
const pt = attr(hm[1], "parttype");
if (pt) hiddenLayers.push({ partType: pt });
}
sets.push({
id: num(attr(sa, 'id')),
gender: attr(sa, 'gender') ?? 'U',
club: num(attr(sa, 'club')),
colorable: bool(attr(sa, 'colorable')),
selectable: bool(attr(sa, 'selectable')),
preselectable: bool(attr(sa, 'preselectable')),
sellable: bool(attr(sa, 'sellable')),
id: num(attr(sa, "id")),
gender: attr(sa, "gender") ?? "U",
club: num(attr(sa, "club")),
colorable: bool(attr(sa, "colorable")),
selectable: bool(attr(sa, "selectable")),
preselectable: bool(attr(sa, "preselectable")),
sellable: bool(attr(sa, "sellable")),
parts,
hiddenLayers: hiddenLayers.length ? hiddenLayers : undefined,
})
});
}
setTypes.push({
type: attr(a, 'type') ?? '',
paletteId: num(attr(a, 'paletteid')),
mandM0: bool(attr(a, 'mand_m_0')),
mandF0: bool(attr(a, 'mand_f_0')),
mandM1: bool(attr(a, 'mand_m_1')),
mandF1: bool(attr(a, 'mand_f_1')),
type: attr(a, "type") ?? "",
paletteId: num(attr(a, "paletteid")),
mandM0: bool(attr(a, "mand_m_0")),
mandF0: bool(attr(a, "mand_f_0")),
mandM1: bool(attr(a, "mand_m_1")),
mandF1: bool(attr(a, "mand_f_1")),
sets,
})
});
}
const data = { palettes, setTypes }
fdCache = { data, ts: now }
return data
const data = { palettes, setTypes };
fdCache = { data, ts: now };
return data;
}
export async function fetchOfficialFigureMap(): Promise<OfficialLibrary[]> {
const now = Date.now()
if (fmCache && now - fmCache.ts < CACHE_TTL) return fmCache.libs
const override = ((await siteSettings.get('figure_swf_base_url', '')) ?? '').trim()
const base = override ? `${override.replace(/\/+$/, '')}/` : await resolveGordonBuildUrl()
const res = await fetch(`${base}figuremap.xml`, { signal: AbortSignal.timeout(30000) })
if (!res.ok) throw new Error(`figuremap fetch failed: ${res.status}`)
const xml = await res.text()
const libs: OfficialLibrary[] = []
const now = Date.now();
if (fmCache && now - fmCache.ts < CACHE_TTL) return fmCache.libs;
const override = ((await siteSettings.get("figure_swf_base_url", "")) ?? "").trim();
const base = override ? `${override.replace(/\/+$/, "")}/` : await resolveGordonBuildUrl();
const res = await fetch(`${base}figuremap.xml`, { signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`figuremap fetch failed: ${res.status}`);
const xml = await res.text();
const libs: OfficialLibrary[] = [];
for (const lm of xml.matchAll(/<lib\b([^>]*)>([\s\S]*?)<\/lib>/g)) {
const parts: { id: number; type: string }[] = []
const parts: { id: number; type: string }[] = [];
for (const pm of lm[2].matchAll(/<part\b([^>]*)\/?>/g)) {
parts.push({ id: num(attr(pm[1], 'id')), type: attr(pm[1], 'type') ?? '' })
parts.push({ id: num(attr(pm[1], "id")), type: attr(pm[1], "type") ?? "" });
}
libs.push({ id: attr(lm[1], 'id') ?? '', revision: num(attr(lm[1], 'revision')), parts })
libs.push({ id: attr(lm[1], "id") ?? "", revision: num(attr(lm[1], "revision")), parts });
}
fmCache = { libs, ts: now }
return libs
fmCache = { libs, ts: now };
return libs;
}
+6 -6
View File
@@ -1,9 +1,9 @@
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
import { siteSettings } from '@/lib/services/site-settings'
import { resolveGordonBuildUrl } from "@/lib/services/import/core/gordon";
import { siteSettings } from "@/lib/services/site-settings";
export async function resolveFigureSwfUrl(lib: string): Promise<string> {
const override = ((await siteSettings.get('figure_swf_base_url', '')) ?? '').trim()
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
const gordon = await resolveGordonBuildUrl()
return `${gordon}${lib}.swf`
const override = ((await siteSettings.get("figure_swf_base_url", "")) ?? "").trim();
if (override) return `${override.replace(/\/+$/, "")}/${lib}.swf`;
const gordon = await resolveGordonBuildUrl();
return `${gordon}${lib}.swf`;
}
+41 -41
View File
@@ -1,81 +1,81 @@
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
import { resolveGamedataFile } from "@/lib/services/import/core/asset-paths";
import {
readGamedataJson,
withGamedataLock,
writeGamedataJsonAtomic,
} from '@/lib/services/import/core/gamedata-json'
import { siteSettings } from '@/lib/services/site-settings'
} from "@/lib/services/import/core/gamedata-json";
import { siteSettings } from "@/lib/services/site-settings";
interface FdSet {
id: number
[k: string]: unknown
id: number;
[k: string]: unknown;
}
interface FdSetType {
type: string
paletteId: number
sets: FdSet[]
[k: string]: unknown
type: string;
paletteId: number;
sets: FdSet[];
[k: string]: unknown;
}
interface FdPalette {
id: number
colors: unknown[]
id: number;
colors: unknown[];
}
interface FigureDataFile {
palettes: FdPalette[]
setTypes: FdSetType[]
palettes: FdPalette[];
setTypes: FdSetType[];
}
let pathOverride: string | null = null
let pathOverride: string | null = null;
export function __setFigureDataPathForTests(p: string | null): void {
pathOverride = p
pathOverride = p;
}
export async function getFigureDataPath(): Promise<string> {
if (pathOverride) return pathOverride
const url = await siteSettings.get('figuredata_url', '/nitro-assets/gamedata/FigureData.json')
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/FigureData.json', 'FigureData.json')
if (pathOverride) return pathOverride;
const url = await siteSettings.get("figuredata_url", "/nitro-assets/gamedata/FigureData.json");
return resolveGamedataFile(url ?? "/nitro-assets/gamedata/FigureData.json", "FigureData.json");
}
export async function readFigureData(): Promise<FigureDataFile> {
const data = await readGamedataJson<Partial<FigureDataFile>>(await getFigureDataPath())
return { palettes: data.palettes ?? [], setTypes: data.setTypes ?? [] }
const data = await readGamedataJson<Partial<FigureDataFile>>(await getFigureDataPath());
return { palettes: data.palettes ?? [], setTypes: data.setTypes ?? [] };
}
export async function listLocalSetIds(setType: string): Promise<Set<number>> {
const fd = await readFigureData()
const st = fd.setTypes.find((s) => s.type === setType)
return new Set((st?.sets ?? []).map((s) => s.id))
const fd = await readFigureData();
const st = fd.setTypes.find((s) => s.type === setType);
return new Set((st?.sets ?? []).map((s) => s.id));
}
export async function mergeSet(setType: string, paletteId: number, set: FdSet): Promise<void> {
const file = await getFigureDataPath()
const file = await getFigureDataPath();
await withGamedataLock(file, async () => {
const fd = await readGamedataJson<FigureDataFile>(file)
fd.setTypes ??= []
let st = fd.setTypes.find((s) => s.type === setType)
const fd = await readGamedataJson<FigureDataFile>(file);
fd.setTypes ??= [];
let st = fd.setTypes.find((s) => s.type === setType);
if (!st) {
st = { type: setType, paletteId, sets: [] }
fd.setTypes.push(st)
st = { type: setType, paletteId, sets: [] };
fd.setTypes.push(st);
}
st.sets ??= []
const idx = st.sets.findIndex((s) => s.id === set.id)
st.sets ??= [];
const idx = st.sets.findIndex((s) => s.id === set.id);
if (idx >= 0) {
st.sets[idx] = set
st.sets[idx] = set;
} else {
st.sets.push(set)
st.sets.push(set);
}
await writeGamedataJsonAtomic(file, fd)
})
await writeGamedataJsonAtomic(file, fd);
});
}
export async function mergePalette(palette: FdPalette): Promise<void> {
const file = await getFigureDataPath()
const file = await getFigureDataPath();
await withGamedataLock(file, async () => {
const fd = await readGamedataJson<FigureDataFile>(file)
fd.palettes ??= []
const fd = await readGamedataJson<FigureDataFile>(file);
fd.palettes ??= [];
if (!fd.palettes.some((p) => p.id === palette.id)) {
fd.palettes.push(palette)
await writeGamedataJsonAtomic(file, fd)
fd.palettes.push(palette);
await writeGamedataJsonAtomic(file, fd);
}
})
});
}
+23 -23
View File
@@ -1,49 +1,49 @@
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
import { resolveGamedataFile } from "@/lib/services/import/core/asset-paths";
import {
readGamedataJson,
withGamedataLock,
writeGamedataJsonAtomic,
} from '@/lib/services/import/core/gamedata-json'
import { siteSettings } from '@/lib/services/site-settings'
} from "@/lib/services/import/core/gamedata-json";
import { siteSettings } from "@/lib/services/site-settings";
export interface FigureLibrary {
id: string
revision: number
parts?: Array<{ id: number; type: string }>
id: string;
revision: number;
parts?: Array<{ id: number; type: string }>;
}
interface FigureMapFile {
libraries: FigureLibrary[]
libraries: FigureLibrary[];
}
let pathOverride: string | null = null
let pathOverride: string | null = null;
export function __setFigureMapPathForTests(p: string | null): void {
pathOverride = p
pathOverride = p;
}
export async function getFigureMapPath(): Promise<string> {
if (pathOverride) return pathOverride
const url = await siteSettings.get('figuremap_url', '/nitro-assets/gamedata/FigureMap.json')
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/FigureMap.json', 'FigureMap.json')
if (pathOverride) return pathOverride;
const url = await siteSettings.get("figuremap_url", "/nitro-assets/gamedata/FigureMap.json");
return resolveGamedataFile(url ?? "/nitro-assets/gamedata/FigureMap.json", "FigureMap.json");
}
export async function listFigureLibraries(): Promise<FigureLibrary[]> {
const file = await getFigureMapPath()
const data = await readGamedataJson<Partial<FigureMapFile>>(file)
return Array.isArray(data.libraries) ? data.libraries : []
const file = await getFigureMapPath();
const data = await readGamedataJson<Partial<FigureMapFile>>(file);
return Array.isArray(data.libraries) ? data.libraries : [];
}
export async function mergeLibrary(lib: FigureLibrary): Promise<void> {
const file = await getFigureMapPath()
const file = await getFigureMapPath();
await withGamedataLock(file, async () => {
const data = await readGamedataJson<{ libraries?: FigureLibrary[] }>(file)
const libraries = Array.isArray(data.libraries) ? data.libraries : []
const idx = libraries.findIndex((l) => l.id === lib.id)
const data = await readGamedataJson<{ libraries?: FigureLibrary[] }>(file);
const libraries = Array.isArray(data.libraries) ? data.libraries : [];
const idx = libraries.findIndex((l) => l.id === lib.id);
if (idx >= 0) {
libraries[idx] = lib
libraries[idx] = lib;
} else {
libraries.push(lib)
libraries.push(lib);
}
await writeGamedataJsonAtomic(file, { ...data, libraries })
})
await writeGamedataJsonAtomic(file, { ...data, libraries });
});
}
+57 -57
View File
@@ -1,110 +1,110 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { siteSettings } from '@/lib/services/site-settings'
import { existsSync } from "node:fs";
import path from "node:path";
import { siteSettings } from "@/lib/services/site-settings";
export const DEFAULT_NITRO_FILES_ROOT = path.join(
/*turbopackIgnore: true*/ path.dirname(process.cwd()),
'DEV',
'Nitro-Files',
)
"DEV",
"Nitro-Files",
);
export const DEFAULT_FURNI_ICON_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/swf/dcr/hof_furni/icons',
)
"public/swf/dcr/hof_furni/icons",
);
export const DEFAULT_FURNI_SWF_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/swf/dcr/hof_furni/swf',
)
"public/swf/dcr/hof_furni/swf",
);
export const DEFAULT_FURNI_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/furniture',
)
"public/nitro-assets/bundled/furniture",
);
export interface FurniAssetDirs {
swfDir: string
iconDir: string
nitroDir: string
swfDir: string;
iconDir: string;
nitroDir: string;
}
export interface FurniAssetWriteTargets extends FurniAssetDirs {
mirrorDirs: FurniAssetDirs[]
mirrorDirs: FurniAssetDirs[];
}
function normalizeDir(value: string | undefined, fallback: string): string {
const raw = value?.trim() || fallback
let normalized = path.normalize(raw)
const root = path.parse(normalized).root
const raw = value?.trim() || fallback;
let normalized = path.normalize(raw);
const root = path.parse(normalized).root;
while (normalized.length > root.length && /[\\/]$/.test(normalized)) {
normalized = normalized.slice(0, -1)
normalized = normalized.slice(0, -1);
}
return normalized
return normalized;
}
function uniqueDirs(dirs: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
const seen = new Set<string>();
const out: string[] = [];
for (const dir of dirs) {
const normalized = normalizeDir(dir, dir)
const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized
if (seen.has(key)) continue
seen.add(key)
out.push(normalized)
const normalized = normalizeDir(dir, dir);
const key = process.platform === "win32" ? normalized.toLowerCase() : normalized;
if (seen.has(key)) continue;
seen.add(key);
out.push(normalized);
}
return out
return out;
}
export async function getNitroFilesRoot(): Promise<string> {
const configured = (await siteSettings.get('nitro_files_root', '')) ?? ''
if (configured.trim()) return normalizeDir(configured, configured)
const configured = (await siteSettings.get("nitro_files_root", "")) ?? "";
if (configured.trim()) return normalizeDir(configured, configured);
return existsSync(/*turbopackIgnore: true*/ DEFAULT_NITRO_FILES_ROOT)
? normalizeDir(DEFAULT_NITRO_FILES_ROOT, DEFAULT_NITRO_FILES_ROOT)
: ''
: "";
}
function dirsFromNitroFilesRoot(root: string): FurniAssetDirs {
return {
swfDir: path.join(/*turbopackIgnore: true*/ root, 'swf/dcr/hof_furni'),
iconDir: path.join(/*turbopackIgnore: true*/ root, 'swf/dcr/hof_furni/icons'),
nitroDir: path.join(/*turbopackIgnore: true*/ root, 'nitro-assets/bundled/furniture'),
}
swfDir: path.join(/*turbopackIgnore: true*/ root, "swf/dcr/hof_furni"),
iconDir: path.join(/*turbopackIgnore: true*/ root, "swf/dcr/hof_furni/icons"),
nitroDir: path.join(/*turbopackIgnore: true*/ root, "nitro-assets/bundled/furniture"),
};
}
export async function getFurniAssetDirs(): Promise<FurniAssetDirs> {
const [swfDir, iconDir, nitroDir] = await Promise.all([
siteSettings.get('furni_swf_dir', DEFAULT_FURNI_SWF_DIR).then((v) => v ?? DEFAULT_FURNI_SWF_DIR),
siteSettings.get('furni_icon_dir', DEFAULT_FURNI_ICON_DIR).then((v) => v ?? DEFAULT_FURNI_ICON_DIR),
siteSettings.get('furni_nitro_dir', DEFAULT_FURNI_NITRO_DIR).then((v) => v ?? DEFAULT_FURNI_NITRO_DIR),
])
siteSettings.get("furni_swf_dir", DEFAULT_FURNI_SWF_DIR).then((v) => v ?? DEFAULT_FURNI_SWF_DIR),
siteSettings.get("furni_icon_dir", DEFAULT_FURNI_ICON_DIR).then((v) => v ?? DEFAULT_FURNI_ICON_DIR),
siteSettings.get("furni_nitro_dir", DEFAULT_FURNI_NITRO_DIR).then((v) => v ?? DEFAULT_FURNI_NITRO_DIR),
]);
return {
swfDir: normalizeDir(swfDir, DEFAULT_FURNI_SWF_DIR),
iconDir: normalizeDir(iconDir, DEFAULT_FURNI_ICON_DIR),
nitroDir: normalizeDir(nitroDir, DEFAULT_FURNI_NITRO_DIR),
}
};
}
export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets> {
const primary = await getFurniAssetDirs()
const nitroFilesRoot = await getNitroFilesRoot()
const mirrorCandidates = nitroFilesRoot ? [dirsFromNitroFilesRoot(nitroFilesRoot)] : []
const primary = await getFurniAssetDirs();
const nitroFilesRoot = await getNitroFilesRoot();
const mirrorCandidates = nitroFilesRoot ? [dirsFromNitroFilesRoot(nitroFilesRoot)] : [];
const primaryKey = {
swfDir: process.platform === 'win32' ? primary.swfDir.toLowerCase() : primary.swfDir,
iconDir: process.platform === 'win32' ? primary.iconDir.toLowerCase() : primary.iconDir,
nitroDir: process.platform === 'win32' ? primary.nitroDir.toLowerCase() : primary.nitroDir,
}
swfDir: process.platform === "win32" ? primary.swfDir.toLowerCase() : primary.swfDir,
iconDir: process.platform === "win32" ? primary.iconDir.toLowerCase() : primary.iconDir,
nitroDir: process.platform === "win32" ? primary.nitroDir.toLowerCase() : primary.nitroDir,
};
const mirrorDirs = mirrorCandidates.filter((dirs) => {
const swfDir = normalizeDir(dirs.swfDir, dirs.swfDir)
const iconDir = normalizeDir(dirs.iconDir, dirs.iconDir)
const nitroDir = normalizeDir(dirs.nitroDir, dirs.nitroDir)
const swfDir = normalizeDir(dirs.swfDir, dirs.swfDir);
const iconDir = normalizeDir(dirs.iconDir, dirs.iconDir);
const nitroDir = normalizeDir(dirs.nitroDir, dirs.nitroDir);
return (
(process.platform === 'win32' ? swfDir.toLowerCase() : swfDir) !== primaryKey.swfDir ||
(process.platform === 'win32' ? iconDir.toLowerCase() : iconDir) !== primaryKey.iconDir ||
(process.platform === 'win32' ? nitroDir.toLowerCase() : nitroDir) !== primaryKey.nitroDir
)
})
(process.platform === "win32" ? swfDir.toLowerCase() : swfDir) !== primaryKey.swfDir ||
(process.platform === "win32" ? iconDir.toLowerCase() : iconDir) !== primaryKey.iconDir ||
(process.platform === "win32" ? nitroDir.toLowerCase() : nitroDir) !== primaryKey.nitroDir
);
});
return {
...primary,
@@ -113,5 +113,5 @@ export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTarget
iconDir: uniqueDirs([dirs.iconDir])[0],
nitroDir: uniqueDirs([dirs.nitroDir])[0],
})),
}
};
}
+113 -136
View File
@@ -1,11 +1,11 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { getNitroFilesRoot } from '@/lib/services/furni-asset-dirs'
import type { HabboItFurniEntry } from '@/lib/services/habbo-furnidata-cache'
import { siteSettings } from '@/lib/services/site-settings'
import type { FurniMetadata } from '@/lib/services/swf-to-nitro'
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { getNitroFilesRoot } from "@/lib/services/furni-asset-dirs";
import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache";
import { siteSettings } from "@/lib/services/site-settings";
import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
const LOCK_STALE_MS = 60_000
const LOCK_STALE_MS = 60_000;
/**
* Resolve the FurnitureData file path from the admin setting
@@ -14,203 +14,188 @@ const LOCK_STALE_MS = 60_000
* is unset.
*/
export async function getFurnitureDataPath(): Promise<string> {
const url = (await siteSettings.get('furni_data_url', '/nitro-assets/config/FurnitureData.json5')) ?? '/nitro-assets/config/FurnitureData.json5'
if (path.isAbsolute(url) && !url.startsWith('/')) return url
const relative = url.replace(/^\/+/, '')
const url =
(await siteSettings.get("furni_data_url", "/nitro-assets/config/FurnitureData.json5")) ??
"/nitro-assets/config/FurnitureData.json5";
if (path.isAbsolute(url) && !url.startsWith("/")) return url;
const relative = url.replace(/^\/+/, "");
// The `relative` segment is admin-configurable so its concrete value is
// unknown at build time; without the hint Turbopack traces the entire
// public/ tree (~400k files) into the route's NFT.
return path.join(/*turbopackIgnore: true*/ process.cwd(), 'public', relative)
return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative);
}
async function getFurnitureDataWritePaths(): Promise<string[]> {
const primary = await getFurnitureDataPath()
const configuredMirror = (await siteSettings.get('furni_data_mirror_path', '')) ?? ''
const nitroFilesRoot = await getNitroFilesRoot()
const primary = await getFurnitureDataPath();
const configuredMirror = (await siteSettings.get("furni_data_mirror_path", "")) ?? "";
const nitroFilesRoot = await getNitroFilesRoot();
const mirror = configuredMirror.trim()
? configuredMirror
: nitroFilesRoot
? path.join(
/*turbopackIgnore: true*/ nitroFilesRoot,
'nitro-assets/gamedata/FurnitureData.json',
)
: ''
? path.join(/*turbopackIgnore: true*/ nitroFilesRoot, "nitro-assets/gamedata/FurnitureData.json")
: "";
const paths = [primary, mirror].filter(Boolean).map((p) => path.normalize(p))
const seen = new Set<string>()
const paths = [primary, mirror].filter(Boolean).map((p) => path.normalize(p));
const seen = new Set<string>();
return paths.filter((p) => {
const key = process.platform === 'win32' ? p.toLowerCase() : p
if (seen.has(key)) return false
seen.add(key)
return true
})
const key = process.platform === "win32" ? p.toLowerCase() : p;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function getFurnitureDataReadPath(): Promise<string> {
const paths = await getFurnitureDataWritePaths()
const existing = paths.find((p) => existsSync(/*turbopackIgnore: true*/ p))
return existing ?? paths[0]
const paths = await getFurnitureDataWritePaths();
const existing = paths.find((p) => existsSync(/*turbopackIgnore: true*/ p));
return existing ?? paths[0];
}
// ── FurnitureData.json lock + helpers ────────────────────────────────
let furniDataLock: Promise<void> = Promise.resolve()
let furniDataLock: Promise<void> = Promise.resolve();
async function acquireDiskLock(lockPath: string): Promise<void> {
const deadline = Date.now() + 30_000
const deadline = Date.now() + 30_000;
while (true) {
try {
const fh = await fs.open(lockPath, 'wx')
await fh.writeFile(String(process.pid))
await fh.close()
return
const fh = await fs.open(lockPath, "wx");
await fh.writeFile(String(process.pid));
await fh.close();
return;
} catch (err) {
const e = err as NodeJS.ErrnoException
if (e.code !== 'EEXIST') throw err
const e = err as NodeJS.ErrnoException;
if (e.code !== "EEXIST") throw err;
try {
const stat = await fs.stat(lockPath)
const stat = await fs.stat(lockPath);
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
await fs.unlink(lockPath).catch(() => {})
continue
await fs.unlink(lockPath).catch(() => {});
continue;
}
} catch {
/* lock vanished between checks */
}
if (Date.now() > deadline) throw new Error('FurnitureData lock timeout')
await new Promise((r) => setTimeout(r, 100))
if (Date.now() > deadline) throw new Error("FurnitureData lock timeout");
await new Promise((r) => setTimeout(r, 100));
}
}
}
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
let release: () => void
let release: () => void;
const acquired = new Promise<void>((r) => {
release = r
})
const prev = furniDataLock
furniDataLock = acquired
await prev
const lockBase = await getFurnitureDataReadPath()
await fs.mkdir(path.dirname(lockBase), { recursive: true })
const lockPath = `${lockBase}.lock`
await acquireDiskLock(lockPath)
release = r;
});
const prev = furniDataLock;
furniDataLock = acquired;
await prev;
const lockBase = await getFurnitureDataReadPath();
await fs.mkdir(path.dirname(lockBase), { recursive: true });
const lockPath = `${lockBase}.lock`;
await acquireDiskLock(lockPath);
try {
return await fn()
return await fn();
} finally {
await fs.unlink(lockPath).catch(() => {})
release!()
await fs.unlink(lockPath).catch(() => {});
release!();
}
}
export async function readFurniData(): Promise<Record<string, unknown>> {
const target = await getFurnitureDataReadPath()
const raw = await fs.readFile(/*turbopackIgnore: true*/ target, 'utf-8')
return JSON.parse(raw)
const target = await getFurnitureDataReadPath();
const raw = await fs.readFile(/*turbopackIgnore: true*/ target, "utf-8");
return JSON.parse(raw);
}
export async function writeFurniData(data: Record<string, unknown>): Promise<void> {
const targets = await getFurnitureDataWritePaths()
const payload = JSON.stringify(data)
const targets = await getFurnitureDataWritePaths();
const payload = JSON.stringify(data);
for (const target of targets) {
await fs.mkdir(path.dirname(target), { recursive: true })
const tmp = `${target}.tmp`
await fs.writeFile(/*turbopackIgnore: true*/ tmp, payload, 'utf-8')
await fs.rename(/*turbopackIgnore: true*/ tmp, target)
await fs.mkdir(path.dirname(target), { recursive: true });
const tmp = `${target}.tmp`;
await fs.writeFile(/*turbopackIgnore: true*/ tmp, payload, "utf-8");
await fs.rename(/*turbopackIgnore: true*/ tmp, target);
}
}
export function buildFurniEntry(params: {
id: number
classname: string
revision: number
category: string
name: string
description: string
spriteId: number
dims: { x: number; y: number; z: number }
metadata: FurniMetadata | null
habboIt?: HabboItFurniEntry | null
id: number;
classname: string;
revision: number;
category: string;
name: string;
description: string;
spriteId: number;
dims: { x: number; y: number; z: number };
metadata: FurniMetadata | null;
habboIt?: HabboItFurniEntry | null;
}): Record<string, unknown> {
const {
id,
classname,
revision,
category,
name,
description,
spriteId,
dims,
metadata,
habboIt,
} = params
const { id, classname, revision, category, name, description, spriteId, dims, metadata, habboIt } = params;
// Merge partcolors: prefer habbo.it (has real hex colors), fallback to .nitro extraction
const partcolors = habboIt?.partcolors?.color?.length
? habboIt.partcolors
: (metadata?.partcolors ?? { color: [] })
: (metadata?.partcolors ?? { color: [] });
return {
id,
classname,
revision: revision || habboIt?.revision || 0,
category: category || habboIt?.category || 'unknown',
category: category || habboIt?.category || "unknown",
defaultdir: metadata?.defaultdir ?? habboIt?.defaultdir ?? 0,
xdim: dims.x,
ydim: dims.y,
partcolors,
name,
description: description || '',
adurl: '',
description: description || "",
adurl: "",
offerid: spriteId,
buyout: !!habboIt?.bc,
rentofferid: -1,
rentbuyout: false,
bc: habboIt?.bc ?? false,
excludeddynamic: false,
customparams: habboIt?.customparams ?? '',
customparams: habboIt?.customparams ?? "",
specialtype: metadata?.specialtype ?? habboIt?.specialtype ?? 0,
canstandon: metadata?.canstandon ?? habboIt?.canstandon ?? false,
cansiton: metadata?.cansiton ?? habboIt?.cansiton ?? false,
canlayon: metadata?.canlayon ?? habboIt?.canlayon ?? false,
furniline: habboIt?.furniline ?? '',
environment: habboIt?.environment ?? '',
furniline: habboIt?.furniline ?? "",
environment: habboIt?.environment ?? "",
rare: habboIt?.rare ?? false,
}
};
}
function upsertEntryInSection(
section: { furnitype: Array<Record<string, unknown>> },
entry: Record<string, unknown>,
) {
const classname = entry.classname as string | undefined
const classname = entry.classname as string | undefined;
if (classname) {
const idx = section.furnitype.findIndex((e) => e.classname === classname)
const idx = section.furnitype.findIndex((e) => e.classname === classname);
if (idx >= 0) {
section.furnitype[idx] = entry
return
section.furnitype[idx] = entry;
return;
}
}
section.furnitype.push(entry)
section.furnitype.push(entry);
}
export async function appendFurniEntry(
entry: Record<string, unknown>,
itemType: string,
): Promise<void> {
export async function appendFurniEntry(entry: Record<string, unknown>, itemType: string): Promise<void> {
await withFurniDataLock(async () => {
const furniData = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>
if (itemType === 'i') {
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] }
upsertEntryInSection(furniData.wallitemtypes, entry)
>;
if (itemType === "i") {
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] };
upsertEntryInSection(furniData.wallitemtypes, entry);
} else {
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] }
upsertEntryInSection(furniData.roomitemtypes, entry)
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] };
upsertEntryInSection(furniData.roomitemtypes, entry);
}
await writeFurniData(furniData as Record<string, unknown>)
})
await writeFurniData(furniData as Record<string, unknown>);
});
}
export async function removeFurniEntry(classname: string): Promise<boolean> {
@@ -218,46 +203,38 @@ export async function removeFurniEntry(classname: string): Promise<boolean> {
const furniData = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>
let removed = false
>;
let removed = false;
for (const section of ['roomitemtypes', 'wallitemtypes'] as const) {
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
if (furniData[section]?.furnitype) {
const before = furniData[section].furnitype.length
furniData[section].furnitype = furniData[section].furnitype.filter(
(e) => e.classname !== classname,
)
if (furniData[section].furnitype.length < before) removed = true
const before = furniData[section].furnitype.length;
furniData[section].furnitype = furniData[section].furnitype.filter((e) => e.classname !== classname);
if (furniData[section].furnitype.length < before) removed = true;
}
}
if (removed) {
await writeFurniData(furniData as Record<string, unknown>)
await writeFurniData(furniData as Record<string, unknown>);
}
return removed
})
return removed;
});
}
export async function appendFurniEntriesBatch(
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
): Promise<void> {
await withFurniDataLock(async () => {
const furniData = (await readFurniData()) as Record<string, { furnitype: unknown[] }>
const furniData = (await readFurniData()) as Record<string, { furnitype: unknown[] }>;
for (const { entry, itemType } of entries) {
if (itemType === 'i') {
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] }
upsertEntryInSection(
furniData.wallitemtypes as { furnitype: Array<Record<string, unknown>> },
entry,
)
if (itemType === "i") {
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] };
upsertEntryInSection(furniData.wallitemtypes as { furnitype: Array<Record<string, unknown>> }, entry);
} else {
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] }
upsertEntryInSection(
furniData.roomitemtypes as { furnitype: Array<Record<string, unknown>> },
entry,
)
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] };
upsertEntryInSection(furniData.roomitemtypes as { furnitype: Array<Record<string, unknown>> }, entry);
}
}
await writeFurniData(furniData as Record<string, unknown>)
})
await writeFurniData(furniData as Record<string, unknown>);
});
}
+21 -21
View File
@@ -1,46 +1,46 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from "vitest";
vi.mock('@/lib/prisma', () => ({
vi.mock("@/lib/prisma", () => ({
prisma: {},
}))
}));
vi.mock('@/lib/services/furni-data', () => ({
vi.mock("@/lib/services/furni-data", () => ({
appendFurniEntry: vi.fn(),
buildFurniEntry: vi.fn(),
removeFurniEntry: vi.fn(),
}))
}));
vi.mock('@/lib/services/habbo-furnidata-cache', () => ({
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
lookupHabboIt: vi.fn(),
}))
}));
vi.mock('@/lib/services/import/core/download', () => ({
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(),
validatePngBytes: vi.fn(),
validateSwfBytes: vi.fn(),
}))
}));
vi.mock('@/lib/services/swf-to-nitro', () => ({
vi.mock("@/lib/services/swf-to-nitro", () => ({
convertSwfToNitro: vi.fn(),
extractIconFromSwf: vi.fn(),
}))
}));
import { buildItemsBaseEnrichmentUpdate } from './furni-import'
import { buildItemsBaseEnrichmentUpdate } from "./furni-import";
describe('buildItemsBaseEnrichmentUpdate', () => {
it('only updates columns present in the live Arcturus items_base schema', () => {
describe("buildItemsBaseEnrichmentUpdate", () => {
it("only updates columns present in the live Arcturus items_base schema", () => {
const update = buildItemsBaseEnrichmentUpdate({
id: 123,
dims: { x: 2, y: 3, z: 1.5 },
cansiton: true,
canlayon: false,
canstandon: true,
allowStack: '1',
interactionType: 'default',
})
allowStack: "1",
interactionType: "default",
});
expect(update.sql).not.toContain('specialtype')
expect(update.values).toEqual([2, 3, 1.5, '1', '0', '1', '1', 'default', 123])
})
})
expect(update.sql).not.toContain("specialtype");
expect(update.values).toEqual([2, 3, 1.5, "1", "0", "1", "1", "default", 123]);
});
});
+277 -288
View File
@@ -1,17 +1,13 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { prisma } from '@/lib/prisma'
import { appendFurniEntry, buildFurniEntry, removeFurniEntry } from '@/lib/services/furni-data'
import type { HabboItFurniEntry } from '@/lib/services/habbo-furnidata-cache'
import { lookupHabboIt } from '@/lib/services/habbo-furnidata-cache'
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { appendFurniEntry, buildFurniEntry, removeFurniEntry } from "@/lib/services/furni-data";
import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache";
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
// Re-export the download helpers now owned by the shared import core.
// Existing furni callers (and furni-repair) keep importing them from here.
export {
downloadFile,
validatePngBytes,
validateSwfBytes,
} from '@/lib/services/import/core/download'
export { downloadFile, validatePngBytes, validateSwfBytes } from "@/lib/services/import/core/download";
import {
DEFAULT_FURNI_ICON_DIR,
@@ -19,83 +15,81 @@ import {
DEFAULT_FURNI_SWF_DIR,
getFurniAssetDirs,
getFurniAssetWriteTargets,
} from '@/lib/services/furni-asset-dirs'
import { downloadFile } from '@/lib/services/import/core/download'
import type { ConversionResult } from '@/lib/services/swf-to-nitro'
import { convertSwfToNitro, extractIconFromSwf } from '@/lib/services/swf-to-nitro'
import type { ImportSingleResult } from '@/types/furni'
} from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download";
import type { ConversionResult } from "@/lib/services/swf-to-nitro";
import { convertSwfToNitro, extractIconFromSwf } from "@/lib/services/swf-to-nitro";
import type { ImportSingleResult } from "@/types/furni";
// ── Constants ────────────────────────────────────────────────────────
// turbopackIgnore hints: this module is reached by App Router routes; without
// them Turbopack's NFT tracer marks the whole project as a dependency because
// it can't prove the dynamic suffixes (file names) are bounded to the prefix.
export const FURNI_ICON_DIR = DEFAULT_FURNI_ICON_DIR
export const FURNI_SWF_DIR = DEFAULT_FURNI_SWF_DIR
export const FURNI_NITRO_DIR = DEFAULT_FURNI_NITRO_DIR
export const FURNI_ICON_DIR = DEFAULT_FURNI_ICON_DIR;
export const FURNI_SWF_DIR = DEFAULT_FURNI_SWF_DIR;
export const FURNI_NITRO_DIR = DEFAULT_FURNI_NITRO_DIR;
export const IMPORTED_PAGE_CAPTION_SAVE = 'imported_furni'
export const IMPORTED_PAGE_CAPTION = 'Imported Furniture'
export const IMPORTED_PAGE_CAPTION_SAVE = "imported_furni";
export const IMPORTED_PAGE_CAPTION = "Imported Furniture";
// Catalog sub-page definitions (key = internal id used for caption_save `imp_<key>`)
export const CATEGORY_PAGE: Record<string, { label: string; icon: number; order: number }> = {
credits: { label: 'Credits & Exchange', icon: 263, order: 1 },
rares: { label: 'Rares', icon: 11, order: 2 },
wired: { label: 'Wired', icon: 1, order: 3 },
nft: { label: 'NFT', icon: 12, order: 4 },
seasonal: { label: 'Seasonal', icon: 10, order: 5 },
music: { label: 'Music & Sound', icon: 13, order: 6 },
clothing: { label: 'Clothing', icon: 3, order: 7 },
builders: { label: 'Builders Club', icon: 7, order: 8 },
walls: { label: 'Wall Items', icon: 5, order: 9 },
promo: { label: 'Promo & Custom', icon: 2, order: 10 },
trophies: { label: 'Trophies', icon: 6, order: 11 },
pets: { label: 'Pets', icon: 4, order: 12 },
room: { label: 'Room & Building', icon: 8, order: 13 },
other: { label: 'Other', icon: 1, order: 99 },
}
credits: { label: "Credits & Exchange", icon: 263, order: 1 },
rares: { label: "Rares", icon: 11, order: 2 },
wired: { label: "Wired", icon: 1, order: 3 },
nft: { label: "NFT", icon: 12, order: 4 },
seasonal: { label: "Seasonal", icon: 10, order: 5 },
music: { label: "Music & Sound", icon: 13, order: 6 },
clothing: { label: "Clothing", icon: 3, order: 7 },
builders: { label: "Builders Club", icon: 7, order: 8 },
walls: { label: "Wall Items", icon: 5, order: 9 },
promo: { label: "Promo & Custom", icon: 2, order: 10 },
trophies: { label: "Trophies", icon: 6, order: 11 },
pets: { label: "Pets", icon: 4, order: 12 },
room: { label: "Room & Building", icon: 8, order: 13 },
other: { label: "Other", icon: 1, order: 99 },
};
// Classname prefix → category key (order matters: first match wins)
export const CLASSNAME_RULES: Array<{ test: (cn: string, type: string) => boolean; cat: string }> =
[
{ test: (cn) => /^CF_/i.test(cn) || /^exe_/i.test(cn) || cn === 'credit', cat: 'credits' },
{ test: (cn) => /^rare_/i.test(cn), cat: 'rares' },
{ test: (cn) => /^wf_/i.test(cn), cat: 'wired' },
{ test: (cn) => /^nft_/i.test(cn), cat: 'nft' },
{
test: (cn) => /^(xmas|hween|easter|val_|valentine|hw_|newyear|summer|carnival)/i.test(cn),
cat: 'seasonal',
},
{ test: (cn) => /^(trax|jukebox|sound|music)/i.test(cn), cat: 'music' },
{ test: (cn) => /^(clothing|clothes)/i.test(cn), cat: 'clothing' },
{ test: (cn) => /^bc_/i.test(cn), cat: 'builders' },
{ test: (cn) => /^(poster|wall_|window_|wallpaper)/i.test(cn), cat: 'walls' },
{ test: (_cn, type) => type === 'wallitem' || type === 'i', cat: 'walls' },
{ test: (cn) => /^(ads_|cstm_)/i.test(cn), cat: 'promo' },
{ test: (cn) => /^(trophy|gold_|silver_|bronze_)/i.test(cn), cat: 'trophies' },
{
test: (cn) =>
/^(pet|dog|cat_|bird|horse|pig|monkey|frog|dragon|bear_|spider|bunny)/i.test(cn),
cat: 'pets',
},
{ test: (cn) => /^(room_|floor_|tile_|carpet|rug|door_|gate_|stair)/i.test(cn), cat: 'room' },
]
export const CLASSNAME_RULES: Array<{ test: (cn: string, type: string) => boolean; cat: string }> = [
{ test: (cn) => /^CF_/i.test(cn) || /^exe_/i.test(cn) || cn === "credit", cat: "credits" },
{ test: (cn) => /^rare_/i.test(cn), cat: "rares" },
{ test: (cn) => /^wf_/i.test(cn), cat: "wired" },
{ test: (cn) => /^nft_/i.test(cn), cat: "nft" },
{
test: (cn) => /^(xmas|hween|easter|val_|valentine|hw_|newyear|summer|carnival)/i.test(cn),
cat: "seasonal",
},
{ test: (cn) => /^(trax|jukebox|sound|music)/i.test(cn), cat: "music" },
{ test: (cn) => /^(clothing|clothes)/i.test(cn), cat: "clothing" },
{ test: (cn) => /^bc_/i.test(cn), cat: "builders" },
{ test: (cn) => /^(poster|wall_|window_|wallpaper)/i.test(cn), cat: "walls" },
{ test: (_cn, type) => type === "wallitem" || type === "i", cat: "walls" },
{ test: (cn) => /^(ads_|cstm_)/i.test(cn), cat: "promo" },
{ test: (cn) => /^(trophy|gold_|silver_|bronze_)/i.test(cn), cat: "trophies" },
{
test: (cn) => /^(pet|dog|cat_|bird|horse|pig|monkey|frog|dragon|bear_|spider|bunny)/i.test(cn),
cat: "pets",
},
{ test: (cn) => /^(room_|floor_|tile_|carpet|rug|door_|gate_|stair)/i.test(cn), cat: "room" },
];
// ── Helpers ──────────────────────────────────────────────────────────
export async function ensureDirectories(): Promise<void> {
const targets = await getFurniAssetWriteTargets()
const targets = await getFurniAssetWriteTargets();
const dirs = [
targets.swfDir,
targets.iconDir,
targets.nitroDir,
...targets.mirrorDirs.flatMap((d) => [d.swfDir, d.iconDir, d.nitroDir]),
]
];
await Promise.all(
dirs
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
.map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })),
)
);
}
async function mirrorFileToDirs(
@@ -104,31 +98,31 @@ async function mirrorFileToDirs(
dirs: string[],
warnings?: string[],
): Promise<void> {
if (!existsSync(/*turbopackIgnore: true*/ sourcePath)) return
if (!existsSync(/*turbopackIgnore: true*/ sourcePath)) return;
for (const dir of dirs) {
const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName)
const sourceKey = process.platform === 'win32' ? sourcePath.toLowerCase() : sourcePath
const targetKey = process.platform === 'win32' ? targetPath.toLowerCase() : targetPath
if (sourceKey === targetKey) continue
const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName);
const sourceKey = process.platform === "win32" ? sourcePath.toLowerCase() : sourcePath;
const targetKey = process.platform === "win32" ? targetPath.toLowerCase() : targetPath;
if (sourceKey === targetKey) continue;
try {
await fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })
await fs.copyFile(/*turbopackIgnore: true*/ sourcePath, /*turbopackIgnore: true*/ targetPath)
await fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true });
await fs.copyFile(/*turbopackIgnore: true*/ sourcePath, /*turbopackIgnore: true*/ targetPath);
} catch (err) {
warnings?.push(`Mirror copy failed for ${fileName}: ${(err as Error).message}`)
warnings?.push(`Mirror copy failed for ${fileName}: ${(err as Error).message}`);
}
}
}
export function buildItemsBaseEnrichmentUpdate(params: {
id: number
dims: { x: number; y: number; z: number }
cansiton: boolean
canlayon: boolean
canstandon: boolean
allowStack: string
interactionType: string
id: number;
dims: { x: number; y: number; z: number };
cansiton: boolean;
canlayon: boolean;
canstandon: boolean;
allowStack: string;
interactionType: string;
}): { sql: string; values: Array<number | string> } {
return {
sql: `
@@ -145,14 +139,14 @@ export function buildItemsBaseEnrichmentUpdate(params: {
params.dims.x,
params.dims.y,
params.dims.z,
params.cansiton ? '1' : '0',
params.canlayon ? '1' : '0',
params.canstandon ? '1' : '0',
params.cansiton ? "1" : "0",
params.canlayon ? "1" : "0",
params.canstandon ? "1" : "0",
params.allowStack,
params.interactionType,
params.id,
],
}
};
}
/** Get or create the root "Imported Furniture" parent page (no items directly on this page). */
@@ -160,31 +154,31 @@ export async function getOrCreateImportedParentPage(): Promise<number> {
const existing = await prisma.catalogPages.findFirst({
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE },
select: { id: true },
})
if (existing) return existing.id
});
if (existing) return existing.id;
// Use raw SQL to avoid ENUM mismatch (page_layout, visible, enabled)
// All string columns must be '' (not NULL) or the Java emulator may skip the page
await prisma.$executeRaw`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${IMPORTED_PAGE_CAPTION_SAVE}, ${IMPORTED_PAGE_CAPTION}, 'default_3x3', -1, '1', 9999, 263, '1', '1', '', '', '', '', '', '', '', '')
`
`;
const created = await prisma.catalogPages.findFirst({
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE },
select: { id: true },
})
});
if (!created) {
throw new Error('Failed to create "Imported Furniture" parent catalog page')
throw new Error('Failed to create "Imported Furniture" parent catalog page');
}
return created.id
return created.id;
}
/** Determine the catalog category for a furniture item based on its classname and type. */
export function classifyFurni(classname: string, itemType: string): string {
for (const rule of CLASSNAME_RULES) {
if (rule.test(classname, itemType)) return rule.cat
if (rule.test(classname, itemType)) return rule.cat;
}
return 'other'
return "other";
}
/**
@@ -198,44 +192,44 @@ export function classifyFurni(classname: string, itemType: string): string {
* Returns { credits, points, pointsType }
*/
export function autoPriceFurni(classname: string): {
credits: number
points: number
pointsType: number
credits: number;
points: number;
pointsType: number;
} {
// CF_<number>_xxx → credit furniture, price = the credit value
const cfMatch = classname.match(/^CF_(\d+)/i)
const cfMatch = classname.match(/^CF_(\d+)/i);
if (cfMatch) {
return { credits: parseInt(cfMatch[1], 10), points: 0, pointsType: 0 }
return { credits: parseInt(cfMatch[1], 10), points: 0, pointsType: 0 };
}
// exe_<number> → exchange items (diamonds/duckets)
const exeMatch = classname.match(/^exe_(\d+)/i)
const exeMatch = classname.match(/^exe_(\d+)/i);
if (exeMatch) {
return { credits: parseInt(exeMatch[1], 10), points: 0, pointsType: 0 }
return { credits: parseInt(exeMatch[1], 10), points: 0, pointsType: 0 };
}
// Rares
if (/^rare_/i.test(classname)) {
return { credits: 25, points: 0, pointsType: 0 }
return { credits: 25, points: 0, pointsType: 0 };
}
// Wired
if (/^wf_/i.test(classname)) {
return { credits: 5, points: 0, pointsType: 0 }
return { credits: 5, points: 0, pointsType: 0 };
}
// NFT items
if (/^nft_/i.test(classname)) {
return { credits: 10, points: 0, pointsType: 0 }
return { credits: 10, points: 0, pointsType: 0 };
}
// Trophies
if (/^(trophy|gold_|silver_|bronze_)/i.test(classname)) {
return { credits: 10, points: 0, pointsType: 0 }
return { credits: 10, points: 0, pointsType: 0 };
}
// Default
return { credits: 3, points: 0, pointsType: 0 }
return { credits: 3, points: 0, pointsType: 0 };
}
/**
@@ -245,71 +239,66 @@ export function autoPriceFurni(classname: string): {
* Prisma connection for the whole operation; the in-process mutex is enough
* for the single-server deployment and avoids pool-connection gymnastics.
*/
let catalogIdAllocChain: Promise<unknown> = Promise.resolve()
let catalogIdAllocChain: Promise<unknown> = Promise.resolve();
export async function allocateCatalogItemId<T>(
insertFn: (nextId: number) => Promise<T>,
): Promise<T> {
const prev = catalogIdAllocChain
let settle!: () => void
export async function allocateCatalogItemId<T>(insertFn: (nextId: number) => Promise<T>): Promise<T> {
const prev = catalogIdAllocChain;
let settle!: () => void;
catalogIdAllocChain = new Promise<void>((r) => {
settle = r
})
await prev.catch(() => {})
settle = r;
});
await prev.catch(() => {});
try {
const maxIdResult = await prisma.$queryRaw<Array<{ maxId: number | bigint | null }>>`
SELECT MAX(id) as maxId FROM catalog_items
`
const nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1
return await insertFn(nextId)
`;
const nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
return await insertFn(nextId);
} finally {
settle()
settle();
}
}
/** Get or create a category sub-page under the imported parent page. */
export async function getOrCreateCategoryPage(
classname: string,
itemType: string,
): Promise<number> {
const parentId = await getOrCreateImportedParentPage()
const catKey = classifyFurni(classname, itemType)
const captionSave = `imp_${catKey}`
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other
export async function getOrCreateCategoryPage(classname: string, itemType: string): Promise<number> {
const parentId = await getOrCreateImportedParentPage();
const catKey = classifyFurni(classname, itemType);
const captionSave = `imp_${catKey}`;
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other;
const existing = await prisma.catalogPages.findFirst({
where: { captionSave, parentId },
select: { id: true },
})
if (existing) return existing.id
});
if (existing) return existing.id;
await prisma.$executeRaw`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '')
`
`;
const created = await prisma.catalogPages.findFirst({
where: { captionSave, parentId },
select: { id: true },
})
});
if (!created) {
throw new Error(`Failed to create catalog sub-page "${captionSave}" under parent ${parentId}`)
throw new Error(`Failed to create catalog sub-page "${captionSave}" under parent ${parentId}`);
}
return created.id
return created.id;
}
// ── Import Single Furni ─────────────────────────────────────────────
export async function importSingleFurni(params: {
id: number
classname: string
name: string
description: string
type: string
revision: number
category: string
skipFurniDataWrite?: boolean
onProgress?: (status: string) => void
id: number;
classname: string;
name: string;
description: string;
type: string;
revision: number;
category: string;
skipFurniDataWrite?: boolean;
onProgress?: (status: string) => void;
}): Promise<ImportSingleResult> {
const {
id: originalId,
@@ -321,55 +310,55 @@ export async function importSingleFurni(params: {
category,
skipFurniDataWrite,
onProgress,
} = params
const warnings: string[] = []
} = params;
const warnings: string[] = [];
// Check if already exists by classname OR by spriteId (primary key collision).
const existsByName = await prisma.itemsBase.findFirst({
where: { itemName: classname },
select: { id: true },
})
});
if (existsByName) {
return {
ok: false,
warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
}
};
}
const existsById = await prisma.itemsBase.findUnique({
where: { id: originalId },
select: { itemName: true },
})
});
if (existsById) {
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${existsById.itemName}"`,
}
};
}
const itemType = type === 'wallitem' ? 'i' : 's'
const spriteId = originalId
const itemType = type === "wallitem" ? "i" : "s";
const spriteId = originalId;
// ── Cross-reference with habbo.it furnidata for enrichment ────────
let habboIt: HabboItFurniEntry | null = null
let habboIt: HabboItFurniEntry | null = null;
try {
habboIt = await lookupHabboIt(classname)
habboIt = await lookupHabboIt(classname);
if (habboIt) {
warnings.push(`Enriched from habbo.it: "${habboIt.name}"`)
warnings.push(`Enriched from habbo.it: "${habboIt.name}"`);
}
} catch {
// Non-blocking: habbo.it enrichment is optional
}
// Best values: use habbo.it furnidata for enrichment
const bestName = name || habboIt?.name || classname
const bestDescription = description || habboIt?.description || ''
const bestRevision = revision || habboIt?.revision || 0
const bestCategory = category || habboIt?.category || 'unknown'
const bestName = name || habboIt?.name || classname;
const bestDescription = description || habboIt?.description || "";
const bestRevision = revision || habboIt?.revision || 0;
const bestCategory = category || habboIt?.category || "unknown";
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.('writing_db')
onProgress?.("writing_db");
try {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below),
@@ -377,28 +366,28 @@ export async function importSingleFurni(params: {
await prisma.$executeRaw`
INSERT INTO items_base (id, sprite_id, public_name, item_name, type)
VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType})
`
`;
} catch (err) {
return { ok: false, warnings, error: `DB insert failed: ${(err as Error).message}` }
return { ok: false, warnings, error: `DB insert failed: ${(err as Error).message}` };
}
// ── Derive file-safe names ────────────────────────────────────────
const rev = revision || 0
const assetTargets = await getFurniAssetWriteTargets()
const { swfDir, iconDir, nitroDir } = assetTargets
const starIdx = classname.indexOf('*')
const hasColor = starIdx !== -1
const baseClassname = hasColor ? classname.substring(0, starIdx) : classname
const _colorNum = hasColor ? classname.substring(starIdx + 1) : '2'
const iconFileName = classname.replace(/\*/g, '_')
const safeSwfName = baseClassname
const safeNitroName = baseClassname
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${safeSwfName}.swf`)
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${safeNitroName}.nitro`)
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`)
const mirrorSwfDirs = assetTargets.mirrorDirs.map((d) => d.swfDir)
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir)
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir)
const rev = revision || 0;
const assetTargets = await getFurniAssetWriteTargets();
const { swfDir, iconDir, nitroDir } = assetTargets;
const starIdx = classname.indexOf("*");
const hasColor = starIdx !== -1;
const baseClassname = hasColor ? classname.substring(0, starIdx) : classname;
const _colorNum = hasColor ? classname.substring(starIdx + 1) : "2";
const iconFileName = classname.replace(/\*/g, "_");
const safeSwfName = baseClassname;
const safeNitroName = baseClassname;
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${safeSwfName}.swf`);
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${safeNitroName}.nitro`);
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`);
const mirrorSwfDirs = assetTargets.mirrorDirs.map((d) => d.swfDir);
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir);
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir);
// ── Download assets in parallel (with retry + validation) ────────
// Icon strategy matches Nitro's URL template:
@@ -406,104 +395,104 @@ export async function importSingleFurni(params: {
// For color variants (classname has `*`): try color-specific first,
// fall back to base-classname icon (Nitro reuses base for all colors
// when no color-specific icon exists).
onProgress?.('downloading')
const iconColorAware = classname.replace(/\*/g, '_')
onProgress?.("downloading");
const iconColorAware = classname.replace(/\*/g, "_");
const iconUrl = hasColor
? `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(iconColorAware)}_icon.png`
: `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`
: `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`;
const downloadResults = await Promise.allSettled([
downloadFile(iconUrl, iconPath, { validate: 'png' }),
downloadFile(iconUrl, iconPath, { validate: "png" }),
downloadFile(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`,
swfPath,
{ validate: 'swf' },
{ validate: "swf" },
),
])
]);
let iconOk = downloadResults[0].status === 'fulfilled' && downloadResults[0].value.ok
const swfOk = downloadResults[1].status === 'fulfilled' && downloadResults[1].value.ok
let iconOk = downloadResults[0].status === "fulfilled" && downloadResults[0].value.ok;
const swfOk = downloadResults[1].status === "fulfilled" && downloadResults[1].value.ok;
// If color-specific icon 404'd, try the base-classname icon.
if (!iconOk && hasColor) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`,
iconPath,
{ validate: 'png' },
)
if (dl.ok) iconOk = true
{ validate: "png" },
);
if (dl.ok) iconOk = true;
}
// ── Icon fallback: extract from SWF if download failed ────────────
if (!iconOk && (swfOk || existsSync(/*turbopackIgnore: true*/ swfPath))) {
try {
const swfBuf = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
const iconPng = extractIconFromSwf(swfBuf, baseClassname)
const swfBuf = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const iconPng = extractIconFromSwf(swfBuf, baseClassname);
if (iconPng) {
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, iconPng)
warnings.push('Icon extracted from SWF (external download failed)')
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, iconPng);
warnings.push("Icon extracted from SWF (external download failed)");
} else {
warnings.push('Icon download failed and SWF icon extraction returned nothing')
warnings.push("Icon download failed and SWF icon extraction returned nothing");
}
} catch {
warnings.push('Icon download failed and SWF icon extraction also failed')
warnings.push("Icon download failed and SWF icon extraction also failed");
}
} else if (!iconOk) {
warnings.push('Icon image download failed')
warnings.push("Icon image download failed");
}
if (!swfOk) warnings.push('SWF download failed')
if (!swfOk) warnings.push("SWF download failed");
// ── Convert SWF to Nitro ──────────────────────────────────────────
onProgress?.('converting')
let conversionResult: ConversionResult | null = null
onProgress?.("converting");
let conversionResult: ConversionResult | null = null;
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
warnings.push('Nitro file already exists (shared across color variants)')
warnings.push("Nitro file already exists (shared across color variants)");
} else {
try {
if (existsSync(/*turbopackIgnore: true*/ swfPath)) {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
conversionResult = convertSwfToNitro(swfBuffer, baseClassname)
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, conversionResult.bundle)
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
conversionResult = convertSwfToNitro(swfBuffer, baseClassname);
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, conversionResult.bundle);
if (conversionResult.warnings.length > 0) {
warnings.push(...conversionResult.warnings)
warnings.push(...conversionResult.warnings);
}
} else {
warnings.push('SWF not available for Nitro conversion')
warnings.push("SWF not available for Nitro conversion");
}
} catch (err) {
console.warn('[import-furni] SWF to Nitro conversion failed:', (err as Error).message)
warnings.push(`SWF to Nitro conversion failed: ${(err as Error).message}`)
console.warn("[import-furni] SWF to Nitro conversion failed:", (err as Error).message);
warnings.push(`SWF to Nitro conversion failed: ${(err as Error).message}`);
}
}
// ── Rollback: if conversion failed AND no .nitro exists, remove DB record ──
if (!existsSync(/*turbopackIgnore: true*/ nitroPath) && !conversionResult) {
try {
await prisma.$executeRaw`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`
await prisma.$executeRaw`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`;
} catch {
/* best effort cleanup */
}
return { ok: false, warnings, error: 'Import rolled back: no .nitro file could be created' }
return { ok: false, warnings, error: "Import rolled back: no .nitro file could be created" };
}
await Promise.all([
mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings),
mirrorFileToDirs(iconPath, `${iconFileName}_icon.png`, mirrorIconDirs, warnings),
mirrorFileToDirs(nitroPath, `${safeNitroName}.nitro`, mirrorNitroDirs, warnings),
])
]);
// ── Build enriched FurnitureData entry ────────────────────────────
const dims =
conversionResult?.dimensions ||
(habboIt ? { x: habboIt.xdim, y: habboIt.ydim, z: 0 } : { x: 1, y: 1, z: 0 })
const meta = conversionResult?.metadata ?? null
(habboIt ? { x: habboIt.xdim, y: habboIt.ydim, z: 0 } : { x: 1, y: 1, z: 0 });
const meta = conversionResult?.metadata ?? null;
// Merge interaction flags: prefer .nitro extraction, fallback to habbo.it
const cansiton = meta?.cansiton ?? habboIt?.cansiton ?? false
const canlayon = meta?.canlayon ?? habboIt?.canlayon ?? false
const canstandon = meta?.canstandon ?? habboIt?.canstandon ?? false
const cansiton = meta?.cansiton ?? habboIt?.cansiton ?? false;
const canlayon = meta?.canlayon ?? habboIt?.canlayon ?? false;
const canstandon = meta?.canstandon ?? habboIt?.canstandon ?? false;
// Derive interaction_type. Priority:
// 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_)
@@ -515,29 +504,29 @@ export async function importSingleFurni(params: {
// wf_floor_switch, …) that didn't match step 2 — use the full
// classname as a safe fallback instead of 'default'.
// 4. Final fallback: default.
const logicType = meta?.logicType || ''
let interactionType = 'default'
const logicType = meta?.logicType || "";
let interactionType = "default";
if (/^wf_(act|trg|cnd|slc|xtra|var)_/.test(classname)) {
interactionType = classname
} else if (logicType.includes('multistate')) interactionType = 'default'
else if (logicType.includes('gate')) interactionType = 'gate'
else if (logicType.includes('teleport')) interactionType = 'teleport'
else if (logicType.includes('roller')) interactionType = 'roller'
else if (logicType.includes('dice')) interactionType = 'dice'
else if (logicType.includes('vendingmachine')) interactionType = 'vendingmachine'
else if (logicType.includes('firework')) interactionType = 'fireworks'
else if (logicType.includes('trophy')) interactionType = 'trophy'
else if (logicType.includes('exchange')) interactionType = 'exchange'
else if (logicType.includes('habbowheel')) interactionType = 'habbowheel'
else if (logicType.includes('soundmachine')) interactionType = 'jukebox'
interactionType = classname;
} else if (logicType.includes("multistate")) interactionType = "default";
else if (logicType.includes("gate")) interactionType = "gate";
else if (logicType.includes("teleport")) interactionType = "teleport";
else if (logicType.includes("roller")) interactionType = "roller";
else if (logicType.includes("dice")) interactionType = "dice";
else if (logicType.includes("vendingmachine")) interactionType = "vendingmachine";
else if (logicType.includes("firework")) interactionType = "fireworks";
else if (logicType.includes("trophy")) interactionType = "trophy";
else if (logicType.includes("exchange")) interactionType = "exchange";
else if (logicType.includes("habbowheel")) interactionType = "habbowheel";
else if (logicType.includes("soundmachine")) interactionType = "jukebox";
if (interactionType === 'default' && /^wf_/.test(classname)) {
interactionType = classname
warnings.push(`Wired fallback: using full classname "${classname}" as interaction_type`)
if (interactionType === "default" && /^wf_/.test(classname)) {
interactionType = classname;
warnings.push(`Wired fallback: using full classname "${classname}" as interaction_type`);
}
// allow_stack: if it has height and you can put things on it
const allowStack = dims.z > 0 ? '1' : '0'
const allowStack = dims.z > 0 ? "1" : "0";
// Update items_base with ALL enriched data
try {
@@ -549,10 +538,10 @@ export async function importSingleFurni(params: {
canstandon,
allowStack,
interactionType,
})
await prisma.$executeRawUnsafe(update.sql, ...update.values)
});
await prisma.$executeRawUnsafe(update.sql, ...update.values);
} catch (err) {
warnings.push(`items_base dimension update failed: ${(err as Error).message}`)
warnings.push(`items_base dimension update failed: ${(err as Error).message}`);
}
// Build enriched FurnitureData.json entry using best available data
@@ -567,40 +556,40 @@ export async function importSingleFurni(params: {
dims,
metadata: meta,
habboIt,
})
});
// ── Add to FurnitureData.json (or defer for batch) ────────────────
if (skipFurniDataWrite) {
// Batch mode: return the entry so the caller writes once at the end
} else {
try {
await appendFurniEntry(furniEntry, itemType)
await appendFurniEntry(furniEntry, itemType);
} catch (err) {
console.warn('[import-furni] Failed to update FurnitureData.json:', (err as Error).message)
warnings.push('FurnitureData.json update failed')
console.warn("[import-furni] Failed to update FurnitureData.json:", (err as Error).message);
warnings.push("FurnitureData.json update failed");
}
}
// ── Create catalog entry (with duplicate check + category sub-page) ──
let catalogItemId: number | null = null
let catalogItemId: number | null = null;
try {
const itemIdStr = String(spriteId)
const itemIdStr = String(spriteId);
// Check if this item already exists anywhere in the catalog
const existingCatalog = await prisma.$queryRaw<Array<{ id: number; page_id: string }>>`
SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1
`
`;
if (existingCatalog.length > 0) {
catalogItemId = existingCatalog[0].id
catalogItemId = existingCatalog[0].id;
warnings.push(
`Item already in catalog (catalog_item #${catalogItemId}, page ${existingCatalog[0].page_id}) — skipped duplicate`,
)
);
} else {
// Get or create the right category sub-page based on classname
const categoryPageId = await getOrCreateCategoryPage(classname, itemType)
const pageIdStr = String(categoryPageId)
const price = autoPriceFurni(classname)
const categoryPageId = await getOrCreateCategoryPage(classname, itemType);
const pageIdStr = String(categoryPageId);
const price = autoPriceFurni(classname);
// catalog_items.id has no AUTO_INCREMENT — serialize ID allocation
// across concurrent imports via a MySQL named lock to avoid MAX(id)
// TOCTOU that would cause duplicate-key failures.
@@ -608,13 +597,13 @@ export async function importSingleFurni(params: {
await prisma.$executeRaw`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')
`
return nextCatalogId
})
`;
return nextCatalogId;
});
}
} catch (err) {
console.warn('[import-furni] Failed to create catalog entry:', (err as Error).message)
warnings.push('Catalog entry creation failed')
console.warn("[import-furni] Failed to create catalog entry:", (err as Error).message);
warnings.push("Catalog entry creation failed");
}
return {
@@ -625,56 +614,56 @@ export async function importSingleFurni(params: {
spriteCount: conversionResult?.spriteCount,
warnings,
furniDataEntry: skipFurniDataWrite ? { entry: furniEntry, itemType } : undefined,
}
};
}
// ── Delete helpers ───────────────────────────────────────────────────
/** Delete a single imported item: DB records, FurnitureData.json entry, and asset files. */
export async function deleteImportedItem(classname: string): Promise<{
spriteId: number
deletedFiles: string[]
errors: string[]
spriteId: number;
deletedFiles: string[];
errors: string[];
}> {
const errors: string[] = []
const errors: string[] = [];
// Look up item in DB
const item = await prisma.itemsBase.findFirst({
where: { itemName: classname },
select: { id: true, type: true },
})
});
if (!item) {
throw new Error(`Item "${classname}" not found in database`)
throw new Error(`Item "${classname}" not found in database`);
}
const spriteId = item.id
const starIdx = classname.indexOf('*')
const assetTargets = await getFurniAssetWriteTargets()
const { swfDir, iconDir, nitroDir } = assetTargets
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
const iconFileName = classname.replace(/\*/g, '_')
const spriteId = item.id;
const starIdx = classname.indexOf("*");
const assetTargets = await getFurniAssetWriteTargets();
const { swfDir, iconDir, nitroDir } = assetTargets;
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname;
const iconFileName = classname.replace(/\*/g, "_");
// 1. Delete catalog_items
try {
const itemIdStr = String(spriteId)
await prisma.$executeRaw`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`
const itemIdStr = String(spriteId);
await prisma.$executeRaw`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`;
} catch (err) {
errors.push(`catalog_items: ${(err as Error).message}`)
errors.push(`catalog_items: ${(err as Error).message}`);
}
// 2. Delete items_base
try {
await prisma.$executeRaw`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`
await prisma.$executeRaw`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`;
} catch (err) {
errors.push(`items_base: ${(err as Error).message}`)
errors.push(`items_base: ${(err as Error).message}`);
}
// 3. Remove from FurnitureData.json
try {
await removeFurniEntry(classname)
await removeFurniEntry(classname);
} catch (err) {
errors.push(`FurnitureData.json: ${(err as Error).message}`)
errors.push(`FurnitureData.json: ${(err as Error).message}`);
}
// 4. Delete files (.nitro, .swf, icon)
@@ -687,49 +676,49 @@ export async function deleteImportedItem(classname: string): Promise<{
path.join(/*turbopackIgnore: true*/ dirs.swfDir, `${baseClassname}.swf`),
path.join(/*turbopackIgnore: true*/ dirs.iconDir, `${iconFileName}_icon.png`),
]),
]
];
const deletedFiles: string[] = []
const deletedFiles: string[] = [];
for (const filePath of filesToDelete) {
if (existsSync(/*turbopackIgnore: true*/ filePath)) {
try {
await fs.unlink(/*turbopackIgnore: true*/ filePath)
deletedFiles.push(path.basename(filePath))
await fs.unlink(/*turbopackIgnore: true*/ filePath);
deletedFiles.push(path.basename(filePath));
} catch (err) {
errors.push(`${path.basename(filePath)}: ${(err as Error).message}`)
errors.push(`${path.basename(filePath)}: ${(err as Error).message}`);
}
}
}
return { spriteId, deletedFiles, errors }
return { spriteId, deletedFiles, errors };
}
/** Clean up SWF files that have already been converted to Nitro. */
export async function cleanupConvertedSwfs(): Promise<{
deleted: number
total: number
remaining: number
errors: string[]
deleted: number;
total: number;
remaining: number;
errors: string[];
}> {
const { swfDir, nitroDir } = await getFurniAssetDirs()
const { swfDir, nitroDir } = await getFurniAssetDirs();
if (!existsSync(swfDir)) {
return { deleted: 0, total: 0, remaining: 0, errors: [] }
return { deleted: 0, total: 0, remaining: 0, errors: [] };
}
const swfFiles = (await fs.readdir(swfDir)).filter((f) => f.endsWith('.swf'))
let deleted = 0
const errors: string[] = []
const swfFiles = (await fs.readdir(swfDir)).filter((f) => f.endsWith(".swf"));
let deleted = 0;
const errors: string[] = [];
for (const swfFile of swfFiles) {
const cn = swfFile.replace('.swf', '')
const nitroFilePath = path.join(/*turbopackIgnore: true*/ nitroDir, `${cn}.nitro`)
const cn = swfFile.replace(".swf", "");
const nitroFilePath = path.join(/*turbopackIgnore: true*/ nitroDir, `${cn}.nitro`);
if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) {
try {
await fs.unlink(path.join(/*turbopackIgnore: true*/ swfDir, swfFile))
deleted++
await fs.unlink(path.join(/*turbopackIgnore: true*/ swfDir, swfFile));
deleted++;
} catch (err) {
errors.push(`Failed to delete ${swfFile}: ${(err as Error).message}`)
errors.push(`Failed to delete ${swfFile}: ${(err as Error).message}`);
}
}
}
@@ -739,5 +728,5 @@ export async function cleanupConvertedSwfs(): Promise<{
total: swfFiles.length,
remaining: swfFiles.length - deleted,
errors,
}
};
}
+159 -159
View File
@@ -8,52 +8,52 @@
* Reuses the download/convert helpers from furni-import.ts.
*/
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { prisma } from '@/lib/prisma'
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
import { appendFurniEntry, buildFurniEntry } from '@/lib/services/furni-data'
import { downloadFile, ensureDirectories } from '@/lib/services/furni-import'
import { getHabboItFurnidata, lookupHabboIt } from '@/lib/services/habbo-furnidata-cache'
import { convertSwfToNitro, extractIconFromSwf } from '@/lib/services/swf-to-nitro'
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
import { getHabboItFurnidata, lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
import { convertSwfToNitro, extractIconFromSwf } from "@/lib/services/swf-to-nitro";
// ── Types ────────────────────────────────────────────────────────────
export type MissingKind = 'nitro' | 'icon' | 'both'
export type MissingKind = "nitro" | "icon" | "both";
export interface MissingItem {
id: number
classname: string
baseClassname: string
revision: number
type: 's' | 'i'
publicName: string
hasSwf: boolean
hasIcon: boolean
hasNitro: boolean
missing: MissingKind
id: number;
classname: string;
baseClassname: string;
revision: number;
type: "s" | "i";
publicName: string;
hasSwf: boolean;
hasIcon: boolean;
hasNitro: boolean;
missing: MissingKind;
/** Pre-flagged as impossible to repair from habbo.com (whitespace, invalid chars). */
unrepairable: boolean
unrepairable: boolean;
}
export interface AuditSummary {
total: number
missingBoth: number
missingNitro: number
missingIcon: number
unrepairable: number
items: MissingItem[]
total: number;
missingBoth: number;
missingNitro: number;
missingIcon: number;
unrepairable: number;
items: MissingItem[];
}
export interface RepairResult {
classname: string
ok: boolean
iconRepaired: boolean
nitroRepaired: boolean
warnings: string[]
error?: string
classname: string;
ok: boolean;
iconRepaired: boolean;
nitroRepaired: boolean;
warnings: string[];
error?: string;
/** True when habbo.com will never serve this asset (bad classname or 404 across all known revisions). */
unrepairable?: boolean
unrepairable?: boolean;
}
/**
@@ -62,12 +62,12 @@ export interface RepairResult {
*/
export function isStructurallyImpossible(classname: string): boolean {
// Whitespace or ASCII control chars — habbo.com URL would 404.
if (/\s/.test(classname)) return true
if (/\s/.test(classname)) return true;
for (let i = 0; i < classname.length; i++) {
const code = classname.charCodeAt(i)
if (code < 0x20) return true
const code = classname.charCodeAt(i);
if (code < 0x20) return true;
}
return false
return false;
}
// ── Audit ────────────────────────────────────────────────────────────
@@ -77,7 +77,7 @@ export function isStructurallyImpossible(classname: string): boolean {
* their `.nitro` bundle and/or icon PNG on disk.
*/
export async function auditImportedAssets(): Promise<AuditSummary> {
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs()
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs();
const rows = await prisma.itemsBase.findMany({
select: {
id: true,
@@ -85,73 +85,73 @@ export async function auditImportedAssets(): Promise<AuditSummary> {
publicName: true,
type: true,
},
})
});
const items: MissingItem[] = []
let missingBoth = 0
let missingNitro = 0
let missingIcon = 0
let unrepairable = 0
const items: MissingItem[] = [];
let missingBoth = 0;
let missingNitro = 0;
let missingIcon = 0;
let unrepairable = 0;
for (const row of rows) {
const classname = row.itemName
if (!classname) continue
const classname = row.itemName;
if (!classname) continue;
const starIdx = classname.indexOf('*')
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
const iconFileName = classname.replace(/\*/g, '_')
const starIdx = classname.indexOf("*");
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname;
const iconFileName = classname.replace(/\*/g, "_");
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`)
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`)
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`)
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`);
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`);
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`);
const hasNitro = existsSync(/*turbopackIgnore: true*/ nitroPath)
const hasIcon = existsSync(/*turbopackIgnore: true*/ iconPath)
const hasSwf = existsSync(/*turbopackIgnore: true*/ swfPath)
const hasNitro = existsSync(/*turbopackIgnore: true*/ nitroPath);
const hasIcon = existsSync(/*turbopackIgnore: true*/ iconPath);
const hasSwf = existsSync(/*turbopackIgnore: true*/ swfPath);
if (hasNitro && hasIcon) continue
if (hasNitro && hasIcon) continue;
let missing: MissingKind
let missing: MissingKind;
if (!hasNitro && !hasIcon) {
missing = 'both'
missingBoth++
missing = "both";
missingBoth++;
} else if (!hasNitro) {
missing = 'nitro'
missingNitro++
missing = "nitro";
missingNitro++;
} else {
missing = 'icon'
missingIcon++
missing = "icon";
missingIcon++;
}
const unrep = isStructurallyImpossible(classname)
if (unrep) unrepairable++
const unrep = isStructurallyImpossible(classname);
if (unrep) unrepairable++;
items.push({
id: row.id,
classname,
baseClassname,
revision: 0,
type: (row.type === 'i' ? 'i' : 's') as 's' | 'i',
type: (row.type === "i" ? "i" : "s") as "s" | "i",
publicName: row.publicName ?? classname,
hasSwf,
hasIcon,
hasNitro,
missing,
unrepairable: unrep,
})
});
}
// Stable sort: missing both first, then nitro, then icon; alphabetical within group.
// Unrepairable items sink to the bottom of their group so fixable ones surface first.
const order: Record<MissingKind, number> = { both: 0, nitro: 1, icon: 2 }
const order: Record<MissingKind, number> = { both: 0, nitro: 1, icon: 2 };
items.sort(
(a, b) =>
order[a.missing] - order[b.missing] ||
Number(a.unrepairable) - Number(b.unrepairable) ||
a.classname.localeCompare(b.classname),
)
);
return { total: rows.length, missingBoth, missingNitro, missingIcon, unrepairable, items }
return { total: rows.length, missingBoth, missingNitro, missingIcon, unrepairable, items };
}
// ── Repair ───────────────────────────────────────────────────────────
@@ -167,9 +167,9 @@ export async function auditImportedAssets(): Promise<AuditSummary> {
* 5. If `.nitro` was (re)generated, update items_base dims / interaction flags.
*/
export async function repairItem(classname: string): Promise<RepairResult> {
const warnings: string[] = []
await ensureDirectories()
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs()
const warnings: string[] = [];
await ensureDirectories();
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs();
const row = await prisma.itemsBase.findFirst({
where: { itemName: classname },
@@ -179,7 +179,7 @@ export async function repairItem(classname: string): Promise<RepairResult> {
type: true,
publicName: true,
},
})
});
if (!row) {
return {
@@ -188,8 +188,8 @@ export async function repairItem(classname: string): Promise<RepairResult> {
iconRepaired: false,
nitroRepaired: false,
warnings,
error: 'Item not found in DB',
}
error: "Item not found in DB",
};
}
// Fast path: shapes habbo.com can never serve (whitespace, control chars).
@@ -200,22 +200,22 @@ export async function repairItem(classname: string): Promise<RepairResult> {
ok: false,
iconRepaired: false,
nitroRepaired: false,
warnings: ['Classname contains whitespace / control chars — habbo.com URL would 404'],
error: 'Unrepairable classname',
warnings: ["Classname contains whitespace / control chars — habbo.com URL would 404"],
error: "Unrepairable classname",
unrepairable: true,
}
};
}
const starIdx = classname.indexOf('*')
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
const iconFileName = classname.replace(/\*/g, '_')
const starIdx = classname.indexOf("*");
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname;
const iconFileName = classname.replace(/\*/g, "_");
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`)
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`)
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`)
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`);
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`);
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`);
const needsNitro = !existsSync(/*turbopackIgnore: true*/ nitroPath)
const needsIcon = !existsSync(/*turbopackIgnore: true*/ iconPath)
const needsNitro = !existsSync(/*turbopackIgnore: true*/ nitroPath);
const needsIcon = !existsSync(/*turbopackIgnore: true*/ iconPath);
if (!needsNitro && !needsIcon) {
return {
@@ -223,8 +223,8 @@ export async function repairItem(classname: string): Promise<RepairResult> {
ok: true,
iconRepaired: false,
nitroRepaired: false,
warnings: ['Nothing to repair'],
}
warnings: ["Nothing to repair"],
};
}
// ── Resolve candidate revisions ─────────────────────────────────────
@@ -232,83 +232,83 @@ export async function repairItem(classname: string): Promise<RepairResult> {
// 1. habbo.it furnidata — authoritative for current live assets
// 2. items_base.revision (what we originally recorded at import)
// 3. items_base.spriteId (some emulators reuse sprite id as revision)
const revsToTry: number[] = []
const revsToTry: number[] = [];
const pushRev = (r?: number | null) => {
if (r && r > 0 && !revsToTry.includes(r)) revsToTry.push(r)
}
if (r && r > 0 && !revsToTry.includes(r)) revsToTry.push(r);
};
try {
const furnidata = await getHabboItFurnidata()
const fd = furnidata.get(classname) || furnidata.get(baseClassname)
if (fd) pushRev(fd.revision)
const furnidata = await getHabboItFurnidata();
const fd = furnidata.get(classname) || furnidata.get(baseClassname);
if (fd) pushRev(fd.revision);
} catch {
// non-fatal
}
pushRev(row.spriteId)
pushRev(row.spriteId);
// Fallback: revision 0 is valid for many habbo.com paths
if (revsToTry.length === 0) revsToTry.push(0)
if (revsToTry.length === 0) revsToTry.push(0);
// ── Ensure SWF exists ───────────────────────────────────────────────
// We also download the SWF when only the icon is missing, so we can
// fall back to SWF-embedded icon extraction when habbo.com 404s the PNG.
let swfOk = existsSync(swfPath)
const wantSwf = needsNitro || needsIcon
let swfOk = existsSync(swfPath);
const wantSwf = needsNitro || needsIcon;
if (wantSwf && !swfOk) {
for (const tryRev of revsToTry) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
swfPath,
{ validate: 'swf' },
)
{ validate: "swf" },
);
if (dl.ok) {
swfOk = true
break
swfOk = true;
break;
}
}
if (!swfOk && needsNitro) {
warnings.push(`SWF not found on habbo.com (tried revisions: ${revsToTry.join(', ')})`)
warnings.push(`SWF not found on habbo.com (tried revisions: ${revsToTry.join(", ")})`);
}
}
// ── Regenerate .nitro from SWF ──────────────────────────────────────
let nitroRepaired = false
let nitroRepaired = false;
if (needsNitro && swfOk) {
try {
const swfBuffer = await fs.readFile(swfPath)
const conversion = convertSwfToNitro(swfBuffer, baseClassname)
await fs.writeFile(nitroPath, conversion.bundle)
nitroRepaired = true
const swfBuffer = await fs.readFile(swfPath);
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
await fs.writeFile(nitroPath, conversion.bundle);
nitroRepaired = true;
// Sync dims + interaction flags back to items_base (best effort)
const dims = conversion.dimensions || { x: 1, y: 1, z: 0 }
const meta = conversion.metadata || null
const cansiton = meta?.cansiton ?? false
const canlayon = meta?.canlayon ?? false
const canstandon = meta?.canstandon ?? false
const allowStack = (dims.z ?? 0) > 0 ? '1' : '0'
const dims = conversion.dimensions || { x: 1, y: 1, z: 0 };
const meta = conversion.metadata || null;
const cansiton = meta?.cansiton ?? false;
const canlayon = meta?.canlayon ?? false;
const canstandon = meta?.canstandon ?? false;
const allowStack = (dims.z ?? 0) > 0 ? "1" : "0";
try {
await prisma.$executeRaw`
UPDATE items_base
SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z ?? 0},
allow_sit = ${cansiton ? '1' : '0'},
allow_lay = ${canlayon ? '1' : '0'},
allow_walk = ${canstandon ? '1' : '0'},
allow_sit = ${cansiton ? "1" : "0"},
allow_lay = ${canlayon ? "1" : "0"},
allow_walk = ${canstandon ? "1" : "0"},
allow_stack = ${allowStack},
canlayon = ${canlayon ? '1' : '0'}
canlayon = ${canlayon ? "1" : "0"}
WHERE id = ${row.id}
`
`;
} catch (err) {
warnings.push(`items_base dim update failed: ${(err as Error).message}`)
warnings.push(`items_base dim update failed: ${(err as Error).message}`);
}
// Keep FurnitureData.json in sync with the regenerated metadata so the
// Nitro client sees up-to-date dims/flags without a full re-import.
try {
let habboIt = null
let habboIt = null;
try {
habboIt = await lookupHabboIt(classname)
habboIt = await lookupHabboIt(classname);
} catch {
/* optional */
}
@@ -316,20 +316,20 @@ export async function repairItem(classname: string): Promise<RepairResult> {
id: row.id,
classname,
revision: habboIt?.revision ?? 0,
category: habboIt?.category || 'unknown',
category: habboIt?.category || "unknown",
name: row.publicName || habboIt?.name || classname,
description: habboIt?.description || '',
description: habboIt?.description || "",
spriteId: row.spriteId,
dims: { x: dims.x, y: dims.y, z: dims.z ?? 0 },
metadata: meta,
habboIt,
})
await appendFurniEntry(entry, row.type)
});
await appendFurniEntry(entry, row.type);
} catch (err) {
warnings.push(`FurnitureData.json sync failed: ${(err as Error).message}`)
warnings.push(`FurnitureData.json sync failed: ${(err as Error).message}`);
}
} catch (err) {
warnings.push(`SWF → Nitro conversion failed: ${(err as Error).message}`)
warnings.push(`SWF → Nitro conversion failed: ${(err as Error).message}`);
}
}
@@ -347,11 +347,11 @@ export async function repairItem(classname: string): Promise<RepairResult> {
// 1. color-specific URL (only if classname has `*`)
// 2. base URL (always)
// After exhausting the network: extract from SWF (embedded icon).
let iconRepaired = false
let iconSource: 'habbo-color' | 'habbo-base' | 'swf' | null = null
let iconRepaired = false;
let iconSource: "habbo-color" | "habbo-base" | "swf" | null = null;
if (needsIcon) {
const hasColor = starIdx !== -1
const colorAwareName = classname.replace(/\*/g, '_')
const hasColor = starIdx !== -1;
const colorAwareName = classname.replace(/\*/g, "_");
for (const tryRev of revsToTry) {
// 1. color-specific icon (authoritative for color variants)
@@ -359,55 +359,55 @@ export async function repairItem(classname: string): Promise<RepairResult> {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(colorAwareName)}_icon.png`,
iconPath,
{ validate: 'png' },
)
{ validate: "png" },
);
if (dl.ok) {
iconRepaired = true
iconSource = 'habbo-color'
break
iconRepaired = true;
iconSource = "habbo-color";
break;
}
}
// 2. base icon (also used by Nitro when no color-specific one exists)
const dlBase = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}_icon.png`,
iconPath,
{ validate: 'png' },
)
{ validate: "png" },
);
if (dlBase.ok) {
iconRepaired = true
iconSource = 'habbo-base'
break
iconRepaired = true;
iconSource = "habbo-base";
break;
}
}
// 3. Fallback: extract from SWF (works offline + covers items with no public PNG)
if (!iconRepaired && existsSync(swfPath)) {
try {
const swfBuf = await fs.readFile(swfPath)
const iconPng = extractIconFromSwf(swfBuf, baseClassname)
const swfBuf = await fs.readFile(swfPath);
const iconPng = extractIconFromSwf(swfBuf, baseClassname);
if (iconPng) {
await fs.writeFile(iconPath, iconPng)
iconRepaired = true
iconSource = 'swf'
warnings.push('Icon extracted from SWF (no PNG on habbo.com)')
await fs.writeFile(iconPath, iconPng);
iconRepaired = true;
iconSource = "swf";
warnings.push("Icon extracted from SWF (no PNG on habbo.com)");
}
} catch (err) {
warnings.push(`SWF icon extraction failed: ${(err as Error).message}`)
warnings.push(`SWF icon extraction failed: ${(err as Error).message}`);
}
}
if (!iconRepaired) {
warnings.push(
existsSync(swfPath)
? 'Icon not on habbo.com and SWF has no embedded icon'
: `Icon not found on habbo.com (tried revisions: ${revsToTry.join(', ')})`,
)
} else if (iconSource === 'habbo-base' && hasColor) {
warnings.push('Used base-classname icon (color-specific not published)')
? "Icon not on habbo.com and SWF has no embedded icon"
: `Icon not found on habbo.com (tried revisions: ${revsToTry.join(", ")})`,
);
} else if (iconSource === "habbo-base" && hasColor) {
warnings.push("Used base-classname icon (color-specific not published)");
}
}
const ok = (!needsNitro || nitroRepaired) && (!needsIcon || iconRepaired)
const ok = (!needsNitro || nitroRepaired) && (!needsIcon || iconRepaired);
// Flag items for which every external revision returned 404 → no point retrying later.
// Criteria: we tried to download something and nothing (not even the SWF extraction)
@@ -417,8 +417,8 @@ export async function repairItem(classname: string): Promise<RepairResult> {
!iconRepaired &&
!nitroRepaired &&
// At least one outbound call was attempted and failed with 404-style errors
(warnings.some((w) => w.startsWith('SWF not found on habbo.com')) ||
warnings.some((w) => w.startsWith('Icon not found on habbo.com')))
(warnings.some((w) => w.startsWith("SWF not found on habbo.com")) ||
warnings.some((w) => w.startsWith("Icon not found on habbo.com")));
return { classname, ok, iconRepaired, nitroRepaired, warnings, unrepairable }
return { classname, ok, iconRepaired, nitroRepaired, warnings, unrepairable };
}
+59 -59
View File
@@ -3,125 +3,125 @@
* Provides instant lookup by classname for enriching imports.
*/
export type { HabboItFurniEntry } from '@/types/furni'
export type { HabboItFurniEntry } from "@/types/furni";
import type { HabboItFurniEntry } from '@/types/furni'
import type { HabboItFurniEntry } from "@/types/furni";
const FURNIDATA_URL = 'https://www.habbo.it/gamedata/furnidata_json/1'
const CACHE_TTL = 30 * 60 * 1000 // 30 minutes
const FURNIDATA_URL = "https://www.habbo.it/gamedata/furnidata_json/1";
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
let cache: Map<string, HabboItFurniEntry> | null = null
let cacheTimestamp = 0
let loading: Promise<Map<string, HabboItFurniEntry>> | null = null
let cache: Map<string, HabboItFurniEntry> | null = null;
let cacheTimestamp = 0;
let loading: Promise<Map<string, HabboItFurniEntry>> | null = null;
/**
* Fetches and parses the full habbo.it furnidata JSON.
* Results are cached in memory for 30 minutes.
*/
export async function getHabboItFurnidata(): Promise<Map<string, HabboItFurniEntry>> {
const now = Date.now()
const now = Date.now();
// Return cache if still valid
if (cache && now - cacheTimestamp < CACHE_TTL) {
return cache
return cache;
}
// Deduplicate concurrent requests
if (loading) return loading
if (loading) return loading;
loading = (async () => {
try {
console.debug('[habbo-it] Fetching furnidata from habbo.it...')
console.debug("[habbo-it] Fetching furnidata from habbo.it...");
const res = await fetch(FURNIDATA_URL, {
signal: AbortSignal.timeout(20000),
headers: { Accept: 'application/json' },
})
headers: { Accept: "application/json" },
});
if (!res.ok) {
console.warn(`[habbo-it] Fetch failed: ${res.status}`)
return cache || new Map()
console.warn(`[habbo-it] Fetch failed: ${res.status}`);
return cache || new Map();
}
const data = await res.json()
const map = new Map<string, HabboItFurniEntry>()
const data = await res.json();
const map = new Map<string, HabboItFurniEntry>();
// Process room items (floor furniture)
const roomTypes = data?.roomitemtypes?.furnitype
const roomTypes = data?.roomitemtypes?.furnitype;
if (Array.isArray(roomTypes)) {
for (const item of roomTypes) {
if (!item.classname) continue
if (!item.classname) continue;
map.set(item.classname, {
id: item.id ?? 0,
classname: item.classname,
revision: item.revision ?? 0,
category: item.category ?? '',
category: item.category ?? "",
defaultdir: item.defaultdir ?? 0,
xdim: item.xdim ?? 1,
ydim: item.ydim ?? 1,
partcolors: item.partcolors ?? { color: [] },
name: item.name ?? '',
description: item.description ?? '',
name: item.name ?? "",
description: item.description ?? "",
specialtype: item.specialtype ?? 0,
canstandon: item.canstandon ?? false,
cansiton: item.cansiton ?? false,
canlayon: item.canlayon ?? false,
furniline: item.furniline ?? '',
environment: item.environment ?? '',
furniline: item.furniline ?? "",
environment: item.environment ?? "",
rare: item.rare ?? false,
tradeable: item.tradeable,
bc: item.bc,
customparams: item.customparams,
itemType: 's',
})
itemType: "s",
});
}
}
// Process wall items
const wallTypes = data?.wallitemtypes?.furnitype
const wallTypes = data?.wallitemtypes?.furnitype;
if (Array.isArray(wallTypes)) {
for (const item of wallTypes) {
if (!item.classname) continue
if (!item.classname) continue;
map.set(item.classname, {
id: item.id ?? 0,
classname: item.classname,
revision: item.revision ?? 0,
category: item.category ?? '',
category: item.category ?? "",
defaultdir: item.defaultdir ?? 0,
xdim: item.xdim ?? 1,
ydim: item.ydim ?? 1,
partcolors: item.partcolors ?? { color: [] },
name: item.name ?? '',
description: item.description ?? '',
name: item.name ?? "",
description: item.description ?? "",
specialtype: item.specialtype ?? 0,
canstandon: item.canstandon ?? false,
cansiton: item.cansiton ?? false,
canlayon: item.canlayon ?? false,
furniline: item.furniline ?? '',
environment: item.environment ?? '',
furniline: item.furniline ?? "",
environment: item.environment ?? "",
rare: item.rare ?? false,
tradeable: item.tradeable,
bc: item.bc,
customparams: item.customparams,
itemType: 'i',
})
itemType: "i",
});
}
}
console.debug(
`[habbo-it] Cached ${map.size} items (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
)
cache = map
cacheTimestamp = now
return map
);
cache = map;
cacheTimestamp = now;
return map;
} catch (err) {
console.warn('[habbo-it] Fetch error:', (err as Error).message)
return cache || new Map()
console.warn("[habbo-it] Fetch error:", (err as Error).message);
return cache || new Map();
} finally {
loading = null
loading = null;
}
})()
})();
return loading
return loading;
}
/**
@@ -129,34 +129,34 @@ export async function getHabboItFurnidata(): Promise<Map<string, HabboItFurniEnt
* Tries exact match first, then base classname (without color variant).
*/
export async function lookupHabboIt(classname: string): Promise<HabboItFurniEntry | null> {
const map = await getHabboItFurnidata()
const map = await getHabboItFurnidata();
// 1. Exact match
const exact = map.get(classname)
if (exact) return exact
const exact = map.get(classname);
if (exact) return exact;
// 2. If classname has color variant (e.g. "chair*4"), try base
if (classname.includes('*')) {
const base = classname.split('*')[0]
if (classname.includes("*")) {
const base = classname.split("*")[0];
// Try base with *0 first (most common default)
const variant0 = map.get(`${base}*0`)
if (variant0) return variant0
const variant0 = map.get(`${base}*0`);
if (variant0) return variant0;
// Try just the base
const baseMatch = map.get(base)
if (baseMatch) return baseMatch
const baseMatch = map.get(base);
if (baseMatch) return baseMatch;
}
// 3. If no variant in classname, try *0
if (!classname.includes('*')) {
const withVariant = map.get(`${classname}*0`)
if (withVariant) return withVariant
if (!classname.includes("*")) {
const withVariant = map.get(`${classname}*0`);
if (withVariant) return withVariant;
}
return null
return null;
}
/** Clear the cache (useful after manual edits) */
export function clearHabboItCache(): void {
cache = null
cacheTimestamp = 0
cache = null;
cacheTimestamp = 0;
}
+63 -63
View File
@@ -6,113 +6,113 @@
* No third-party APIs (habboassets.com) needed.
*/
export type { HabboAssetBadge } from '@/types/furni'
export type { HabboAssetBadge } from "@/types/furni";
import type { HabboAssetBadge } from '@/types/furni'
import type { HabboAssetBadge } from "@/types/furni";
const EXTERNAL_TEXTS_URL = 'https://www.habbo.it/gamedata/external_flash_texts/1'
const BADGE_IMAGE_BASE = 'https://images.habbo.com/c_images/album1584'
const CACHE_TTL = 30 * 60 * 1000 // 30 minutes
const EXTERNAL_TEXTS_URL = "https://www.habbo.it/gamedata/external_flash_texts/1";
const BADGE_IMAGE_BASE = "https://images.habbo.com/c_images/album1584";
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
interface BadgeEntry {
code: string
name: string
description: string
code: string;
name: string;
description: string;
}
let badgeCache: BadgeEntry[] | null = null
let cacheTimestamp = 0
let loadingPromise: Promise<BadgeEntry[]> | null = null
let badgeCache: BadgeEntry[] | null = null;
let cacheTimestamp = 0;
let loadingPromise: Promise<BadgeEntry[]> | null = null;
/**
* Fetch and parse all badges from official Habbo external texts.
* Cached in memory for 30 minutes.
*/
async function loadBadges(): Promise<BadgeEntry[]> {
const now = Date.now()
if (badgeCache && now - cacheTimestamp < CACHE_TTL) return badgeCache
if (loadingPromise) return loadingPromise
const now = Date.now();
if (badgeCache && now - cacheTimestamp < CACHE_TTL) return badgeCache;
if (loadingPromise) return loadingPromise;
loadingPromise = (async () => {
try {
if (!EXTERNAL_TEXTS_URL) {
console.warn('[badges] HABBO_EXTERNAL_TEXTS_URL not configured')
return badgeCache || []
console.warn("[badges] HABBO_EXTERNAL_TEXTS_URL not configured");
return badgeCache || [];
}
const res = await fetch(EXTERNAL_TEXTS_URL, {
signal: AbortSignal.timeout(20000),
})
});
if (!res.ok) {
console.warn(`[badges] Fetch failed: ${res.status}`)
return badgeCache || []
console.warn(`[badges] Fetch failed: ${res.status}`);
return badgeCache || [];
}
const text = await res.text()
const lines = text.split('\n')
const text = await res.text();
const lines = text.split("\n");
// Parse key=value format, extract badge_name_CODE and badge_desc_CODE
const nameMap = new Map<string, string>()
const descMap = new Map<string, string>()
const nameMap = new Map<string, string>();
const descMap = new Map<string, string>();
for (const line of lines) {
const eqIdx = line.indexOf('=')
if (eqIdx === -1) continue
const key = line.substring(0, eqIdx).trim()
const value = line.substring(eqIdx + 1).trim()
const eqIdx = line.indexOf("=");
if (eqIdx === -1) continue;
const key = line.substring(0, eqIdx).trim();
const value = line.substring(eqIdx + 1).trim();
// Format: badge_name_CODE or CODE_badge_name
let match = key.match(/^badge_name_(.+)$/)
let match = key.match(/^badge_name_(.+)$/);
if (match) {
nameMap.set(match[1], value)
continue
nameMap.set(match[1], value);
continue;
}
match = key.match(/^(.+)_badge_name$/)
match = key.match(/^(.+)_badge_name$/);
if (match) {
nameMap.set(match[1], value)
continue
nameMap.set(match[1], value);
continue;
}
match = key.match(/^badge_desc_(.+)$/)
match = key.match(/^badge_desc_(.+)$/);
if (match) {
descMap.set(match[1], value)
continue
descMap.set(match[1], value);
continue;
}
match = key.match(/^(.+)_badge_desc$/)
match = key.match(/^(.+)_badge_desc$/);
if (match) {
descMap.set(match[1], value)
descMap.set(match[1], value);
}
}
// Merge into badge entries
const allCodes = new Set([...nameMap.keys(), ...descMap.keys()])
const badges: BadgeEntry[] = []
const allCodes = new Set([...nameMap.keys(), ...descMap.keys()]);
const badges: BadgeEntry[] = [];
for (const code of allCodes) {
badges.push({
code,
name: nameMap.get(code) || '',
description: descMap.get(code) || '',
})
name: nameMap.get(code) || "",
description: descMap.get(code) || "",
});
}
// Sort alphabetically by code
badges.sort((a, b) => a.code.localeCompare(b.code))
badges.sort((a, b) => a.code.localeCompare(b.code));
console.debug(`[badges] Loaded ${badges.length} badges from external texts`)
badgeCache = badges
cacheTimestamp = now
return badges
console.debug(`[badges] Loaded ${badges.length} badges from external texts`);
badgeCache = badges;
cacheTimestamp = now;
return badges;
} catch (err) {
console.warn('[badges] Load error:', (err as Error).message)
return badgeCache || []
console.warn("[badges] Load error:", (err as Error).message);
return badgeCache || [];
} finally {
loadingPromise = null
loadingPromise = null;
}
})()
})();
return loadingPromise
return loadingPromise;
}
/**
@@ -125,37 +125,37 @@ export async function searchBadges(
offset = 0,
_hotel: string | null = null, // kept for API compat, ignored (single source)
): Promise<HabboAssetBadge[]> {
const badges = await loadBadges()
const search = term.toLowerCase().trim()
const badges = await loadBadges();
const search = term.toLowerCase().trim();
let filtered = badges
let filtered = badges;
if (search) {
filtered = badges.filter(
(b) =>
b.code.toLowerCase().includes(search) ||
b.name.toLowerCase().includes(search) ||
b.description.toLowerCase().includes(search),
)
);
} else {
// No search term: return newest (reverse sorted, last entries are newest)
filtered = [...badges].reverse()
filtered = [...badges].reverse();
}
const page = filtered.slice(offset, offset + limit)
const page = filtered.slice(offset, offset + limit);
return page.map((b) => ({
id: 0,
code: b.code,
name: b.name,
description: b.description,
hotel: 'it',
hotel: "it",
url_habbo: `${BADGE_IMAGE_BASE}/${b.code}.gif`,
url_habboassets: `${BADGE_IMAGE_BASE}/${b.code}.gif`, // same source, no fallback needed
}))
}));
}
/** Clear the badge cache */
export function clearBadgeCache(): void {
badgeCache = null
cacheTimestamp = 0
badgeCache = null;
cacheTimestamp = 0;
}
+52 -54
View File
@@ -5,10 +5,10 @@
* No third-party APIs (habbofurni.com) needed.
*/
import type { FurniListResult, HabboFurniItem } from '@/types/furni'
import { getHabboItFurnidata } from './habbo-furnidata-cache'
import type { FurniListResult, HabboFurniItem } from "@/types/furni";
import { getHabboItFurnidata } from "./habbo-furnidata-cache";
export type { FurniListResult, HabboFurniItem }
export type { FurniListResult, HabboFurniItem };
// ── List / Browse endpoint ────────────────────────────────────────
@@ -18,32 +18,32 @@ export type { FurniListResult, HabboFurniItem }
*/
export async function listFurniture(
options: {
search?: string
page?: number
perPage?: number
search?: string;
page?: number;
perPage?: number;
/**
* Optional set of already-imported classnames. When provided, non-imported
* items are sorted to the top so the user finds them on page 1 instead of
* paging through 15+ pages of already-imported newest items.
*/
importedSet?: Set<string>
importedSet?: Set<string>;
} = {},
): Promise<FurniListResult> {
const empty: FurniListResult = {
items: [],
meta: { currentPage: 1, lastPage: 1, total: 0, perPage: 50 },
}
const perPage = Math.min(options.perPage || 50, 100)
const page = options.page || 1
const search = options.search?.toLowerCase().trim() || ''
const importedSet = options.importedSet
};
const perPage = Math.min(options.perPage || 50, 100);
const page = options.page || 1;
const search = options.search?.toLowerCase().trim() || "";
const importedSet = options.importedSet;
try {
const map = await getHabboItFurnidata()
if (map.size === 0) return empty
const map = await getHabboItFurnidata();
if (map.size === 0) return empty;
// Convert to array and optionally filter by search
let items = Array.from(map.values())
let items = Array.from(map.values());
if (search) {
items = items.filter(
@@ -51,42 +51,42 @@ export async function listFurniture(
item.classname.toLowerCase().includes(search) ||
item.name.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search),
)
);
}
if (importedSet) {
// Non-imported first (id desc), then imported (id desc).
items.sort((a, b) => {
const aImp = importedSet.has(a.classname) ? 1 : 0
const bImp = importedSet.has(b.classname) ? 1 : 0
if (aImp !== bImp) return aImp - bImp
return b.id - a.id
})
const aImp = importedSet.has(a.classname) ? 1 : 0;
const bImp = importedSet.has(b.classname) ? 1 : 0;
if (aImp !== bImp) return aImp - bImp;
return b.id - a.id;
});
} else {
// Sort by id descending (newest first)
items.sort((a, b) => b.id - a.id)
items.sort((a, b) => b.id - a.id);
}
const total = items.length
const lastPage = Math.max(Math.ceil(total / perPage), 1)
const start = (page - 1) * perPage
const pageItems = items.slice(start, start + perPage)
const total = items.length;
const lastPage = Math.max(Math.ceil(total / perPage), 1);
const start = (page - 1) * perPage;
const pageItems = items.slice(start, start + perPage);
return {
items: pageItems.map((item) => ({
id: item.id,
classname: item.classname,
name: item.name || item.classname,
description: item.description || '',
type: item.itemType === 'i' ? 'wallitem' : 'flooritem',
description: item.description || "",
type: item.itemType === "i" ? "wallitem" : "flooritem",
revision: item.revision,
category: item.category || item.furniline || '',
category: item.category || item.furniline || "",
})),
meta: { currentPage: page, lastPage, total, perPage },
}
};
} catch (err) {
console.warn('[furni] List error:', (err as Error).message)
return empty
console.warn("[furni] List error:", (err as Error).message);
return empty;
}
}
@@ -96,8 +96,8 @@ export async function listFurniture(
* Search furniture by query string.
*/
export async function searchFurniture(query: string, perPage = 50): Promise<HabboFurniItem[]> {
const result = await listFurniture({ search: query, perPage })
return result.items
const result = await listFurniture({ search: query, perPage });
return result.items;
}
// ── Direct lookup by classname ─────────────────────────────────────
@@ -107,33 +107,33 @@ export async function searchFurniture(query: string, perPage = 50): Promise<Habb
* Uses the cached habbo.it furnidata.
*/
export async function getFurnitureByClassname(classname: string): Promise<HabboFurniItem | null> {
const map = await getHabboItFurnidata()
const map = await getHabboItFurnidata();
// Try exact match
let entry = map.get(classname)
let entry = map.get(classname);
// Try without color variant
if (!entry && classname.includes('*')) {
const base = classname.split('*')[0]
entry = map.get(`${base}*0`) || map.get(base)
if (!entry && classname.includes("*")) {
const base = classname.split("*")[0];
entry = map.get(`${base}*0`) || map.get(base);
}
// Try with *0
if (!entry && !classname.includes('*')) {
entry = map.get(`${classname}*0`)
if (!entry && !classname.includes("*")) {
entry = map.get(`${classname}*0`);
}
if (!entry) return null
if (!entry) return null;
return {
id: entry.id,
classname: entry.classname,
name: entry.name || entry.classname,
description: entry.description || '',
type: entry.itemType === 'i' ? 'wallitem' : 'flooritem',
description: entry.description || "",
type: entry.itemType === "i" ? "wallitem" : "flooritem",
revision: entry.revision,
category: entry.category || entry.furniline || '',
}
category: entry.category || entry.furniline || "",
};
}
// ── Batch lookup ───────────────────────────────────────────────────
@@ -142,18 +142,16 @@ export async function getFurnitureByClassname(classname: string): Promise<HabboF
* Batch lookup: get data for a list of classnames.
* Much faster than the old habbofurni.com API — single cache lookup, no network.
*/
export async function batchLookupByClassnames(
classnames: string[],
): Promise<Map<string, HabboFurniItem>> {
const result = new Map<string, HabboFurniItem>()
if (classnames.length === 0) return result
export async function batchLookupByClassnames(classnames: string[]): Promise<Map<string, HabboFurniItem>> {
const result = new Map<string, HabboFurniItem>();
if (classnames.length === 0) return result;
for (const cn of classnames) {
const item = await getFurnitureByClassname(cn)
const item = await getFurnitureByClassname(cn);
if (item?.name) {
result.set(cn, item)
result.set(cn, item);
}
}
return result
return result;
}
+34 -34
View File
@@ -1,46 +1,46 @@
import { describe, expect, it } from 'vitest'
import { encodePng } from '@/lib/services/swf/nitro-builder'
import { cropRgba, decodePng } from './png-decode'
import { describe, expect, it } from "vitest";
import { encodePng } from "@/lib/services/swf/nitro-builder";
import { cropRgba, decodePng } from "./png-decode";
describe('png-decode', () => {
it('round-trips an RGBA image through encodePng → decodePng', () => {
const w = 3
const h = 2
describe("png-decode", () => {
it("round-trips an RGBA image through encodePng → decodePng", () => {
const w = 3;
const h = 2;
const rgba = Buffer.from([
// row 0
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255,
// row 1
10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120,
])
const png = encodePng(w, h, rgba)
const dec = decodePng(png)
expect(dec.width).toBe(w)
expect(dec.height).toBe(h)
expect(Buffer.compare(dec.rgba, rgba)).toBe(0)
})
]);
const png = encodePng(w, h, rgba);
const dec = decodePng(png);
expect(dec.width).toBe(w);
expect(dec.height).toBe(h);
expect(Buffer.compare(dec.rgba, rgba)).toBe(0);
});
it('decodes a larger gradient losslessly', () => {
const w = 16
const h = 16
const rgba = Buffer.alloc(w * h * 4)
it("decodes a larger gradient losslessly", () => {
const w = 16;
const h = 16;
const rgba = Buffer.alloc(w * h * 4);
for (let i = 0; i < w * h; i++) {
rgba[i * 4] = i % 256
rgba[i * 4 + 1] = (i * 3) % 256
rgba[i * 4 + 2] = (i * 7) % 256
rgba[i * 4 + 3] = 255
rgba[i * 4] = i % 256;
rgba[i * 4 + 1] = (i * 3) % 256;
rgba[i * 4 + 2] = (i * 7) % 256;
rgba[i * 4 + 3] = 255;
}
const dec = decodePng(encodePng(w, h, rgba))
expect(Buffer.compare(dec.rgba, rgba)).toBe(0)
})
const dec = decodePng(encodePng(w, h, rgba));
expect(Buffer.compare(dec.rgba, rgba)).toBe(0);
});
it('crops a sub-region', () => {
it("crops a sub-region", () => {
// 2x2 sheet, crop the bottom-right 1x1 pixel
const sheet = Buffer.from([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4])
const cropped = cropRgba(sheet, 2, 1, 1, 1, 1)
expect([...cropped]).toEqual([4, 4, 4, 4])
})
const sheet = Buffer.from([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]);
const cropped = cropRgba(sheet, 2, 1, 1, 1, 1);
expect([...cropped]).toEqual([4, 4, 4, 4]);
});
it('rejects non-PNG input', () => {
expect(() => decodePng(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toThrow('not a PNG')
})
})
it("rejects non-PNG input", () => {
expect(() => decodePng(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toThrow("not a PNG");
});
});
+78 -85
View File
@@ -1,48 +1,48 @@
import { inflateSync } from 'node:zlib'
import { inflateSync } from "node:zlib";
export interface DecodedPng {
width: number
height: number
width: number;
height: number;
/** RGBA8888, row-major, length = width * height * 4 */
rgba: Buffer
rgba: Buffer;
}
function paeth(a: number, b: number, c: number): number {
const p = a + b - c
const pa = Math.abs(p - a)
const pb = Math.abs(p - b)
const pc = Math.abs(p - c)
if (pa <= pb && pa <= pc) return a
if (pb <= pc) return b
return c
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
function unfilter(filter: number, cur: Buffer, prev: Buffer, bpp: number): void {
const n = cur.length
const n = cur.length;
switch (filter) {
case 0:
break
break;
case 1:
for (let i = 0; i < n; i++) cur[i] = (cur[i] + (i >= bpp ? cur[i - bpp] : 0)) & 0xff
break
for (let i = 0; i < n; i++) cur[i] = (cur[i] + (i >= bpp ? cur[i - bpp] : 0)) & 0xff;
break;
case 2:
for (let i = 0; i < n; i++) cur[i] = (cur[i] + prev[i]) & 0xff
break
for (let i = 0; i < n; i++) cur[i] = (cur[i] + prev[i]) & 0xff;
break;
case 3:
for (let i = 0; i < n; i++) {
const a = i >= bpp ? cur[i - bpp] : 0
cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 0xff
const a = i >= bpp ? cur[i - bpp] : 0;
cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 0xff;
}
break
break;
case 4:
for (let i = 0; i < n; i++) {
const a = i >= bpp ? cur[i - bpp] : 0
const c = i >= bpp ? prev[i - bpp] : 0
cur[i] = (cur[i] + paeth(a, prev[i], c)) & 0xff
const a = i >= bpp ? cur[i - bpp] : 0;
const c = i >= bpp ? prev[i - bpp] : 0;
cur[i] = (cur[i] + paeth(a, prev[i], c)) & 0xff;
}
break
break;
default:
throw new Error(`unknown PNG filter ${filter}`)
throw new Error(`unknown PNG filter ${filter}`);
}
}
@@ -53,83 +53,76 @@ function unfilter(filter: number, cur: Buffer, prev: Buffer, bpp: number): void
*/
export function decodePng(buf: Buffer): DecodedPng {
if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
throw new Error('not a PNG')
throw new Error("not a PNG");
}
let off = 8
let width = 0
let height = 0
let bitDepth = 0
let colorType = 0
let interlace = 0
const idat: Buffer[] = []
let off = 8;
let width = 0;
let height = 0;
let bitDepth = 0;
let colorType = 0;
let interlace = 0;
const idat: Buffer[] = [];
while (off + 8 <= buf.length) {
const len = buf.readUInt32BE(off)
const type = buf.toString('ascii', off + 4, off + 8)
const data = buf.subarray(off + 8, off + 8 + len)
off += 12 + len // length(4) + type(4) + data + crc(4)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
bitDepth = data[8]
colorType = data[9]
interlace = data[12]
} else if (type === 'IDAT') {
idat.push(data)
} else if (type === 'IEND') {
break
const len = buf.readUInt32BE(off);
const type = buf.toString("ascii", off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
off += 12 + len; // length(4) + type(4) + data + crc(4)
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
interlace = data[12];
} else if (type === "IDAT") {
idat.push(data);
} else if (type === "IEND") {
break;
}
}
if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth ${bitDepth}`)
if (interlace !== 0) throw new Error('interlaced PNG not supported')
const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : -1
if (channels === -1) throw new Error(`unsupported PNG color type ${colorType}`)
if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth ${bitDepth}`);
if (interlace !== 0) throw new Error("interlaced PNG not supported");
const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : -1;
if (channels === -1) throw new Error(`unsupported PNG color type ${colorType}`);
const raw = inflateSync(Buffer.concat(idat))
const stride = width * channels
const planar = Buffer.alloc(height * stride)
let prev = Buffer.alloc(stride)
let rp = 0
const raw = inflateSync(Buffer.concat(idat));
const stride = width * channels;
const planar = Buffer.alloc(height * stride);
let prev = Buffer.alloc(stride);
let rp = 0;
for (let y = 0; y < height; y++) {
const filter = raw[rp++]
const cur = planar.subarray(y * stride, y * stride + stride)
raw.copy(cur, 0, rp, rp + stride)
rp += stride
unfilter(filter, cur, prev, channels)
prev = cur
const filter = raw[rp++];
const cur = planar.subarray(y * stride, y * stride + stride);
raw.copy(cur, 0, rp, rp + stride);
rp += stride;
unfilter(filter, cur, prev, channels);
prev = cur;
}
if (channels === 4) return { width, height, rgba: planar }
if (channels === 4) return { width, height, rgba: planar };
const rgba = Buffer.alloc(width * height * 4)
const rgba = Buffer.alloc(width * height * 4);
for (let i = 0, j = 0; i < width * height; i++) {
if (channels === 3) {
rgba[j++] = planar[i * 3]
rgba[j++] = planar[i * 3 + 1]
rgba[j++] = planar[i * 3 + 2]
rgba[j++] = planar[i * 3];
rgba[j++] = planar[i * 3 + 1];
rgba[j++] = planar[i * 3 + 2];
} else {
const g = planar[i]
rgba[j++] = g
rgba[j++] = g
rgba[j++] = g
const g = planar[i];
rgba[j++] = g;
rgba[j++] = g;
rgba[j++] = g;
}
rgba[j++] = 255
rgba[j++] = 255;
}
return { width, height, rgba }
return { width, height, rgba };
}
/** Crop a w×h RGBA region at (x,y) out of a sheetW-wide RGBA buffer. */
export function cropRgba(
rgba: Buffer,
sheetW: number,
x: number,
y: number,
w: number,
h: number,
): Buffer {
const out = Buffer.alloc(w * h * 4)
export function cropRgba(rgba: Buffer, sheetW: number, x: number, y: number, w: number, h: number): Buffer {
const out = Buffer.alloc(w * h * 4);
for (let row = 0; row < h; row++) {
const src = ((y + row) * sheetW + x) * 4
rgba.copy(out, row * w * 4, src, src + w * 4)
const src = ((y + row) * sheetW + x) * 4;
rgba.copy(out, row * w * 4, src, src + w * 4);
}
return out
return out;
}
@@ -1,21 +1,21 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveGamedataFile } from './asset-paths'
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveGamedataFile } from "./asset-paths";
describe('resolveGamedataFile', () => {
it('uses absolute external paths as-is', () => {
describe("resolveGamedataFile", () => {
it("uses absolute external paths as-is", () => {
const absolutePath = path.win32.join(
'E:\\',
'Users',
'simol',
'Desktop',
'DEV',
'Nitro-Files',
'nitro-assets',
'gamedata',
'FigureMap.json',
)
"E:\\",
"Users",
"simol",
"Desktop",
"DEV",
"Nitro-Files",
"nitro-assets",
"gamedata",
"FigureMap.json",
);
expect(resolveGamedataFile(absolutePath, 'FigureMap.json')).toBe(absolutePath)
})
})
expect(resolveGamedataFile(absolutePath, "FigureMap.json")).toBe(absolutePath);
});
});
+9 -9
View File
@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { existsSync } from "node:fs";
import path from "node:path";
// The CMS default gamedata JSON paths point under public/nitro-assets/gamedata/,
// but this deployment keeps the gamedata JSON (FigureMap/FigureData/EffectMap…)
@@ -9,7 +9,7 @@ import path from 'node:path'
// the client's served asset dir — see *_NITRO_DIR consts in the import services.)
function pub(...segs: string[]): string {
return path.join(/*turbopackIgnore: true*/ process.cwd(), 'public', ...segs)
return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", ...segs);
}
/**
@@ -19,10 +19,10 @@ function pub(...segs: string[]): string {
* Returns the configured path when neither exists (write target for fresh setups).
*/
export function resolveGamedataFile(url: string, fallbackName: string): string {
if (path.win32.isAbsolute(url) || (path.isAbsolute(url) && !url.startsWith('/'))) return url
const configured = pub(url.replace(/^\/+/, ''))
if (existsSync(/*turbopackIgnore: true*/ configured)) return configured
const gamedata = pub('Gamedata', 'config', fallbackName)
if (existsSync(/*turbopackIgnore: true*/ gamedata)) return gamedata
return configured
if (path.win32.isAbsolute(url) || (path.isAbsolute(url) && !url.startsWith("/"))) return url;
const configured = pub(url.replace(/^\/+/, ""));
if (existsSync(/*turbopackIgnore: true*/ configured)) return configured;
const gamedata = pub("Gamedata", "config", fallbackName);
if (existsSync(/*turbopackIgnore: true*/ gamedata)) return gamedata;
return configured;
}
+17 -17
View File
@@ -1,20 +1,20 @@
import { describe, expect, it } from 'vitest'
import { validatePngBytes, validateSwfBytes } from './download'
import { describe, expect, it } from "vitest";
import { validatePngBytes, validateSwfBytes } from "./download";
describe('import/core/download validators', () => {
it('accepts FWS/CWS/ZWS swf magic', () => {
expect(validateSwfBytes(Buffer.from('FWS\x06\x00\x00\x00\x00'))).toBe(true)
expect(validateSwfBytes(Buffer.from('CWS\x06\x00\x00\x00\x00'))).toBe(true)
expect(validateSwfBytes(Buffer.from('ZWS\x06\x00\x00\x00\x00'))).toBe(true)
})
describe("import/core/download validators", () => {
it("accepts FWS/CWS/ZWS swf magic", () => {
expect(validateSwfBytes(Buffer.from("FWS\x06\x00\x00\x00\x00"))).toBe(true);
expect(validateSwfBytes(Buffer.from("CWS\x06\x00\x00\x00\x00"))).toBe(true);
expect(validateSwfBytes(Buffer.from("ZWS\x06\x00\x00\x00\x00"))).toBe(true);
});
it('rejects non-swf and too-short buffers', () => {
expect(validateSwfBytes(Buffer.from('PNG\x00\x00\x00\x00\x00'))).toBe(false)
expect(validateSwfBytes(Buffer.from('FW'))).toBe(false)
})
it("rejects non-swf and too-short buffers", () => {
expect(validateSwfBytes(Buffer.from("PNG\x00\x00\x00\x00\x00"))).toBe(false);
expect(validateSwfBytes(Buffer.from("FW"))).toBe(false);
});
it('accepts png magic and rejects others', () => {
expect(validatePngBytes(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))).toBe(true)
expect(validatePngBytes(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toBe(false)
})
})
it("accepts png magic and rejects others", () => {
expect(validatePngBytes(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))).toBe(true);
expect(validatePngBytes(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toBe(false);
});
});
+32 -32
View File
@@ -1,67 +1,67 @@
import { promises as fs } from 'node:fs'
import { promises as fs } from "node:fs";
export function validateSwfBytes(buffer: Buffer): boolean {
if (buffer.length < 8) return false
const sig = buffer.toString('ascii', 0, 3)
return sig === 'FWS' || sig === 'CWS' || sig === 'ZWS'
if (buffer.length < 8) return false;
const sig = buffer.toString("ascii", 0, 3);
return sig === "FWS" || sig === "CWS" || sig === "ZWS";
}
export function validatePngBytes(buffer: Buffer): boolean {
if (buffer.length < 8) return false
return buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71
if (buffer.length < 8) return false;
return buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71;
}
export async function downloadFile(
url: string,
destPath: string,
options?: { maxRetries?: number; validate?: 'swf' | 'png' },
options?: { maxRetries?: number; validate?: "swf" | "png" },
): Promise<{ ok: boolean; size: number }> {
const maxRetries = options?.maxRetries ?? 3
const baseDelay = 1000
const maxRetries = options?.maxRetries ?? 3;
const baseDelay = 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)))
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
}
const res = await fetch(url, { signal: AbortSignal.timeout(15000) })
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!res.ok) {
const deterministic = res.status === 404 || res.status === 410 || res.status === 403
const deterministic = res.status === 404 || res.status === 410 || res.status === 403;
if (deterministic || attempt === maxRetries) {
console.warn(
`[import-download] Download failed ${url}: ${res.status}${deterministic ? ' (deterministic, not retrying)' : ` after ${maxRetries + 1} attempts`}`,
)
return { ok: false, size: 0 }
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
);
return { ok: false, size: 0 };
}
continue
continue;
}
const buffer = Buffer.from(await res.arrayBuffer())
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 8) {
if (attempt === maxRetries) return { ok: false, size: 0 }
continue
if (attempt === maxRetries) return { ok: false, size: 0 };
continue;
}
if (options?.validate === 'swf' && !validateSwfBytes(buffer)) {
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
if (attempt === maxRetries) {
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`)
return { ok: false, size: 0 }
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
return { ok: false, size: 0 };
}
continue
continue;
}
if (options?.validate === 'png' && !validatePngBytes(buffer)) {
if (options?.validate === "png" && !validatePngBytes(buffer)) {
if (attempt === maxRetries) {
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`)
return { ok: false, size: 0 }
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
return { ok: false, size: 0 };
}
continue
continue;
}
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer)
return { ok: true, size: buffer.length }
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
return { ok: true, size: buffer.length };
} catch (err) {
if (attempt === maxRetries) {
console.warn(`[import-download] Download error ${url}:`, (err as Error).message)
return { ok: false, size: 0 }
console.warn(`[import-download] Download error ${url}:`, (err as Error).message);
return { ok: false, size: 0 };
}
}
}
return { ok: false, size: 0 }
return { ok: false, size: 0 };
}
@@ -1,43 +1,43 @@
import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readGamedataJson, withGamedataLock, writeGamedataJsonAtomic } from './gamedata-json'
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readGamedataJson, withGamedataLock, writeGamedataJsonAtomic } from "./gamedata-json";
let dir: string
let file: string
let dir: string;
let file: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gamedata-'))
file = path.join(dir, 'EffectMap.json')
await fs.writeFile(file, JSON.stringify({ effects: [{ id: '1', lib: 'A' }] }))
})
dir = await fs.mkdtemp(path.join(os.tmpdir(), "gamedata-"));
file = path.join(dir, "EffectMap.json");
await fs.writeFile(file, JSON.stringify({ effects: [{ id: "1", lib: "A" }] }));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true })
})
await fs.rm(dir, { recursive: true, force: true });
});
describe('import/core/gamedata-json', () => {
it('reads parsed JSON', async () => {
const data = await readGamedataJson<{ effects: unknown[] }>(file)
expect(data.effects).toHaveLength(1)
})
describe("import/core/gamedata-json", () => {
it("reads parsed JSON", async () => {
const data = await readGamedataJson<{ effects: unknown[] }>(file);
expect(data.effects).toHaveLength(1);
});
it('writes atomically (round-trips)', async () => {
await writeGamedataJsonAtomic(file, { effects: [{ id: '9', lib: 'Z' }] })
const data = await readGamedataJson<{ effects: Array<{ id: string }> }>(file)
expect(data.effects[0].id).toBe('9')
})
it("writes atomically (round-trips)", async () => {
await writeGamedataJsonAtomic(file, { effects: [{ id: "9", lib: "Z" }] });
const data = await readGamedataJson<{ effects: Array<{ id: string }> }>(file);
expect(data.effects[0].id).toBe("9");
});
it('serializes concurrent writers (no lost update)', async () => {
it("serializes concurrent writers (no lost update)", async () => {
const bump = (n: number) =>
withGamedataLock(file, async () => {
const d = await readGamedataJson<{ effects: unknown[] }>(file)
d.effects.push({ id: String(n), lib: `L${n}` })
await writeGamedataJsonAtomic(file, d)
})
await Promise.all([bump(2), bump(3), bump(4)])
const d = await readGamedataJson<{ effects: unknown[] }>(file)
expect(d.effects).toHaveLength(4) // 1 seed + 3 appended, none lost
})
})
const d = await readGamedataJson<{ effects: unknown[] }>(file);
d.effects.push({ id: String(n), lib: `L${n}` });
await writeGamedataJsonAtomic(file, d);
});
await Promise.all([bump(2), bump(3), bump(4)]);
const d = await readGamedataJson<{ effects: unknown[] }>(file);
expect(d.effects).toHaveLength(4); // 1 seed + 3 appended, none lost
});
});
+32 -32
View File
@@ -1,63 +1,63 @@
import { promises as fs } from 'node:fs'
import { promises as fs } from "node:fs";
const LOCK_STALE_MS = 60_000
const LOCK_ACQUIRE_TIMEOUT_MS = 30_000
const LOCK_STALE_MS = 60_000;
const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
// In-process serialization, keyed by absolute file path, layered on top of the
// on-disk lock so multiple awaiters in the same Node process queue cleanly.
const chains = new Map<string, Promise<void>>()
const chains = new Map<string, Promise<void>>();
async function acquireDiskLock(lockPath: string): Promise<void> {
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
while (true) {
try {
const fh = await fs.open(/*turbopackIgnore: true*/ lockPath, 'wx')
await fh.writeFile(String(process.pid))
await fh.close()
return
const fh = await fs.open(/*turbopackIgnore: true*/ lockPath, "wx");
await fh.writeFile(String(process.pid));
await fh.close();
return;
} catch (err) {
const e = err as NodeJS.ErrnoException
if (e.code !== 'EEXIST') throw err
const e = err as NodeJS.ErrnoException;
if (e.code !== "EEXIST") throw err;
try {
const stat = await fs.stat(/*turbopackIgnore: true*/ lockPath)
const stat = await fs.stat(/*turbopackIgnore: true*/ lockPath);
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {})
continue
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {});
continue;
}
} catch {
/* lock vanished between checks */
}
if (Date.now() > deadline) throw new Error(`gamedata lock timeout: ${lockPath}`)
await new Promise((r) => setTimeout(r, 100))
if (Date.now() > deadline) throw new Error(`gamedata lock timeout: ${lockPath}`);
await new Promise((r) => setTimeout(r, 100));
}
}
}
export async function withGamedataLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
let release!: () => void
let release!: () => void;
const acquired = new Promise<void>((r) => {
release = r
})
const prev = chains.get(filePath) ?? Promise.resolve()
chains.set(filePath, acquired)
await prev
const lockPath = `${filePath}.lock`
await acquireDiskLock(lockPath)
release = r;
});
const prev = chains.get(filePath) ?? Promise.resolve();
chains.set(filePath, acquired);
await prev;
const lockPath = `${filePath}.lock`;
await acquireDiskLock(lockPath);
try {
return await fn()
return await fn();
} finally {
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {})
release()
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {});
release();
}
}
export async function readGamedataJson<T>(filePath: string): Promise<T> {
const raw = await fs.readFile(/*turbopackIgnore: true*/ filePath, 'utf-8')
return JSON.parse(raw) as T
const raw = await fs.readFile(/*turbopackIgnore: true*/ filePath, "utf-8");
return JSON.parse(raw) as T;
}
export async function writeGamedataJsonAtomic(filePath: string, data: unknown): Promise<void> {
const tmp = `${filePath}.tmp`
await fs.writeFile(/*turbopackIgnore: true*/ tmp, JSON.stringify(data, null, 2), 'utf-8')
await fs.rename(/*turbopackIgnore: true*/ tmp, filePath)
const tmp = `${filePath}.tmp`;
await fs.writeFile(/*turbopackIgnore: true*/ tmp, JSON.stringify(data, null, 2), "utf-8");
await fs.rename(/*turbopackIgnore: true*/ tmp, filePath);
}
+25 -25
View File
@@ -1,31 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __clearGordonCache, resolveGordonBuildUrl } from './gordon'
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __clearGordonCache, resolveGordonBuildUrl } from "./gordon";
beforeEach(() => {
__clearGordonCache()
vi.restoreAllMocks()
})
afterEach(() => vi.restoreAllMocks())
__clearGordonCache();
vi.restoreAllMocks();
});
afterEach(() => vi.restoreAllMocks());
describe('import/core/gordon', () => {
it('parses flash.client.url from external_variables and normalizes', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('flash.client.url=//images.habbo.com/gordon/PRODUCTION-XYZ/\n', { status: 200 }),
)
expect(await resolveGordonBuildUrl()).toBe('https://images.habbo.com/gordon/PRODUCTION-XYZ/')
})
describe("import/core/gordon", () => {
it("parses flash.client.url from external_variables and normalizes", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("flash.client.url=//images.habbo.com/gordon/PRODUCTION-XYZ/\n", { status: 200 }),
);
expect(await resolveGordonBuildUrl()).toBe("https://images.habbo.com/gordon/PRODUCTION-XYZ/");
});
it('caches the resolved build (second call does not refetch)', async () => {
it("caches the resolved build (second call does not refetch)", async () => {
const spy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('flash.client.url=//x/gordon/B/\n', { status: 200 }))
await resolveGordonBuildUrl()
await resolveGordonBuildUrl()
expect(spy).toHaveBeenCalledTimes(1)
})
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("flash.client.url=//x/gordon/B/\n", { status: 200 }));
await resolveGordonBuildUrl();
await resolveGordonBuildUrl();
expect(spy).toHaveBeenCalledTimes(1);
});
it('throws when flash.client.url is absent', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('other=1\n', { status: 200 }))
await expect(resolveGordonBuildUrl()).rejects.toThrow()
})
})
it("throws when flash.client.url is absent", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("other=1\n", { status: 200 }));
await expect(resolveGordonBuildUrl()).rejects.toThrow();
});
});
+14 -14
View File
@@ -1,22 +1,22 @@
const EXTERNAL_VARIABLES_URL = 'https://www.habbo.com/gamedata/external_variables/1'
const CACHE_TTL = 30 * 60 * 1000
const EXTERNAL_VARIABLES_URL = "https://www.habbo.com/gamedata/external_variables/1";
const CACHE_TTL = 30 * 60 * 1000;
let gordonCache: { url: string; ts: number } | null = null
let gordonCache: { url: string; ts: number } | null = null;
export function __clearGordonCache(): void {
gordonCache = null
gordonCache = null;
}
/** Resolve the current Habbo "gordon" asset build URL (trailing slash) from external_variables. */
export async function resolveGordonBuildUrl(): Promise<string> {
const now = Date.now()
if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url
const res = await fetch(EXTERNAL_VARIABLES_URL, { signal: AbortSignal.timeout(20000) })
if (!res.ok) throw new Error(`external_variables fetch failed: ${res.status}`)
const text = await res.text()
const line = /^flash\.client\.url=(.+)$/m.exec(text)?.[1]?.trim()
if (!line) throw new Error('flash.client.url not found in external_variables')
const normalized = (line.startsWith('//') ? `https:${line}` : line).replace(/\/?$/, '/')
gordonCache = { url: normalized, ts: now }
return normalized
const now = Date.now();
if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url;
const res = await fetch(EXTERNAL_VARIABLES_URL, { signal: AbortSignal.timeout(20000) });
if (!res.ok) throw new Error(`external_variables fetch failed: ${res.status}`);
const text = await res.text();
const line = /^flash\.client\.url=(.+)$/m.exec(text)?.[1]?.trim();
if (!line) throw new Error("flash.client.url not found in external_variables");
const normalized = (line.startsWith("//") ? `https:${line}` : line).replace(/\/?$/, "/");
gordonCache = { url: normalized, ts: now };
return normalized;
}
+20 -20
View File
@@ -1,28 +1,28 @@
import { describe, expect, it } from 'vitest'
import { runSseBatch } from './sse-batch'
import { describe, expect, it } from "vitest";
import { runSseBatch } from "./sse-batch";
async function collect(res: Response): Promise<Array<Record<string, unknown>>> {
const text = await res.text()
const text = await res.text();
return text
.split('\n\n')
.filter((l) => l.startsWith('data: '))
.map((l) => JSON.parse(l.slice(6)))
.split("\n\n")
.filter((l) => l.startsWith("data: "))
.map((l) => JSON.parse(l.slice(6)));
}
describe('import/core/sse-batch', () => {
it('emits batch_start, per-item, and batch_complete with correct counts', async () => {
describe("import/core/sse-batch", () => {
it("emits batch_start, per-item, and batch_complete with correct counts", async () => {
const res = runSseBatch({
items: ['a', 'b', 'c'],
items: ["a", "b", "c"],
concurrency: 2,
worker: async (item) => ({ ok: item !== 'b', error: item === 'b' ? 'boom' : undefined }),
worker: async (item) => ({ ok: item !== "b", error: item === "b" ? "boom" : undefined }),
labelOf: (item) => item,
})
const events = await collect(res)
expect(events[0]).toMatchObject({ type: 'batch_start', total: 3, concurrency: 2 })
const done = events.filter((e) => e.type === 'item_progress' && e.status === 'done')
const failed = events.filter((e) => e.type === 'item_progress' && e.status === 'failed')
expect(done).toHaveLength(2)
expect(failed).toHaveLength(1)
expect(events.at(-1)).toMatchObject({ type: 'batch_complete', succeeded: 2, failed: 1 })
})
})
});
const events = await collect(res);
expect(events[0]).toMatchObject({ type: "batch_start", total: 3, concurrency: 2 });
const done = events.filter((e) => e.type === "item_progress" && e.status === "done");
const failed = events.filter((e) => e.type === "item_progress" && e.status === "failed");
expect(done).toHaveLength(2);
expect(failed).toHaveLength(1);
expect(events.at(-1)).toMatchObject({ type: "batch_complete", succeeded: 2, failed: 1 });
});
});
+45 -45
View File
@@ -1,16 +1,16 @@
export interface SseWorkerResult {
ok: boolean
warnings?: string[]
error?: string
ok: boolean;
warnings?: string[];
error?: string;
}
export interface RunSseBatchOptions<T> {
items: T[]
concurrency: number
items: T[];
concurrency: number;
/** Label used as the `classname` field on item_progress (kept for the existing client parser). */
labelOf: (item: T) => string
labelOf: (item: T) => string;
/** Per-item worker. `report(status)` streams intermediate progress (e.g. 'downloading'). */
worker: (item: T, index: number, report: (status: string) => void) => Promise<SseWorkerResult>
worker: (item: T, index: number, report: (status: string) => void) => Promise<SseWorkerResult>;
}
/**
@@ -18,88 +18,88 @@ export interface RunSseBatchOptions<T> {
* parser consumes: batch_start / item_progress / batch_complete.
*/
export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
const { items, labelOf, worker } = opts
const concurrency = Math.min(Math.max(opts.concurrency || 3, 1), 5)
const encoder = new TextEncoder()
const { items, labelOf, worker } = opts;
const concurrency = Math.min(Math.max(opts.concurrency || 3, 1), 5);
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
/* stream closed by client */
}
}
};
const startTime = Date.now()
send({ type: 'batch_start', total: items.length, concurrency })
const startTime = Date.now();
send({ type: "batch_start", total: items.length, concurrency });
let succeeded = 0
let failed = 0
let withWarnings = 0
let succeeded = 0;
let failed = 0;
let withWarnings = 0;
for (let i = 0; i < items.length; i += concurrency) {
const chunk = items.slice(i, i + concurrency)
const chunk = items.slice(i, i + concurrency);
await Promise.allSettled(
chunk.map(async (item, chunkIdx) => {
const index = i + chunkIdx
const classname = labelOf(item)
send({ type: 'item_progress', classname, status: 'started', index })
const index = i + chunkIdx;
const classname = labelOf(item);
send({ type: "item_progress", classname, status: "started", index });
try {
const result = await worker(item, index, (status) =>
send({ type: 'item_progress', classname, status, index }),
)
send({ type: "item_progress", classname, status, index }),
);
if (result.ok) {
succeeded++
if (result.warnings?.length) withWarnings++
succeeded++;
if (result.warnings?.length) withWarnings++;
send({
type: 'item_progress',
type: "item_progress",
classname,
status: 'done',
status: "done",
index,
warnings: result.warnings?.length ? result.warnings : undefined,
})
});
} else {
failed++
failed++;
send({
type: 'item_progress',
type: "item_progress",
classname,
status: 'failed',
status: "failed",
index,
message: result.error,
})
});
}
} catch (err) {
failed++
failed++;
send({
type: 'item_progress',
type: "item_progress",
classname,
status: 'failed',
status: "failed",
index,
message: (err as Error).message,
})
});
}
}),
)
);
}
send({
type: 'batch_complete',
type: "batch_complete",
succeeded,
failed,
warnings: withWarnings,
duration: Date.now() - startTime,
})
controller.close()
});
controller.close();
},
})
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
});
}
+29 -29
View File
@@ -1,17 +1,17 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { matchCategory, normalizeName } from '@/lib/catalog-categories'
import { getAncestors } from '@/lib/services/catalog-tree'
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
import { promises as fs } from "node:fs";
import path from "node:path";
import { matchCategory, normalizeName } from "@/lib/catalog-categories";
import { getAncestors } from "@/lib/services/catalog-tree";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
/**
* Categorize a file using matchCategory logic.
* Returns the category key.
*/
export function categorizeFile(filename: string): string {
const name = filename.replace(/\.nitro$/, '')
const cat = matchCategory(normalizeName(name))
return cat.key
const name = filename.replace(/\.nitro$/, "");
const cat = matchCategory(normalizeName(name));
return cat.key;
}
/**
@@ -20,10 +20,10 @@ export function categorizeFile(filename: string): string {
* Includes the root page in the path.
*/
export async function buildCatalogPath(pageId: number): Promise<string> {
const ancestors = await getAncestors(pageId)
if (ancestors.length === 0) return 'uncategorized'
const ancestors = await getAncestors(pageId);
if (ancestors.length === 0) return "uncategorized";
return ancestors.map((a) => slugifyPath(a.caption)).join('/')
return ancestors.map((a) => slugifyPath(a.caption)).join("/");
}
/** Sanitize a catalog page name into a valid folder segment */
@@ -31,11 +31,11 @@ export function slugifyPath(name: string): string {
return (
name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'uncategorized'
)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "uncategorized"
);
}
/**
@@ -43,11 +43,11 @@ export function slugifyPath(name: string): string {
*/
export async function scanFlatFolder(): Promise<string[]> {
try {
const { nitroDir } = await getFurniAssetDirs()
const entries = await fs.readdir(nitroDir)
return entries.filter((e) => e.endsWith('.nitro'))
const { nitroDir } = await getFurniAssetDirs();
const entries = await fs.readdir(nitroDir);
return entries.filter((e) => e.endsWith(".nitro"));
} catch {
return []
return [];
}
}
@@ -57,18 +57,18 @@ export async function scanFlatFolder(): Promise<string[]> {
*/
export async function scanAllNitroFiles(): Promise<Set<string>> {
try {
const { nitroDir } = await getFurniAssetDirs()
const entries = await fs.readdir(nitroDir, { recursive: true })
const classnames = new Set<string>()
const { nitroDir } = await getFurniAssetDirs();
const entries = await fs.readdir(nitroDir, { recursive: true });
const classnames = new Set<string>();
for (const entry of entries) {
const name = typeof entry === 'string' ? entry : String(entry)
if (name.endsWith('.nitro')) {
const basename = path.basename(name, '.nitro')
classnames.add(basename)
const name = typeof entry === "string" ? entry : String(entry);
if (name.endsWith(".nitro")) {
const basename = path.basename(name, ".nitro");
classnames.add(basename);
}
}
return classnames
return classnames;
} catch {
return new Set()
return new Set();
}
}
+1 -5
View File
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
authorizeTopupCapture,
claimTopupDelivery,
recordCreatedTopup,
} from "@/lib/services/paypal-topup";
import { authorizeTopupCapture, claimTopupDelivery, recordCreatedTopup } from "@/lib/services/paypal-topup";
describe("authorizeTopupCapture", () => {
it("rejects an order created by a different user", async () => {
+1 -3
View File
@@ -35,9 +35,7 @@ export class TopupDeliveryError extends Error {
}
}
export async function claimTopupDelivery(
claim: () => Promise<{ count: number }>,
): Promise<void> {
export async function claimTopupDelivery(claim: () => Promise<{ count: number }>): Promise<void> {
const result = await claim();
if (result.count !== 1) throw new TopupDeliveryError();
}
+108 -116
View File
@@ -1,79 +1,79 @@
import { Prisma } from '@/generated/prisma/client'
import { Prisma } from "@/generated/prisma/client";
const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/
const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/;
export const RANK_GENERAL_FIELDS = new Set([
'rank_name',
'hidden_rank',
'badge',
'job_description',
'staff_color',
'staff_background',
'level',
'room_effect',
'log_commands',
'prefix',
'prefix_color',
'auto_credits_amount',
'auto_pixels_amount',
'auto_gotw_amount',
'auto_points_amount',
])
"rank_name",
"hidden_rank",
"badge",
"job_description",
"staff_color",
"staff_background",
"level",
"room_effect",
"log_commands",
"prefix",
"prefix_color",
"auto_credits_amount",
"auto_pixels_amount",
"auto_gotw_amount",
"auto_points_amount",
]);
export interface EmulatorRankSummary {
id: number
rank_name: string
badge: string
level: number
prefix: string
prefix_color: string
hidden_rank: string
id: number;
rank_name: string;
badge: string;
level: number;
prefix: string;
prefix_color: string;
hidden_rank: string;
}
export interface EmulatorRankForEdit extends EmulatorRankSummary {
log_commands: string
room_effect: number
auto_credits_amount: number
auto_pixels_amount: number
auto_gotw_amount: number
auto_points_amount: number
permissions: Record<string, string>
permissionMaxValues: Record<string, number>
log_commands: string;
room_effect: number;
auto_credits_amount: number;
auto_pixels_amount: number;
auto_gotw_amount: number;
auto_points_amount: number;
permissions: Record<string, string>;
permissionMaxValues: Record<string, number>;
}
type RawDb = {
$queryRaw: <T = unknown>(query: Prisma.Sql) => Promise<T>
$executeRaw: (query: Prisma.Sql) => Promise<number>
$transaction: <T>(queries: Promise<T>[]) => Promise<T[]>
}
$queryRaw: <T = unknown>(query: Prisma.Sql) => Promise<T>;
$executeRaw: (query: Prisma.Sql) => Promise<number>;
$transaction: <T>(queries: Promise<T>[]) => Promise<T[]>;
};
export function getRankPermissionColumn(rankId: number) {
if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) {
throw new Error('Invalid rank id')
throw new Error("Invalid rank id");
}
return `rank_${rankId}`
return `rank_${rankId}`;
}
export function toPermissionValue(value: string | number) {
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10)
if (!Number.isFinite(parsed) || parsed < 0) return 0
return Math.min(Math.trunc(parsed), 255)
const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Math.min(Math.trunc(parsed), 255);
}
export function splitRankUpdateFields(fields: Record<string, string | number>) {
const rankFields: Record<string, string | number> = {}
const permissionFields: Record<string, number> = {}
const rankFields: Record<string, string | number> = {};
const permissionFields: Record<string, number> = {};
for (const [key, value] of Object.entries(fields)) {
if (key === 'id' || !VALID_KEY.test(key)) continue
if (key === "id" || !VALID_KEY.test(key)) continue;
if (RANK_GENERAL_FIELDS.has(key)) {
rankFields[key] = value
rankFields[key] = value;
} else {
permissionFields[key] = toPermissionValue(value)
permissionFields[key] = toPermissionValue(value);
}
}
return { rankFields, permissionFields }
return { rankFields, permissionFields };
}
export async function fetchEmulatorRankSummaries(db: RawDb): Promise<EmulatorRankSummary[]> {
@@ -83,17 +83,17 @@ export async function fetchEmulatorRankSummaries(db: RawDb): Promise<EmulatorRan
FROM permission_ranks
ORDER BY id ASC
`,
)
);
return rows.map((row) => ({
id: Number(row.id),
rank_name: String(row.rank_name ?? ''),
badge: String(row.badge ?? ''),
rank_name: String(row.rank_name ?? ""),
badge: String(row.badge ?? ""),
level: Number(row.level ?? 1),
prefix: String(row.prefix ?? ''),
prefix_color: String(row.prefix_color ?? ''),
hidden_rank: String(row.hidden_rank ?? '0'),
}))
prefix: String(row.prefix ?? ""),
prefix_color: String(row.prefix_color ?? ""),
hidden_rank: String(row.hidden_rank ?? "0"),
}));
}
export async function fetchEmulatorRankForEdit(
@@ -108,10 +108,10 @@ export async function fetchEmulatorRankForEdit(
FROM permission_ranks
WHERE id = ${rankId}
`,
)
if (rankRows.length === 0) return null
);
if (rankRows.length === 0) return null;
const rankColumn = getRankPermissionColumn(rankId)
const rankColumn = getRankPermissionColumn(rankId);
const permissionRows = await db.$queryRaw<
{ permission_key: string; max_value: number; value: number | string | null }[]
>(
@@ -120,25 +120,25 @@ export async function fetchEmulatorRankForEdit(
FROM permission_definitions
ORDER BY permission_key ASC
`,
)
);
const permissions: Record<string, string> = {}
const permissionMaxValues: Record<string, number> = {}
const permissions: Record<string, string> = {};
const permissionMaxValues: Record<string, number> = {};
for (const row of permissionRows) {
permissions[row.permission_key] = String(row.value ?? 0)
permissionMaxValues[row.permission_key] = Number(row.max_value ?? 1)
permissions[row.permission_key] = String(row.value ?? 0);
permissionMaxValues[row.permission_key] = Number(row.max_value ?? 1);
}
const row = rankRows[0]
const row = rankRows[0];
return {
id: Number(row.id),
rank_name: String(row.rank_name ?? ''),
badge: String(row.badge ?? ''),
rank_name: String(row.rank_name ?? ""),
badge: String(row.badge ?? ""),
level: Number(row.level ?? 1),
prefix: String(row.prefix ?? ''),
prefix_color: String(row.prefix_color ?? ''),
hidden_rank: String(row.hidden_rank ?? '0'),
log_commands: String(row.log_commands ?? '0'),
prefix: String(row.prefix ?? ""),
prefix_color: String(row.prefix_color ?? ""),
hidden_rank: String(row.hidden_rank ?? "0"),
log_commands: String(row.log_commands ?? "0"),
room_effect: Number(row.room_effect ?? 0),
auto_credits_amount: Number(row.auto_credits_amount ?? 0),
auto_pixels_amount: Number(row.auto_pixels_amount ?? 0),
@@ -146,7 +146,7 @@ export async function fetchEmulatorRankForEdit(
auto_points_amount: Number(row.auto_points_amount ?? 0),
permissions,
permissionMaxValues,
}
};
}
export async function createEmulatorRank(
@@ -155,10 +155,10 @@ export async function createEmulatorRank(
): Promise<number> {
const rows = await db.$queryRaw<{ next_id: number }[]>(
Prisma.sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
)
const id = Number(rows[0]?.next_id ?? 1)
await ensurePermissionRankColumn(db, id)
await resetPermissionRankColumn(db, id)
);
const id = Number(rows[0]?.next_id ?? 1);
await ensurePermissionRankColumn(db, id);
await resetPermissionRankColumn(db, id);
await db.$transaction([
db.$executeRaw(
@@ -174,34 +174,30 @@ export async function createEmulatorRank(
ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level)
`,
),
])
]);
return id
return id;
}
export async function deleteEmulatorRank(db: RawDb, rankId: number) {
await resetPermissionRankColumn(db, rankId)
await resetPermissionRankColumn(db, rankId);
await db.$transaction([
db.$executeRaw(Prisma.sql`DELETE FROM permission_ranks WHERE id = ${rankId}`),
db.$executeRaw(Prisma.sql`DELETE FROM permissions WHERE id = ${rankId}`),
])
]);
}
export async function updateEmulatorRank(
db: RawDb,
rankId: number,
fields: Record<string, string | number>,
) {
const { rankFields, permissionFields } = splitRankUpdateFields(fields)
export async function updateEmulatorRank(db: RawDb, rankId: number, fields: Record<string, string | number>) {
const { rankFields, permissionFields } = splitRankUpdateFields(fields);
if (Object.keys(rankFields).length > 0) {
await updateTableFields(db, 'permission_ranks', rankId, rankFields)
await updateLegacyPermissionFields(db, rankId, rankFields)
await updateTableFields(db, "permission_ranks", rankId, rankFields);
await updateLegacyPermissionFields(db, rankId, rankFields);
}
if (Object.keys(permissionFields).length > 0) {
await updatePermissionDefinitionValues(db, rankId, permissionFields)
await updateLegacyPermissionFields(db, rankId, permissionFields)
await updatePermissionDefinitionValues(db, rankId, permissionFields);
await updateLegacyPermissionFields(db, rankId, permissionFields);
}
}
@@ -213,14 +209,14 @@ async function updateTableFields(
) {
const assignments = Object.entries(fields).map(
([key, value]) => Prisma.sql`${Prisma.raw(quoteIdentifier(key))} = ${value}`,
)
);
return db.$executeRaw(
Prisma.sql`
UPDATE ${Prisma.raw(quoteIdentifier(table))}
SET ${Prisma.join(assignments, ', ')}
SET ${Prisma.join(assignments, ", ")}
WHERE id = ${rankId}
`,
)
);
}
async function updatePermissionDefinitionValues(
@@ -228,24 +224,22 @@ async function updatePermissionDefinitionValues(
rankId: number,
permissionFields: Record<string, number>,
) {
const rankColumn = getRankPermissionColumn(rankId)
const entries = Object.entries(permissionFields)
const cases = entries.map(
([key, value]) => Prisma.sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
)
const keys = entries.map(([key]) => key)
const rankColumn = getRankPermissionColumn(rankId);
const entries = Object.entries(permissionFields);
const cases = entries.map(([key, value]) => Prisma.sql`WHEN ${key} THEN LEAST(${value}, max_value)`);
const keys = entries.map(([key]) => key);
return db.$executeRaw(
Prisma.sql`
UPDATE permission_definitions
SET ${Prisma.raw(quoteIdentifier(rankColumn))} =
CASE permission_key
${Prisma.join(cases, ' ')}
${Prisma.join(cases, " ")}
ELSE ${Prisma.raw(quoteIdentifier(rankColumn))}
END
WHERE permission_key IN (${Prisma.join(keys)})
`,
)
);
}
async function updateLegacyPermissionFields(
@@ -253,16 +247,14 @@ async function updateLegacyPermissionFields(
rankId: number,
fields: Record<string, string | number>,
) {
const columns = await getTableColumns(db, 'permissions')
const existingFields = Object.fromEntries(
Object.entries(fields).filter(([key]) => columns.has(key)),
)
if (Object.keys(existingFields).length === 0) return 0
return updateTableFields(db, 'permissions', rankId, existingFields)
const columns = await getTableColumns(db, "permissions");
const existingFields = Object.fromEntries(Object.entries(fields).filter(([key]) => columns.has(key)));
if (Object.keys(existingFields).length === 0) return 0;
return updateTableFields(db, "permissions", rankId, existingFields);
}
async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
const rankColumn = getRankPermissionColumn(rankId)
const rankColumn = getRankPermissionColumn(rankId);
const exists = await db.$queryRaw<{ c: bigint | number }[]>(
Prisma.sql`
SELECT COUNT(*) AS c
@@ -271,25 +263,25 @@ async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
AND table_name = 'permission_definitions'
AND column_name = ${rankColumn}
`,
)
if (Number(exists[0]?.c ?? 0) > 0) return
);
if (Number(exists[0]?.c ?? 0) > 0) return;
await db.$executeRaw(
Prisma.sql`
ALTER TABLE permission_definitions
ADD COLUMN ${Prisma.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0
`,
)
);
}
async function resetPermissionRankColumn(db: RawDb, rankId: number) {
const rankColumn = getRankPermissionColumn(rankId)
const rankColumn = getRankPermissionColumn(rankId);
await db.$executeRaw(
Prisma.sql`
UPDATE permission_definitions
SET ${Prisma.raw(quoteIdentifier(rankColumn))} = 0
`,
)
);
}
async function getTableColumns(db: RawDb, table: string) {
@@ -300,11 +292,11 @@ async function getTableColumns(db: RawDb, table: string) {
WHERE table_schema = DATABASE()
AND table_name = ${table}
`,
)
return new Set(rows.map((row) => row.column_name))
);
return new Set(rows.map((row) => row.column_name));
}
function quoteIdentifier(identifier: string) {
if (!VALID_KEY.test(identifier)) throw new Error('Invalid identifier')
return `\`${identifier}\``
if (!VALID_KEY.test(identifier)) throw new Error("Invalid identifier");
return `\`${identifier}\``;
}
+158 -174
View File
@@ -1,57 +1,57 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { cropRgba, decodePng } from '@/lib/services/imager/png-decode'
import { encodePng, parseNitroBundle } from '@/lib/services/swf/nitro-builder'
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { cropRgba, decodePng } from "@/lib/services/imager/png-decode";
import { encodePng, parseNitroBundle } from "@/lib/services/swf/nitro-builder";
// Pet .nitro can live in the CMS import target or the bundled asset set.
const PET_DIRS = [
path.join(/*turbopackIgnore: true*/ process.cwd(), 'public/nitro-assets/bundled/pet'),
path.join(/*turbopackIgnore: true*/ process.cwd(), 'public/Gamedata/bundled/pet'),
]
path.join(/*turbopackIgnore: true*/ process.cwd(), "public/nitro-assets/bundled/pet"),
path.join(/*turbopackIgnore: true*/ process.cwd(), "public/Gamedata/bundled/pet"),
];
interface Rect {
x: number
y: number
w: number
h: number
x: number;
y: number;
w: number;
h: number;
}
interface Asset {
x?: number
y?: number
source?: string
x?: number;
y?: number;
source?: string;
}
interface AnimLayer {
frameSequences?: Record<string, { frames?: Record<string, { id?: number }> }>
frameSequences?: Record<string, { frames?: Record<string, { id?: number }> }>;
}
interface Visualization {
size?: number
layerCount?: number
animations?: Record<string, { layers?: Record<string, AnimLayer> }>
size?: number;
layerCount?: number;
animations?: Record<string, { layers?: Record<string, AnimLayer> }>;
}
interface NitroJson {
name?: string
assets?: Record<string, Asset>
spritesheet?: { frames?: Record<string, { frame: Rect; rotated?: boolean }> }
visualizations?: Visualization[]
name?: string;
assets?: Record<string, Asset>;
spritesheet?: { frames?: Record<string, { frame: Rect; rotated?: boolean }> };
visualizations?: Visualization[];
}
interface LayerSpec {
letter: string
frameId: number
letter: string;
frameId: number;
}
// A pet is rendered as several stacked layers (a,b,c,d = body parts; sd = shadow).
// Directions present on pets are a subset of 0..7; 2 reads best as a portrait.
const DIR_PREFERENCE = [2, 0, 4, 3, 1, 7, 6, 5]
const DIR_PREFERENCE = [2, 0, 4, 3, 1, 7, 6, 5];
// Generated PNG icons are tiny; cache them in-process keyed by lib.
const iconCache = new Map<string, Buffer | null>()
const iconCache = new Map<string, Buffer | null>();
function findNitro(lib: string): string | null {
for (const dir of PET_DIRS) {
const p = path.join(/*turbopackIgnore: true*/ dir, `${lib}.nitro`)
if (existsSync(/*turbopackIgnore: true*/ p)) return p
const p = path.join(/*turbopackIgnore: true*/ dir, `${lib}.nitro`);
if (existsSync(/*turbopackIgnore: true*/ p)) return p;
}
return null
return null;
}
/**
@@ -62,31 +62,27 @@ function findNitro(lib: string): string | null {
* into a junk pile. Falls back to layers a–d at frame 0 when no visualization.
*/
function idleLayerSpecs(json: NitroJson): LayerSpec[] {
const viz = json.visualizations?.find((v) => v.size === 64)
const viz = json.visualizations?.find((v) => v.size === 64);
if (!viz || !viz.layerCount) {
return ['a', 'b', 'c', 'd'].map((letter) => ({ letter, frameId: 0 }))
return ["a", "b", "c", "d"].map((letter) => ({ letter, frameId: 0 }));
}
const anim0 = viz.animations?.['0']
const raw: { letter: string; alPresent: boolean; hasSeq: boolean; frameId: number }[] = []
let anyWithSeq = false
const anim0 = viz.animations?.["0"];
const raw: { letter: string; alPresent: boolean; hasSeq: boolean; frameId: number }[] = [];
let anyWithSeq = false;
for (let i = 0; i < viz.layerCount && i < 4; i++) {
const letter = String.fromCharCode(97 + i) // a, b, c, d
const al = anim0?.layers?.[String(i)]
const seqKeys = al ? Object.keys(al.frameSequences ?? {}) : []
const hasSeq = seqKeys.length > 0
if (hasSeq) anyWithSeq = true
const frameId = hasSeq
? Number(al?.frameSequences?.[seqKeys[0]]?.frames?.['0']?.id ?? 0) || 0
: 0
raw.push({ letter, alPresent: !!al, hasSeq, frameId })
const letter = String.fromCharCode(97 + i); // a, b, c, d
const al = anim0?.layers?.[String(i)];
const seqKeys = al ? Object.keys(al.frameSequences ?? {}) : [];
const hasSeq = seqKeys.length > 0;
if (hasSeq) anyWithSeq = true;
const frameId = hasSeq ? Number(al?.frameSequences?.[seqKeys[0]]?.frames?.["0"]?.id ?? 0) || 0 : 0;
raw.push({ letter, alPresent: !!al, hasSeq, frameId });
}
// No idle animation defined anywhere → render every layer's static frame 0.
if (!anyWithSeq) return raw.map((r) => ({ letter: r.letter, frameId: 0 }))
if (!anyWithSeq) return raw.map((r) => ({ letter: r.letter, frameId: 0 }));
// Idle animation exists → include only layers it drives (or layers with no
// entry); skip layers explicitly muted in idle (empty frameSequences).
return raw
.filter((r) => r.hasSeq || !r.alPresent)
.map((r) => ({ letter: r.letter, frameId: r.frameId }))
return raw.filter((r) => r.hasSeq || !r.alPresent).map((r) => ({ letter: r.letter, frameId: r.frameId }));
}
/** Resolve a layer asset to its spritesheet rect + placement offset. */
@@ -97,42 +93,34 @@ function resolveSprite(
dir: number,
frameId: number,
): { rect: Rect; ox: number; oy: number } | null {
const assetName = `${lib}_64_${layer}_${dir}_${frameId}`
const asset = json.assets?.[assetName]
if (!asset) return null
const assetName = `${lib}_64_${layer}_${dir}_${frameId}`;
const asset = json.assets?.[assetName];
if (!asset) return null;
// `source` aliases another asset's pixels; offset stays on this asset.
const pixelAsset = asset.source ?? assetName
const frameKey = `${lib}_${pixelAsset}`
const f = json.spritesheet?.frames?.[frameKey]
if (!f || f.rotated || f.frame.w <= 0 || f.frame.h <= 0) return null
return { rect: f.frame, ox: asset.x ?? 0, oy: asset.y ?? 0 }
const pixelAsset = asset.source ?? assetName;
const frameKey = `${lib}_${pixelAsset}`;
const f = json.spritesheet?.frames?.[frameKey];
if (!f || f.rotated || f.frame.w <= 0 || f.frame.h <= 0) return null;
return { rect: f.frame, ox: asset.x ?? 0, oy: asset.y ?? 0 };
}
/** Alpha-over `src` (w×h RGBA) onto `dst` at (dx,dy) within a dstW-wide canvas. */
function blit(
dst: Buffer,
dstW: number,
src: Buffer,
w: number,
h: number,
dx: number,
dy: number,
): void {
function blit(dst: Buffer, dstW: number, src: Buffer, w: number, h: number, dx: number, dy: number): void {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const s = (y * w + x) * 4
const sa = src[s + 3]
if (sa === 0) continue
const d = ((dy + y) * dstW + (dx + x)) * 4
const s = (y * w + x) * 4;
const sa = src[s + 3];
if (sa === 0) continue;
const d = ((dy + y) * dstW + (dx + x)) * 4;
if (sa === 255) {
src.copy(dst, d, s, s + 4)
continue
src.copy(dst, d, s, s + 4);
continue;
}
const ia = 255 - sa
dst[d] = (src[s] * sa + dst[d] * ia) / 255
dst[d + 1] = (src[s + 1] * sa + dst[d + 1] * ia) / 255
dst[d + 2] = (src[s + 2] * sa + dst[d + 2] * ia) / 255
dst[d + 3] = sa + (dst[d + 3] * ia) / 255
const ia = 255 - sa;
dst[d] = (src[s] * sa + dst[d] * ia) / 255;
dst[d + 1] = (src[s + 1] * sa + dst[d + 1] * ia) / 255;
dst[d + 2] = (src[s + 2] * sa + dst[d + 2] * ia) / 255;
dst[d + 3] = sa + (dst[d + 3] * ia) / 255;
}
}
}
@@ -179,22 +167,22 @@ const DEFAULT_PET_COLOR: Record<string, [number, number, number]> = {
monsterplant: [120, 170, 96],
pterosaur: [128, 176, 108],
velociraptor: [128, 176, 108],
}
const FALLBACK_PET_COLOR: [number, number, number] = [190, 158, 120]
};
const FALLBACK_PET_COLOR: [number, number, number] = [190, 158, 120];
function recolorMask(rgba: Buffer, [tr, tg, tb]: [number, number, number]): void {
for (let i = 0; i < rgba.length; i += 4) {
if (rgba[i + 3] === 0) continue
const r = rgba[i]
const g = rgba[i + 1]
const b = rgba[i + 2]
if (rgba[i + 3] === 0) continue;
const r = rgba[i];
const g = rgba[i + 1];
const b = rgba[i + 2];
// Placeholder mask: highly saturated toward magenta/red (green far below
// red/blue). Tint to the breed colour, preserving the mask's shading.
if (Math.max(r, b) - g > 60) {
const l = Math.max(r, g, b) / 255
rgba[i] = Math.round(tr * l)
rgba[i + 1] = Math.round(tg * l)
rgba[i + 2] = Math.round(tb * l)
const l = Math.max(r, g, b) / 255;
rgba[i] = Math.round(tr * l);
rgba[i + 1] = Math.round(tg * l);
rgba[i + 2] = Math.round(tb * l);
}
}
}
@@ -205,126 +193,122 @@ function recolorMask(rgba: Buffer, [tr, tg, tb]: [number, number, number]): void
* that don't align at this frame), then tight-crop to it. Turns "body + floating
* junk" into a clean centered icon.
*/
function keepLargestBlob(
rgba: Buffer,
w: number,
h: number,
): { rgba: Buffer; w: number; h: number } {
const n = w * h
const label = new Int32Array(n).fill(-1)
const opaque = (i: number) => rgba[i * 4 + 3] > 30
const comps: { id: number; size: number }[] = []
function keepLargestBlob(rgba: Buffer, w: number, h: number): { rgba: Buffer; w: number; h: number } {
const n = w * h;
const label = new Int32Array(n).fill(-1);
const opaque = (i: number) => rgba[i * 4 + 3] > 30;
const comps: { id: number; size: number }[] = [];
for (let start = 0; start < n; start++) {
if (label[start] !== -1 || !opaque(start)) continue
const id = comps.length
let size = 0
const stack = [start]
label[start] = id
if (label[start] !== -1 || !opaque(start)) continue;
const id = comps.length;
let size = 0;
const stack = [start];
label[start] = id;
while (stack.length) {
const p = stack.pop() as number
size++
const x = p % w
const y = (p / w) | 0
const p = stack.pop() as number;
size++;
const x = p % w;
const y = (p / w) | 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx
const ny = y + dy
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue
const np = ny * w + nx
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const np = ny * w + nx;
if (label[np] === -1 && opaque(np)) {
label[np] = id
stack.push(np)
label[np] = id;
stack.push(np);
}
}
}
}
comps.push({ id, size })
comps.push({ id, size });
}
if (comps.length === 0) return { rgba, w, h }
let main = comps[0]
for (const c of comps) if (c.size > main.size) main = c
if (comps.length === 0) return { rgba, w, h };
let main = comps[0];
for (const c of comps) if (c.size > main.size) main = c;
// Tight-crop bbox of the main blob.
let minX = w
let minY = h
let maxX = -1
let maxY = -1
let minX = w;
let minY = h;
let maxX = -1;
let maxY = -1;
for (let p = 0; p < n; p++) {
if (label[p] !== main.id) continue
const x = p % w
const y = (p / w) | 0
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
if (label[p] !== main.id) continue;
const x = p % w;
const y = (p / w) | 0;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const nw = maxX - minX + 1
const nh = maxY - minY + 1
const out = Buffer.alloc(nw * nh * 4)
const nw = maxX - minX + 1;
const nh = maxY - minY + 1;
const out = Buffer.alloc(nw * nh * 4);
for (let y = 0; y < nh; y++) {
for (let x = 0; x < nw; x++) {
const sp = (minY + y) * w + (minX + x)
const sp = (minY + y) * w + (minX + x);
if (label[sp] === main.id) {
const o = (y * nw + x) * 4
const si = sp * 4
rgba.copy(out, o, si, si + 4)
const o = (y * nw + x) * 4;
const si = sp * 4;
rgba.copy(out, o, si, si + 4);
}
}
}
return { rgba: out, w: nw, h: nh }
return { rgba: out, w: nw, h: nh };
}
/** Composite all non-shadow body layers of one direction's base pose into a PNG. */
function composite(json: NitroJson, sheet: { width: number; rgba: Buffer }): Buffer | null {
const lib = (json.name ?? '').toString()
if (!lib) return null
const lib = (json.name ?? "").toString();
if (!lib) return null;
type Sprite = { rect: Rect; ox: number; oy: number }
type Sprite = { rect: Rect; ox: number; oy: number };
const bestDir = (specs: LayerSpec[]): Sprite[] => {
let best: Sprite[] = []
let best: Sprite[] = [];
for (const dir of DIR_PREFERENCE) {
const resolved = specs
.map((s) => resolveSprite(json, lib, s.letter, dir, s.frameId))
.filter((s): s is Sprite => s !== null)
.filter((s): s is Sprite => s !== null);
if (resolved.length > best.length) {
best = resolved
if (dir === DIR_PREFERENCE[0]) break // front pose is good enough
best = resolved;
if (dir === DIR_PREFERENCE[0]) break; // front pose is good enough
}
}
return best
}
return best;
};
// Prefer the idle-pose layers; if nothing resolves, fall back to every
// layer's static frame 0 (covers pets whose frame ids don't line up).
let sprites = bestDir(idleLayerSpecs(json))
let sprites = bestDir(idleLayerSpecs(json));
if (sprites.length === 0) {
sprites = bestDir(['a', 'b', 'c', 'd'].map((letter) => ({ letter, frameId: 0 })))
sprites = bestDir(["a", "b", "c", "d"].map((letter) => ({ letter, frameId: 0 })));
}
if (sprites.length === 0) return null
if (sprites.length === 0) return null;
// Union bounding box in pet-local coords.
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const s of sprites) {
minX = Math.min(minX, s.ox)
minY = Math.min(minY, s.oy)
maxX = Math.max(maxX, s.ox + s.rect.w)
maxY = Math.max(maxY, s.oy + s.rect.h)
minX = Math.min(minX, s.ox);
minY = Math.min(minY, s.oy);
maxX = Math.max(maxX, s.ox + s.rect.w);
maxY = Math.max(maxY, s.oy + s.rect.h);
}
const cw = maxX - minX
const ch = maxY - minY
if (cw <= 0 || ch <= 0 || cw > 256 || ch > 256) return null
const cw = maxX - minX;
const ch = maxY - minY;
if (cw <= 0 || ch <= 0 || cw > 256 || ch > 256) return null;
const canvas = Buffer.alloc(cw * ch * 4) // transparent
const canvas = Buffer.alloc(cw * ch * 4); // transparent
for (const s of sprites) {
const px = cropRgba(sheet.rgba, sheet.width, s.rect.x, s.rect.y, s.rect.w, s.rect.h)
blit(canvas, cw, px, s.rect.w, s.rect.h, s.ox - minX, s.oy - minY)
const px = cropRgba(sheet.rgba, sheet.width, s.rect.x, s.rect.y, s.rect.w, s.rect.h);
blit(canvas, cw, px, s.rect.w, s.rect.h, s.ox - minX, s.oy - minY);
}
recolorMask(canvas, DEFAULT_PET_COLOR[lib] ?? FALLBACK_PET_COLOR)
const blob = keepLargestBlob(canvas, cw, ch)
return encodePng(blob.w, blob.h, blob.rgba)
recolorMask(canvas, DEFAULT_PET_COLOR[lib] ?? FALLBACK_PET_COLOR);
const blob = keepLargestBlob(canvas, cw, ch);
return encodePng(blob.w, blob.h, blob.rgba);
}
/**
@@ -332,23 +316,23 @@ function composite(json: NitroJson, sheet: { width: number; rgba: Buffer }): Buf
* if the pet has no bundle / no usable frames. Cached in-process.
*/
export function getPetIconPng(lib: string): Buffer | null {
if (!/^[A-Za-z0-9_]+$/.test(lib)) return null
const cached = iconCache.get(lib)
if (cached !== undefined) return cached
if (!/^[A-Za-z0-9_]+$/.test(lib)) return null;
const cached = iconCache.get(lib);
if (cached !== undefined) return cached;
const nitroPath = findNitro(lib)
const nitroPath = findNitro(lib);
if (!nitroPath) {
iconCache.set(lib, null)
return null
iconCache.set(lib, null);
return null;
}
try {
const { json, png } = parseNitroBundle(readFileSync(/*turbopackIgnore: true*/ nitroPath))
const sheet = decodePng(png)
const icon = composite(json as NitroJson, sheet)
iconCache.set(lib, icon)
return icon
const { json, png } = parseNitroBundle(readFileSync(/*turbopackIgnore: true*/ nitroPath));
const sheet = decodePng(png);
const icon = composite(json as NitroJson, sheet);
iconCache.set(lib, icon);
return icon;
} catch {
iconCache.set(lib, null)
return null
iconCache.set(lib, null);
return null;
}
}
+30 -31
View File
@@ -1,48 +1,47 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from "vitest";
vi.mock('@/lib/services/import/core/download', () => ({
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
}))
vi.mock('@/lib/services/pet-source', () => ({
CANONICAL_PETS: ['dog', 'cat'],
}));
vi.mock("@/lib/services/pet-source", () => ({
CANONICAL_PETS: ["dog", "cat"],
resolvePetSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
}))
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from('nitro'), warnings: [] }))
vi.mock('@/lib/services/swf-to-nitro', () => ({
convertSwfToNitro: (...a: unknown[]) =>
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}))
vi.mock('node:fs', async (orig) => {
const real = (await orig()) as typeof import('node:fs')
}));
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from("nitro"), warnings: [] }));
vi.mock("@/lib/services/swf-to-nitro", () => ({
convertSwfToNitro: (...a: unknown[]) => convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}));
vi.mock("node:fs", async (orig) => {
const real = (await orig()) as typeof import("node:fs");
return {
...real,
existsSync: vi.fn(() => false),
promises: {
...real.promises,
readFile: vi.fn(async () => Buffer.from('CWS')),
readFile: vi.fn(async () => Buffer.from("CWS")),
writeFile: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
},
}
})
};
});
import { importSinglePet } from './pet-import'
import { importSinglePet } from "./pet-import";
describe('pet-import.importSinglePet', () => {
it('downloads, converts, writes .nitro', async () => {
const r = await importSinglePet({ lib: 'dog' })
expect(r.ok).toBe(true)
expect(convertSwfToNitro).toHaveBeenCalled()
})
describe("pet-import.importSinglePet", () => {
it("downloads, converts, writes .nitro", async () => {
const r = await importSinglePet({ lib: "dog" });
expect(r.ok).toBe(true);
expect(convertSwfToNitro).toHaveBeenCalled();
});
it('fails cleanly when conversion throws', async () => {
it("fails cleanly when conversion throws", async () => {
convertSwfToNitro.mockImplementationOnce(() => {
throw new Error('bad swf')
})
const r = await importSinglePet({ lib: 'cat' })
expect(r.ok).toBe(false)
expect(r.error).toContain('conversion')
})
})
throw new Error("bad swf");
});
const r = await importSinglePet({ lib: "cat" });
expect(r.ok).toBe(false);
expect(r.error).toContain("conversion");
});
});
+44 -44
View File
@@ -1,102 +1,102 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { downloadFile } from '@/lib/services/import/core/download'
import { CANONICAL_PETS, resolvePetSwfUrl } from '@/lib/services/pet-source'
import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { downloadFile } from "@/lib/services/import/core/download";
import { CANONICAL_PETS, resolvePetSwfUrl } from "@/lib/services/pet-source";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
export interface PetListItem {
lib: string
nitroExists: boolean
lib: string;
nitroExists: boolean;
}
export interface ImportPetResult {
ok: boolean
lib: string
warnings: string[]
error?: string
ok: boolean;
lib: string;
warnings: string[];
error?: string;
}
export const PET_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/pet',
)
export const PET_SWF_DIR = path.join(/*turbopackIgnore: true*/ process.cwd(), 'public/swf/gordon')
"public/nitro-assets/bundled/pet",
);
export const PET_SWF_DIR = path.join(/*turbopackIgnore: true*/ process.cwd(), "public/swf/gordon");
export async function ensurePetDirs(): Promise<void> {
await Promise.all(
[PET_NITRO_DIR, PET_SWF_DIR]
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
.map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })),
)
);
}
function nitroPathFor(lib: string): string {
return path.join(/*turbopackIgnore: true*/ PET_NITRO_DIR, `${lib}.nitro`)
return path.join(/*turbopackIgnore: true*/ PET_NITRO_DIR, `${lib}.nitro`);
}
export async function importSinglePet(params: {
lib: string
onProgress?: (status: string) => void
lib: string;
onProgress?: (status: string) => void;
}): Promise<ImportPetResult> {
const { lib, onProgress } = params
const warnings: string[] = []
await ensurePetDirs()
const { lib, onProgress } = params;
const warnings: string[] = [];
await ensurePetDirs();
onProgress?.('downloading')
const swfPath = path.join(/*turbopackIgnore: true*/ PET_SWF_DIR, `${lib}.swf`)
let swfUrl: string
onProgress?.("downloading");
const swfPath = path.join(/*turbopackIgnore: true*/ PET_SWF_DIR, `${lib}.swf`);
let swfUrl: string;
try {
swfUrl = await resolvePetSwfUrl(lib)
swfUrl = await resolvePetSwfUrl(lib);
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `Could not resolve SWF URL: ${(err as Error).message}`,
}
};
}
const dl = await downloadFile(swfUrl, swfPath, { validate: 'swf' })
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` }
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` };
onProgress?.('converting')
onProgress?.("converting");
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
const result = convertSwfToNitro(swfBuffer, lib)
await fs.writeFile(/*turbopackIgnore: true*/ nitroPathFor(lib), result.bundle)
if (result.warnings?.length) warnings.push(...result.warnings)
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
const result = convertSwfToNitro(swfBuffer, lib);
await fs.writeFile(/*turbopackIgnore: true*/ nitroPathFor(lib), result.bundle);
if (result.warnings?.length) warnings.push(...result.warnings);
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
}
};
}
return { ok: true, lib, warnings }
return { ok: true, lib, warnings };
}
export async function deleteImportedPet(lib: string): Promise<{ deletedFile: boolean }> {
const p = nitroPathFor(lib)
const p = nitroPathFor(lib);
if (existsSync(/*turbopackIgnore: true*/ p)) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {})
return { deletedFile: true }
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
return { deletedFile: true };
}
return { deletedFile: false }
return { deletedFile: false };
}
export function getPetList(search: string): PetListItem[] {
const term = search.trim().toLowerCase()
const term = search.trim().toLowerCase();
const list = CANONICAL_PETS.map((lib) => ({
lib,
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
}))
return term ? list.filter((p) => p.lib.toLowerCase().includes(term)) : list
}));
return term ? list.filter((p) => p.lib.toLowerCase().includes(term)) : list;
}
export function getPetStats(): { total: number; imported: number; missing: number } {
const imported = CANONICAL_PETS.filter((lib) =>
existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
).length
return { total: CANONICAL_PETS.length, imported, missing: CANONICAL_PETS.length - imported }
).length;
return { total: CANONICAL_PETS.length, imported, missing: CANONICAL_PETS.length - imported };
}
+43 -43
View File
@@ -1,52 +1,52 @@
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
import { siteSettings } from '@/lib/services/site-settings'
import { resolveGordonBuildUrl } from "@/lib/services/import/core/gordon";
import { siteSettings } from "@/lib/services/site-settings";
/**
* The canonical Habbo pet set (matches renderer-config.json `pet.types`).
* The lib/filename for each is the name itself → `{name}.nitro`.
*/
export const CANONICAL_PETS: string[] = [
'dog',
'cat',
'croco',
'terrier',
'bear',
'pig',
'lion',
'rhino',
'spider',
'turtle',
'chicken',
'frog',
'dragon',
'monster',
'monkey',
'horse',
'monsterplant',
'bunnyeaster',
'bunnyevil',
'bunnydepressed',
'bunnylove',
'pigeongood',
'pigeonevil',
'demonmonkey',
'bearbaby',
'terrierbaby',
'gnome',
'kittenbaby',
'puppybaby',
'pigletbaby',
'haloompa',
'fools',
'pterosaur',
'velociraptor',
'cow',
'elephants',
]
"dog",
"cat",
"croco",
"terrier",
"bear",
"pig",
"lion",
"rhino",
"spider",
"turtle",
"chicken",
"frog",
"dragon",
"monster",
"monkey",
"horse",
"monsterplant",
"bunnyeaster",
"bunnyevil",
"bunnydepressed",
"bunnylove",
"pigeongood",
"pigeonevil",
"demonmonkey",
"bearbaby",
"terrierbaby",
"gnome",
"kittenbaby",
"puppybaby",
"pigletbaby",
"haloompa",
"fools",
"pterosaur",
"velociraptor",
"cow",
"elephants",
];
export async function resolvePetSwfUrl(lib: string): Promise<string> {
const override = ((await siteSettings.get('pet_swf_base_url', '')) ?? '').trim()
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
const gordon = await resolveGordonBuildUrl()
return `${gordon}${lib}.swf`
const override = ((await siteSettings.get("pet_swf_base_url", "")) ?? "").trim();
if (override) return `${override.replace(/\/+$/, "")}/${lib}.swf`;
const gordon = await resolveGordonBuildUrl();
return `${gordon}${lib}.swf`;
}
+28 -28
View File
@@ -13,40 +13,40 @@
* The public URL prefix is configurable via the CMS setting
* `soundtrack_base_url` — see site-settings.ts defaults.
*/
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { parseBuffer } from 'music-metadata'
import { promises as fs } from "node:fs";
import path from "node:path";
import { parseBuffer } from "music-metadata";
// ── Constants ──────────────────────────────────────────────────────
/** On-disk directory where MP3s live. Fixed (not configurable from UI to
* avoid any path traversal risk). */
export const SOUNDTRACK_DIR = path.join(process.cwd(), 'public', 'swf', 'dcr', 'hof_furni', 'mp3')
export const SOUNDTRACK_DIR = path.join(process.cwd(), "public", "swf", "dcr", "hof_furni", "mp3");
/** Default public URL prefix (with trailing slash). Can be overridden via
* the `soundtrack_base_url` CMS setting. */
export const DEFAULT_SOUNDTRACK_BASE_URL = '/swf/dcr/hof_furni/mp3/'
export const DEFAULT_SOUNDTRACK_BASE_URL = "/swf/dcr/hof_furni/mp3/";
/** 10 MB hard limit per upload. */
export const MAX_SOUNDTRACK_SIZE = 10 * 1024 * 1024
export const MAX_SOUNDTRACK_SIZE = 10 * 1024 * 1024;
// ── File paths ─────────────────────────────────────────────────────
/** Reject codes that would escape the sounds directory or use unsafe chars. */
export function isSafeSoundtrackCode(code: string): boolean {
return /^[a-zA-Z0-9_-]{1,32}$/.test(code)
return /^[a-zA-Z0-9_-]{1,32}$/.test(code);
}
export function getSoundtrackPath(code: string): string {
if (!isSafeSoundtrackCode(code)) {
throw new Error(`Unsafe soundtrack code: ${code}`)
throw new Error(`Unsafe soundtrack code: ${code}`);
}
return path.join(SOUNDTRACK_DIR, `${code}.mp3`)
return path.join(SOUNDTRACK_DIR, `${code}.mp3`);
}
/** Ensure the sounds directory exists (mkdir -p). */
export async function ensureSoundtrackDir(): Promise<void> {
await fs.mkdir(SOUNDTRACK_DIR, { recursive: true })
await fs.mkdir(SOUNDTRACK_DIR, { recursive: true });
}
// ── Validation ─────────────────────────────────────────────────────
@@ -62,18 +62,18 @@ export async function ensureSoundtrackDir(): Promise<void> {
* files masquerading as .mp3 (e.g. renamed .exe).
*/
export function validateMp3Bytes(buf: Buffer): boolean {
if (buf.length < 4) return false
if (buf.length < 4) return false;
// ID3v2 tag header
if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return true
if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return true;
// MPEG sync: 0xFF followed by 0xFA/0xFB/0xFE/0xF3/0xF2
if (buf[0] === 0xff) {
const b = buf[1]
if (b === 0xfa || b === 0xfb || b === 0xfe || b === 0xf3 || b === 0xf2) return true
const b = buf[1];
if (b === 0xfa || b === 0xfb || b === 0xfe || b === 0xf3 || b === 0xf2) return true;
}
return false
return false;
}
/**
@@ -82,12 +82,12 @@ export function validateMp3Bytes(buf: Buffer): boolean {
*/
export async function extractMp3Duration(buf: Buffer): Promise<number> {
try {
const meta = await parseBuffer(buf, { mimeType: 'audio/mpeg' }, { duration: true })
const seconds = meta.format.duration
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return 0
return Math.round(seconds)
const meta = await parseBuffer(buf, { mimeType: "audio/mpeg" }, { duration: true });
const seconds = meta.format.duration;
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return 0;
return Math.round(seconds);
} catch {
return 0
return 0;
}
}
@@ -98,8 +98,8 @@ export async function extractMp3Duration(buf: Buffer): Promise<number> {
* Creates the parent directory if missing.
*/
export async function writeSoundtrackFile(code: string, buf: Buffer): Promise<void> {
await ensureSoundtrackDir()
await fs.writeFile(getSoundtrackPath(code), buf)
await ensureSoundtrackDir();
await fs.writeFile(getSoundtrackPath(code), buf);
}
/**
@@ -107,15 +107,15 @@ export async function writeSoundtrackFile(code: string, buf: Buffer): Promise<vo
*/
export async function deleteSoundtrackFile(code: string): Promise<void> {
try {
await fs.unlink(getSoundtrackPath(code))
await fs.unlink(getSoundtrackPath(code));
} catch (err) {
const errCode = (err as NodeJS.ErrnoException).code
if (errCode === 'ENOENT') return
throw err
const errCode = (err as NodeJS.ErrnoException).code;
if (errCode === "ENOENT") return;
throw err;
}
}
export type { TraxChannel, TraxEvent } from '@/lib/trax-format'
export type { TraxChannel, TraxEvent } from "@/lib/trax-format";
// Re-export the Trax format parser for server-side use. The actual
// implementation lives in `@/lib/trax-format` so it can be imported by
// client components without pulling in Node-only deps from this file.
@@ -124,4 +124,4 @@ export {
parseTrackSamples,
parseTraxChannels,
TRAX_TICK_SECONDS,
} from '@/lib/trax-format'
} from "@/lib/trax-format";
+1 -1
View File
@@ -2,4 +2,4 @@
* Re-export barrel for backward compatibility.
* All implementation has been moved to the ./swf/ module directory.
*/
export * from './swf'
export * from "./swf";
+154 -154
View File
@@ -1,188 +1,188 @@
import { inflateSync } from 'node:zlib'
import * as jpeg from 'jpeg-js'
import { inflateSync } from "node:zlib";
import * as jpeg from "jpeg-js";
export interface SpriteImage {
id: number
name: string
width: number
height: number
pixels: Buffer // RGBA
id: number;
name: string;
width: number;
height: number;
pixels: Buffer; // RGBA
}
// ── DefineBitsLossless2 Parser (with alpha) ──────────────────────
export function parseBitsLossless2(data: Buffer): {
id: number
width: number
height: number
pixels: Buffer
id: number;
width: number;
height: number;
pixels: Buffer;
} {
let offset = 0
const id = data.readUInt16LE(offset)
offset += 2
const format = data.readUInt8(offset)
offset += 1
const width = data.readUInt16LE(offset)
offset += 2
const height = data.readUInt16LE(offset)
offset += 2
let offset = 0;
const id = data.readUInt16LE(offset);
offset += 2;
const format = data.readUInt8(offset);
offset += 1;
const width = data.readUInt16LE(offset);
offset += 2;
const height = data.readUInt16LE(offset);
offset += 2;
let colorTableSize = 0
let colorTableSize = 0;
if (format === 3) {
colorTableSize = data.readUInt8(offset) + 1
offset += 1
colorTableSize = data.readUInt8(offset) + 1;
offset += 1;
}
const compressed = data.subarray(offset)
let decompressed: Buffer
const compressed = data.subarray(offset);
let decompressed: Buffer;
try {
decompressed = inflateSync(compressed)
decompressed = inflateSync(compressed);
} catch {
return { id, width, height, pixels: Buffer.alloc(width * height * 4) }
return { id, width, height, pixels: Buffer.alloc(width * height * 4) };
}
const pixels = Buffer.alloc(width * height * 4)
const pixels = Buffer.alloc(width * height * 4);
if (format === 5) {
let srcOff = 0
let srcOff = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (srcOff + 3 >= decompressed.length) break
const a = decompressed[srcOff]
let r = decompressed[srcOff + 1]
let g = decompressed[srcOff + 2]
let b = decompressed[srcOff + 3]
srcOff += 4
if (srcOff + 3 >= decompressed.length) break;
const a = decompressed[srcOff];
let r = decompressed[srcOff + 1];
let g = decompressed[srcOff + 2];
let b = decompressed[srcOff + 3];
srcOff += 4;
// Un-premultiply alpha
if (a > 0 && a < 255) {
r = Math.min(255, Math.round((r * 255) / a))
g = Math.min(255, Math.round((g * 255) / a))
b = Math.min(255, Math.round((b * 255) / a))
r = Math.min(255, Math.round((r * 255) / a));
g = Math.min(255, Math.round((g * 255) / a));
b = Math.min(255, Math.round((b * 255) / a));
}
const dstOff = (y * width + x) * 4
pixels[dstOff] = r
pixels[dstOff + 1] = g
pixels[dstOff + 2] = b
pixels[dstOff + 3] = a
const dstOff = (y * width + x) * 4;
pixels[dstOff] = r;
pixels[dstOff + 1] = g;
pixels[dstOff + 2] = b;
pixels[dstOff + 3] = a;
}
}
} else if (format === 3) {
const palette: number[][] = []
let palOff = 0
const palette: number[][] = [];
let palOff = 0;
for (let i = 0; i < colorTableSize; i++) {
const a = decompressed[palOff]
let r = decompressed[palOff + 1]
let g = decompressed[palOff + 2]
let b = decompressed[palOff + 3]
const a = decompressed[palOff];
let r = decompressed[palOff + 1];
let g = decompressed[palOff + 2];
let b = decompressed[palOff + 3];
// Un-premultiply alpha for palette entries (same as format 5)
if (a > 0 && a < 255) {
r = Math.min(255, Math.round((r * 255) / a))
g = Math.min(255, Math.round((g * 255) / a))
b = Math.min(255, Math.round((b * 255) / a))
r = Math.min(255, Math.round((r * 255) / a));
g = Math.min(255, Math.round((g * 255) / a));
b = Math.min(255, Math.round((b * 255) / a));
}
palette.push([r, g, b, a])
palOff += 4
palette.push([r, g, b, a]);
palOff += 4;
}
const rowPadded = Math.ceil(width / 4) * 4
let srcOff = colorTableSize * 4
const rowPadded = Math.ceil(width / 4) * 4;
let srcOff = colorTableSize * 4;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = decompressed[srcOff + x]
const color = palette[idx] || [0, 0, 0, 0]
const dstOff = (y * width + x) * 4
pixels[dstOff] = color[0]
pixels[dstOff + 1] = color[1]
pixels[dstOff + 2] = color[2]
pixels[dstOff + 3] = color[3]
const idx = decompressed[srcOff + x];
const color = palette[idx] || [0, 0, 0, 0];
const dstOff = (y * width + x) * 4;
pixels[dstOff] = color[0];
pixels[dstOff + 1] = color[1];
pixels[dstOff + 2] = color[2];
pixels[dstOff + 3] = color[3];
}
srcOff += rowPadded
srcOff += rowPadded;
}
}
return { id, width, height, pixels }
return { id, width, height, pixels };
}
// ── DefineBitsLossless Parser (without alpha) ──────────────────────
export function parseBitsLossless(data: Buffer): {
id: number
width: number
height: number
pixels: Buffer
id: number;
width: number;
height: number;
pixels: Buffer;
} {
let offset = 0
const id = data.readUInt16LE(offset)
offset += 2
const format = data.readUInt8(offset)
offset += 1
const width = data.readUInt16LE(offset)
offset += 2
const height = data.readUInt16LE(offset)
offset += 2
let offset = 0;
const id = data.readUInt16LE(offset);
offset += 2;
const format = data.readUInt8(offset);
offset += 1;
const width = data.readUInt16LE(offset);
offset += 2;
const height = data.readUInt16LE(offset);
offset += 2;
let colorTableSize = 0
let colorTableSize = 0;
if (format === 3) {
colorTableSize = data.readUInt8(offset) + 1
offset += 1
colorTableSize = data.readUInt8(offset) + 1;
offset += 1;
}
const compressed = data.subarray(offset)
let decompressed: Buffer
const compressed = data.subarray(offset);
let decompressed: Buffer;
try {
decompressed = inflateSync(compressed)
decompressed = inflateSync(compressed);
} catch {
return { id, width, height, pixels: Buffer.alloc(width * height * 4) }
return { id, width, height, pixels: Buffer.alloc(width * height * 4) };
}
const pixels = Buffer.alloc(width * height * 4)
const pixels = Buffer.alloc(width * height * 4);
if (format === 5) {
let srcOff = 0
let srcOff = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (srcOff + 3 >= decompressed.length) break
srcOff++ // skip padding byte
const r = decompressed[srcOff++]
const g = decompressed[srcOff++]
const b = decompressed[srcOff++]
const dstOff = (y * width + x) * 4
pixels[dstOff] = r
pixels[dstOff + 1] = g
pixels[dstOff + 2] = b
pixels[dstOff + 3] = 255
if (srcOff + 3 >= decompressed.length) break;
srcOff++; // skip padding byte
const r = decompressed[srcOff++];
const g = decompressed[srcOff++];
const b = decompressed[srcOff++];
const dstOff = (y * width + x) * 4;
pixels[dstOff] = r;
pixels[dstOff + 1] = g;
pixels[dstOff + 2] = b;
pixels[dstOff + 3] = 255;
}
}
} else if (format === 3) {
const palette: number[][] = []
let palOff = 0
const palette: number[][] = [];
let palOff = 0;
for (let i = 0; i < colorTableSize; i++) {
const r = decompressed[palOff]
const g = decompressed[palOff + 1]
const b = decompressed[palOff + 2]
palette.push([r, g, b, 255])
palOff += 3
const r = decompressed[palOff];
const g = decompressed[palOff + 1];
const b = decompressed[palOff + 2];
palette.push([r, g, b, 255]);
palOff += 3;
}
const rowPadded = Math.ceil(width / 4) * 4
let srcOff = colorTableSize * 3
const rowPadded = Math.ceil(width / 4) * 4;
let srcOff = colorTableSize * 3;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = decompressed[srcOff + x]
const color = palette[idx] || [0, 0, 0, 255]
const dstOff = (y * width + x) * 4
pixels[dstOff] = color[0]
pixels[dstOff + 1] = color[1]
pixels[dstOff + 2] = color[2]
pixels[dstOff + 3] = color[3]
const idx = decompressed[srcOff + x];
const color = palette[idx] || [0, 0, 0, 255];
const dstOff = (y * width + x) * 4;
pixels[dstOff] = color[0];
pixels[dstOff + 1] = color[1];
pixels[dstOff + 2] = color[2];
pixels[dstOff + 3] = color[3];
}
srcOff += rowPadded
srcOff += rowPadded;
}
}
return { id, width, height, pixels }
return { id, width, height, pixels };
}
// ── DefineBitsJpeg2 Parser (JPEG without alpha) ───────────────────
@@ -190,10 +190,10 @@ export function parseBitsLossless(data: Buffer): {
export function parseBitsJpeg2(
data: Buffer,
): { id: number; width: number; height: number; pixels: Buffer } | null {
if (data.length < 6) return null
if (data.length < 6) return null;
const id = data.readUInt16LE(0)
let jpegData = data.subarray(2)
const id = data.readUInt16LE(0);
let jpegData = data.subarray(2);
// Strip erroneous JPEG header (FF D9 FF D1) if present
if (
@@ -203,14 +203,14 @@ export function parseBitsJpeg2(
jpegData[2] === 0xff &&
jpegData[3] === 0xd1
) {
jpegData = jpegData.subarray(4)
jpegData = jpegData.subarray(4);
}
const dims = parseJpegDimensions(jpegData)
if (!dims) return null
const dims = parseJpegDimensions(jpegData);
if (!dims) return null;
const pixels = decodeJpegToRGBA(jpegData, dims.width, dims.height)
return { id, width: dims.width, height: dims.height, pixels }
const pixels = decodeJpegToRGBA(jpegData, dims.width, dims.height);
return { id, width: dims.width, height: dims.height, pixels };
}
// ── DefineBitsJpeg3 Parser (JPEG with alpha channel) ──────────────
@@ -218,11 +218,11 @@ export function parseBitsJpeg2(
export function parseBitsJpeg3(
data: Buffer,
): { id: number; width: number; height: number; pixels: Buffer } | null {
if (data.length < 6) return null
if (data.length < 6) return null;
const id = data.readUInt16LE(0)
const alphaDataOffset = data.readUInt32LE(2)
let jpegData = data.subarray(6, 6 + alphaDataOffset)
const id = data.readUInt16LE(0);
const alphaDataOffset = data.readUInt32LE(2);
let jpegData = data.subarray(6, 6 + alphaDataOffset);
// Strip erroneous JPEG header (FF D9 FF D1) if present
if (
@@ -232,72 +232,72 @@ export function parseBitsJpeg3(
jpegData[2] === 0xff &&
jpegData[3] === 0xd1
) {
jpegData = jpegData.subarray(4)
jpegData = jpegData.subarray(4);
}
const dims = parseJpegDimensions(jpegData)
if (!dims) return null
const dims = parseJpegDimensions(jpegData);
if (!dims) return null;
const pixels = decodeJpegToRGBA(jpegData, dims.width, dims.height)
const pixels = decodeJpegToRGBA(jpegData, dims.width, dims.height);
// Apply zlib-compressed alpha channel
const compressedAlpha = data.subarray(6 + alphaDataOffset)
const compressedAlpha = data.subarray(6 + alphaDataOffset);
if (compressedAlpha.length > 0) {
try {
const alpha = inflateSync(compressedAlpha)
const pixelCount = dims.width * dims.height
const alpha = inflateSync(compressedAlpha);
const pixelCount = dims.width * dims.height;
for (let i = 0; i < pixelCount && i < alpha.length; i++) {
pixels[i * 4 + 3] = alpha[i]
pixels[i * 4 + 3] = alpha[i];
}
} catch {
// Alpha decompression failed, keep fully opaque
}
}
return { id, width: dims.width, height: dims.height, pixels }
return { id, width: dims.width, height: dims.height, pixels };
}
// ── JPEG Dimension Parser (reads SOF0/SOF2 markers) ───────────────
export function parseJpegDimensions(jpeg: Buffer): { width: number; height: number } | null {
let offset = 0
let offset = 0;
while (offset < jpeg.length - 1) {
if (jpeg[offset] !== 0xff) {
offset++
continue
offset++;
continue;
}
const marker = jpeg[offset + 1]
offset += 2
const marker = jpeg[offset + 1];
offset += 2;
// SOF0 (Baseline), SOF1 (Extended), SOF2 (Progressive)
if (marker >= 0xc0 && marker <= 0xc2) {
if (offset + 7 > jpeg.length) return null
if (offset + 7 > jpeg.length) return null;
// Skip length (2 bytes) + precision (1 byte)
const height = jpeg.readUInt16BE(offset + 3)
const width = jpeg.readUInt16BE(offset + 5)
return { width, height }
const height = jpeg.readUInt16BE(offset + 3);
const width = jpeg.readUInt16BE(offset + 5);
return { width, height };
}
// Skip non-SOF markers with length field
if (marker === 0xd8 || marker === 0xd9) continue // SOI / EOI
if (marker >= 0xd0 && marker <= 0xd7) continue // RST markers
if (offset + 2 > jpeg.length) break
const segLen = jpeg.readUInt16BE(offset)
offset += segLen
if (marker === 0xd8 || marker === 0xd9) continue; // SOI / EOI
if (marker >= 0xd0 && marker <= 0xd7) continue; // RST markers
if (offset + 2 > jpeg.length) break;
const segLen = jpeg.readUInt16BE(offset);
offset += segLen;
}
return null
return null;
}
// ── JPEG to RGBA decoder using jpeg-js ──────────────────────────────
export function decodeJpegToRGBA(jpegData: Buffer, width: number, height: number): Buffer {
try {
const decoded = jpeg.decode(jpegData, { useTArray: true, formatAsRGBA: true })
const decoded = jpeg.decode(jpegData, { useTArray: true, formatAsRGBA: true });
// jpeg-js returns RGBA data directly
return Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength)
return Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength);
} catch {
// Fallback: transparent pixels if JPEG decode fails
return Buffer.alloc(width * height * 4)
return Buffer.alloc(width * height * 4);
}
}
+185 -185
View File
@@ -5,16 +5,16 @@
* then bundles everything into the .nitro format expected
* by the Nitro HTML5 client.
*/
import { gunzipSync, inflateSync } from 'node:zlib'
import type { BatchConversionItem, BatchConversionResult, ConversionResult } from '@/types/furni'
import { gunzipSync, inflateSync } from "node:zlib";
import type { BatchConversionItem, BatchConversionResult, ConversionResult } from "@/types/furni";
import {
parseBitsJpeg2,
parseBitsJpeg3,
parseBitsLossless,
parseBitsLossless2,
type SpriteImage,
} from './image-decoder'
import { buildSpritesheet, createNitroBundle, encodePng } from './nitro-builder'
} from "./image-decoder";
import { buildSpritesheet, createNitroBundle, encodePng } from "./nitro-builder";
import {
decompressSwf,
parseBinaryData,
@@ -27,7 +27,7 @@ import {
TAG_DEFINE_BITS_LOSSLESS,
TAG_DEFINE_BITS_LOSSLESS2,
TAG_SYMBOL_CLASS,
} from './swf-parser'
} from "./swf-parser";
import {
computeDefaultDir,
extractPartColors,
@@ -39,151 +39,151 @@ import {
processLogic,
processManifest,
processVisualization,
} from './xml-processor'
} from "./xml-processor";
export type {
BatchConversionItem,
BatchConversionResult,
ConversionResult,
FurniMetadata,
} from '@/types/furni'
} from "@/types/furni";
// Re-export public API from submodules
export { createNitroBundle, parseNitroBundle } from './nitro-builder'
export { computeDefaultDir, extractPartColors } from './xml-processor'
export { createNitroBundle, parseNitroBundle } from "./nitro-builder";
export { computeDefaultDir, extractPartColors } from "./xml-processor";
// ── Main Conversion ────────────────────────────────────────────────
export function convertSwfToNitro(swfBuffer: Buffer, classname: string): ConversionResult {
const warnings: string[] = []
const warnings: string[] = [];
// 1. Decompress SWF
let decompressed: Buffer
let decompressed: Buffer;
try {
decompressed = decompressSwf(swfBuffer)
decompressed = decompressSwf(swfBuffer);
} catch (err) {
throw new Error(`SWF decompression failed for ${classname}: ${(err as Error).message}`)
throw new Error(`SWF decompression failed for ${classname}: ${(err as Error).message}`);
}
// 2. Parse tags
const tags = parseSwfTags(decompressed)
const tags = parseSwfTags(decompressed);
if (tags.length === 0) {
throw new Error(`No tags found in SWF for ${classname}`)
throw new Error(`No tags found in SWF for ${classname}`);
}
// 3. Parse SymbolClass to map IDs to names
let symbolResult: SymbolClassResult = { classNames: new Map(), symbols: new Map() }
let symbolResult: SymbolClassResult = { classNames: new Map(), symbols: new Map() };
for (const tag of tags) {
if (tag.type === TAG_SYMBOL_CLASS) {
symbolResult = parseSymbolClass(tag.data)
break
symbolResult = parseSymbolClass(tag.data);
break;
}
}
const { classNames: symbolMap, symbols: allSymbols } = symbolResult
const { classNames: symbolMap, symbols: allSymbols } = symbolResult;
// 4. Extract binary data (XML configs) and images
const binaryData = new Map<string, Buffer>()
const rawImages = new Map<number, { width: number; height: number; pixels: Buffer }>()
const binaryData = new Map<string, Buffer>();
const rawImages = new Map<number, { width: number; height: number; pixels: Buffer }>();
for (const tag of tags) {
switch (tag.type) {
case TAG_DEFINE_BINARY_DATA: {
const { id, content } = parseBinaryData(tag.data)
const name = symbolMap.get(id) || `binary_${id}`
binaryData.set(name, content)
break
const { id, content } = parseBinaryData(tag.data);
const name = symbolMap.get(id) || `binary_${id}`;
binaryData.set(name, content);
break;
}
case TAG_DEFINE_BITS_LOSSLESS2: {
const img = parseBitsLossless2(tag.data)
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels })
break
const img = parseBitsLossless2(tag.data);
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels });
break;
}
case TAG_DEFINE_BITS_LOSSLESS: {
const img = parseBitsLossless(tag.data)
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels })
break
const img = parseBitsLossless(tag.data);
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels });
break;
}
case TAG_DEFINE_BITS_JPEG2: {
const img = parseBitsJpeg2(tag.data)
const img = parseBitsJpeg2(tag.data);
if (img && !rawImages.has(img.id)) {
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels })
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels });
}
break
break;
}
case TAG_DEFINE_BITS_JPEG3: {
const img = parseBitsJpeg3(tag.data)
const img = parseBitsJpeg3(tag.data);
if (img && !rawImages.has(img.id)) {
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels })
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels });
}
break
break;
}
}
}
// 5. Strip the SymbolClass package prefix
const prefix = `${classname}_`
const prefix = `${classname}_`;
function stripPrefix(name: string): string {
if (name.startsWith(prefix)) return name.substring(prefix.length)
if (name.includes('.')) return name.split('.').pop() || name
return name
if (name.startsWith(prefix)) return name.substring(prefix.length);
if (name.includes(".")) return name.split(".").pop() || name;
return name;
}
// 6. Parse XML data from binary tags
let assetsXml = ''
let indexXml = ''
let logicXml = ''
let visualizationXml = ''
let manifestXml = ''
let assetsXml = "";
let indexXml = "";
let logicXml = "";
let visualizationXml = "";
let manifestXml = "";
for (const [rawName, content] of binaryData) {
let xmlStr = content.toString('utf-8').trim()
xmlStr = xmlStr.replace(/encoding="[Ii][Ss][Oo]-8859-1"/g, 'encoding="UTF-8"')
const name = stripPrefix(rawName).toLowerCase()
if (name.includes('manifest') || name.endsWith('_manifest')) {
manifestXml = xmlStr
} else if (name.includes('assets') || name.endsWith('_assets')) {
assetsXml = xmlStr
} else if (name === 'index' || name.endsWith('_index')) {
indexXml = xmlStr
} else if (name.includes('logic') || name.endsWith('_logic')) {
logicXml = xmlStr
} else if (name.includes('visualization') || name.endsWith('_visualization')) {
visualizationXml = xmlStr
let xmlStr = content.toString("utf-8").trim();
xmlStr = xmlStr.replace(/encoding="[Ii][Ss][Oo]-8859-1"/g, 'encoding="UTF-8"');
const name = stripPrefix(rawName).toLowerCase();
if (name.includes("manifest") || name.endsWith("_manifest")) {
manifestXml = xmlStr;
} else if (name.includes("assets") || name.endsWith("_assets")) {
assetsXml = xmlStr;
} else if (name === "index" || name.endsWith("_index")) {
indexXml = xmlStr;
} else if (name.includes("logic") || name.endsWith("_logic")) {
logicXml = xmlStr;
} else if (name.includes("visualization") || name.endsWith("_visualization")) {
visualizationXml = xmlStr;
}
}
if (!assetsXml) warnings.push('No assets XML found in SWF')
if (!logicXml) warnings.push('No logic XML found in SWF')
if (!visualizationXml) warnings.push('No visualization XML found in SWF')
if (!assetsXml) warnings.push("No assets XML found in SWF");
if (!logicXml) warnings.push("No logic XML found in SWF");
if (!visualizationXml) warnings.push("No visualization XML found in SWF");
// Build set of needed sprites from assets.xml
const neededSprites = new Set<string>()
const neededSprites = new Set<string>();
if (assetsXml) {
const assetsRoot = parseXml(assetsXml)
const assetsRoot = parseXml(assetsXml);
if (assetsRoot) {
const assetsNode =
assetsRoot.tag === 'assets' ? assetsRoot : findChild(assetsRoot, 'assets') || assetsRoot
for (const asset of findChildren(assetsNode, 'asset')) {
const assetName = asset.attrs.name || ''
if (!assetName || assetName.startsWith('sh_') || assetName.includes('_32_')) continue
assetsRoot.tag === "assets" ? assetsRoot : findChild(assetsRoot, "assets") || assetsRoot;
for (const asset of findChildren(assetsNode, "asset")) {
const assetName = asset.attrs.name || "";
if (!assetName || assetName.startsWith("sh_") || assetName.includes("_32_")) continue;
if (!asset.attrs.source) {
neededSprites.add(assetName)
neededSprites.add(assetName);
} else {
neededSprites.add(asset.attrs.source)
neededSprites.add(asset.attrs.source);
}
}
}
}
// Map images to named sprites
const namedImages: SpriteImage[] = []
const namedImages: SpriteImage[] = [];
for (const [id, imgData] of rawImages) {
const rawName = symbolMap.get(id)
if (!rawName) continue
const name = stripPrefix(rawName)
const rawName = symbolMap.get(id);
if (!rawName) continue;
const name = stripPrefix(rawName);
if (name.startsWith('sh_') || name.includes('_32_')) continue
if (neededSprites.size > 0 && !neededSprites.has(name)) continue
if (name.startsWith("sh_") || name.includes("_32_")) continue;
if (neededSprites.size > 0 && !neededSprites.has(name)) continue;
namedImages.push({
id,
@@ -191,29 +191,29 @@ export function convertSwfToNitro(swfBuffer: Buffer, classname: string): Convers
width: imgData.width,
height: imgData.height,
pixels: imgData.pixels,
})
});
}
namedImages.sort((a, b) => a.name.localeCompare(b.name))
namedImages.sort((a, b) => a.name.localeCompare(b.name));
// 7. Build spritesheet using bin-packing
const { png, frames, width: sheetWidth, height: sheetHeight } = buildSpritesheet(namedImages)
const { png, frames, width: sheetWidth, height: sheetHeight } = buildSpritesheet(namedImages);
// 8. Process XML into Nitro JSON structures
const { palettes } = processAssetsXml(assetsXml)
const manifest = processManifest(manifestXml)
const indexData = processIndex(indexXml)
const logic = processLogic(logicXml)
const visualizations = processVisualization(visualizationXml)
const { palettes } = processAssetsXml(assetsXml);
const manifest = processManifest(manifestXml);
const indexData = processIndex(indexXml);
const logic = processLogic(logicXml);
const visualizations = processVisualization(visualizationXml);
// Build IMAGE_SOURCES map
const imageSources = new Map<string, string>()
const imageSources = new Map<string, string>();
for (const [symbolName, charId] of allSymbols) {
if (!rawImages.has(charId)) continue
const canonicalName = symbolMap.get(charId)
if (!canonicalName) continue
if (!rawImages.has(charId)) continue;
const canonicalName = symbolMap.get(charId);
if (!canonicalName) continue;
if (symbolName !== canonicalName) {
imageSources.set(stripPrefix(symbolName), stripPrefix(canonicalName))
imageSources.set(stripPrefix(symbolName), stripPrefix(canonicalName));
}
}
@@ -221,82 +221,82 @@ export function convertSwfToNitro(swfBuffer: Buffer, classname: string): Convers
const assets: Record<
string,
{
x: number
y: number
source?: string
flipH?: boolean
flipV?: boolean
usesPalette?: boolean
x: number;
y: number;
source?: string;
flipH?: boolean;
flipV?: boolean;
usesPalette?: boolean;
}
> = {}
> = {};
for (const ma of manifest.assets) {
assets[ma.name] = { x: ma.x, y: ma.y }
assets[ma.name] = { x: ma.x, y: ma.y };
}
if (assetsXml) {
const root = parseXml(assetsXml)
const root = parseXml(assetsXml);
if (root) {
const assetsNode = root.tag === 'assets' ? root : findChild(root, 'assets') || root
for (const asset of findChildren(assetsNode, 'asset')) {
const assetName = asset.attrs.name || ''
if (!assetName) continue
if (assetName.startsWith('sh_') || assetName.includes('_32_')) continue
const assetsNode = root.tag === "assets" ? root : findChild(root, "assets") || root;
for (const asset of findChildren(assetsNode, "asset")) {
const assetName = asset.attrs.name || "";
if (!assetName) continue;
if (assetName.startsWith("sh_") || assetName.includes("_32_")) continue;
const x = parseInt(asset.attrs.x || '0', 10)
const y = parseInt(asset.attrs.y || '0', 10)
const x = parseInt(asset.attrs.x || "0", 10);
const y = parseInt(asset.attrs.y || "0", 10);
let source = asset.attrs.source || ''
let source = asset.attrs.source || "";
if (source && imageSources.has(source)) {
source = imageSources.get(source)!
source = imageSources.get(source)!;
}
if (imageSources.has(assetName)) {
source = imageSources.get(assetName)!
source = imageSources.get(assetName)!;
}
const entry: (typeof assets)[string] = source
? ({ source, x, y } as (typeof assets)[string])
: { x, y }
if (asset.attrs.flipH === '1') {
entry.flipH = true
: { x, y };
if (asset.attrs.flipH === "1") {
entry.flipH = true;
}
if (asset.attrs.flipV === '1') {
entry.flipV = true
if (asset.attrs.flipV === "1") {
entry.flipV = true;
}
if (asset.attrs.usesPalette === '1') {
entry.usesPalette = true
if (asset.attrs.usesPalette === "1") {
entry.usesPalette = true;
}
assets[assetName] = entry
assets[assetName] = entry;
}
}
}
if (Object.keys(assets).length === 0) {
for (const img of namedImages) {
assets[img.name] = { x: 0, y: 0 }
assets[img.name] = { x: 0, y: 0 };
}
}
const validSpriteNames = new Set(namedImages.map((img) => img.name))
const validSpriteNames = new Set(namedImages.map((img) => img.name));
for (const key of Object.keys(assets)) {
if (!validSpriteNames.has(key) && !assets[key].source) {
delete assets[key]
delete assets[key];
}
}
const sortedAssets: typeof assets = {}
const sortedAssets: typeof assets = {};
for (const key of Object.keys(assets).sort()) {
sortedAssets[key] = assets[key]
sortedAssets[key] = assets[key];
}
// Build spritesheet frames
const spritesheetFrames: Record<string, unknown> = {}
const spritesheetFrames: Record<string, unknown> = {};
for (const img of namedImages) {
const frameInfo = frames.get(img.name)
if (!frameInfo) continue
const frameKey = `${classname}_${img.name}`
const frameInfo = frames.get(img.name);
if (!frameInfo) continue;
const frameKey = `${classname}_${img.name}`;
spritesheetFrames[frameKey] = {
frame: { x: frameInfo.x, y: frameInfo.y, w: frameInfo.w, h: frameInfo.h },
rotated: false,
@@ -304,17 +304,17 @@ export function convertSwfToNitro(swfBuffer: Buffer, classname: string): Convers
spriteSourceSize: { x: 0, y: 0, w: frameInfo.w, h: frameInfo.h },
sourceSize: { w: frameInfo.w, h: frameInfo.h },
pivot: { x: 0.5, y: 0.5 },
}
};
}
// Determine visualization/logic types
let visualizationType = indexData?.visualizationType || 'furniture_static'
let logicType = indexData?.logicType || 'furniture_basic'
let visualizationType = indexData?.visualizationType || "furniture_static";
let logicType = indexData?.logicType || "furniture_basic";
if (!indexData) {
if (visualizations.some((v) => v.animations && Object.keys(v.animations).length > 0)) {
visualizationType = 'furniture_animated'
logicType = 'furniture_multistate'
visualizationType = "furniture_animated";
logicType = "furniture_multistate";
}
}
@@ -324,14 +324,14 @@ export function convertSwfToNitro(swfBuffer: Buffer, classname: string): Convers
dimensions: logic.dimensions,
directions: logic.directions,
},
}
if (logic.maskType) logicObj.maskType = logic.maskType
if (logic.action) logicObj.action = logic.action
if (logic.credits) logicObj.credits = logic.credits
if (logic.soundSample) logicObj.soundSample = logic.soundSample
if (logic.customVars) logicObj.customVars = logic.customVars
if (logic.particleSystems) logicObj.particleSystems = logic.particleSystems
if (logic.planetSystems) logicObj.planetSystems = logic.planetSystems
};
if (logic.maskType) logicObj.maskType = logic.maskType;
if (logic.action) logicObj.action = logic.action;
if (logic.credits) logicObj.credits = logic.credits;
if (logic.soundSample) logicObj.soundSample = logic.soundSample;
if (logic.customVars) logicObj.customVars = logic.customVars;
if (logic.particleSystems) logicObj.particleSystems = logic.particleSystems;
if (logic.planetSystems) logicObj.planetSystems = logic.planetSystems;
const nitroJson: Record<string, unknown> = {
name: classname,
@@ -340,24 +340,24 @@ export function convertSwfToNitro(swfBuffer: Buffer, classname: string): Convers
spritesheet: {
meta: {
image: `${classname}.png`,
format: 'RGBA8888',
format: "RGBA8888",
size: { w: sheetWidth, h: sheetHeight },
scale: 1,
},
frames: spritesheetFrames,
},
assets: sortedAssets,
}
};
if (Object.keys(palettes).length > 0) {
nitroJson.palettes = palettes
nitroJson.palettes = palettes;
}
nitroJson.visualizations = visualizations
nitroJson.logic = logicObj
nitroJson.visualizations = visualizations;
nitroJson.logic = logicObj;
// 10. Create .nitro bundle
const bundle = createNitroBundle(nitroJson, png, classname)
const bundle = createNitroBundle(nitroJson, png, classname);
return {
bundle,
@@ -376,74 +376,74 @@ export function convertSwfToNitro(swfBuffer: Buffer, classname: string): Convers
defaultdir: computeDefaultDir(logic.directions),
partcolors: extractPartColors(visualizations),
},
}
};
}
// ── Icon Extraction from SWF ────────────────────────────────────────
export function extractIconFromSwf(swfBuffer: Buffer, classname: string): Buffer | null {
try {
const decompressed = decompressSwf(swfBuffer)
const tags = parseSwfTags(decompressed)
const decompressed = decompressSwf(swfBuffer);
const tags = parseSwfTags(decompressed);
let iconSymbolMap = new Map<number, string>()
let iconSymbolMap = new Map<number, string>();
for (const tag of tags) {
if (tag.type === TAG_SYMBOL_CLASS) {
const result = parseSymbolClass(tag.data)
iconSymbolMap = result.classNames
break
const result = parseSymbolClass(tag.data);
iconSymbolMap = result.classNames;
break;
}
}
const prefix = `${classname}_`
const candidates: { name: string; width: number; height: number; pixels: Buffer }[] = []
const prefix = `${classname}_`;
const candidates: { name: string; width: number; height: number; pixels: Buffer }[] = [];
for (const tag of tags) {
let img: { id: number; width: number; height: number; pixels: Buffer } | null = null
if (tag.type === TAG_DEFINE_BITS_LOSSLESS2) img = parseBitsLossless2(tag.data)
else if (tag.type === TAG_DEFINE_BITS_LOSSLESS) img = parseBitsLossless(tag.data)
else if (tag.type === TAG_DEFINE_BITS_JPEG2) img = parseBitsJpeg2(tag.data)
else if (tag.type === TAG_DEFINE_BITS_JPEG3) img = parseBitsJpeg3(tag.data)
if (!img) continue
let img: { id: number; width: number; height: number; pixels: Buffer } | null = null;
if (tag.type === TAG_DEFINE_BITS_LOSSLESS2) img = parseBitsLossless2(tag.data);
else if (tag.type === TAG_DEFINE_BITS_LOSSLESS) img = parseBitsLossless(tag.data);
else if (tag.type === TAG_DEFINE_BITS_JPEG2) img = parseBitsJpeg2(tag.data);
else if (tag.type === TAG_DEFINE_BITS_JPEG3) img = parseBitsJpeg3(tag.data);
if (!img) continue;
const rawName = iconSymbolMap.get(img.id) || ''
const name = rawName.startsWith(prefix) ? rawName.substring(prefix.length) : rawName
if (!name || name.startsWith('sh_')) continue
const rawName = iconSymbolMap.get(img.id) || "";
const name = rawName.startsWith(prefix) ? rawName.substring(prefix.length) : rawName;
if (!name || name.startsWith("sh_")) continue;
if (name.includes('icon') || /^1_[a-z]_\d+_\d+$/.test(name)) {
candidates.push({ name, width: img.width, height: img.height, pixels: img.pixels })
if (name.includes("icon") || /^1_[a-z]_\d+_\d+$/.test(name)) {
candidates.push({ name, width: img.width, height: img.height, pixels: img.pixels });
}
}
if (candidates.length === 0) {
for (const tag of tags) {
let img: { id: number; width: number; height: number; pixels: Buffer } | null = null
if (tag.type === TAG_DEFINE_BITS_LOSSLESS2) img = parseBitsLossless2(tag.data)
else if (tag.type === TAG_DEFINE_BITS_LOSSLESS) img = parseBitsLossless(tag.data)
if (!img || img.width < 2 || img.height < 2) continue
let img: { id: number; width: number; height: number; pixels: Buffer } | null = null;
if (tag.type === TAG_DEFINE_BITS_LOSSLESS2) img = parseBitsLossless2(tag.data);
else if (tag.type === TAG_DEFINE_BITS_LOSSLESS) img = parseBitsLossless(tag.data);
if (!img || img.width < 2 || img.height < 2) continue;
const rawName = iconSymbolMap.get(img.id) || ''
const name = rawName.startsWith(prefix) ? rawName.substring(prefix.length) : rawName
if (name.startsWith('sh_')) continue
const rawName = iconSymbolMap.get(img.id) || "";
const name = rawName.startsWith(prefix) ? rawName.substring(prefix.length) : rawName;
if (name.startsWith("sh_")) continue;
candidates.push({ name, width: img.width, height: img.height, pixels: img.pixels })
candidates.push({ name, width: img.width, height: img.height, pixels: img.pixels });
}
}
if (candidates.length === 0) return null
if (candidates.length === 0) return null;
candidates.sort((a, b) => a.width * a.height - b.width * b.height)
const icon = candidates[0]
return encodePng(icon.width, icon.height, icon.pixels)
candidates.sort((a, b) => a.width * a.height - b.width * b.height);
const icon = candidates[0];
return encodePng(icon.width, icon.height, icon.pixels);
} catch {
return null
return null;
}
}
// ── Backward-compatible wrapper ────────────────────────────────────
export function convertSwfToNitroBuffer(swfBuffer: Buffer, classname: string): Buffer {
return convertSwfToNitro(swfBuffer, classname).bundle
return convertSwfToNitro(swfBuffer, classname).bundle;
}
// ── Batch Conversion ───────────────────────────────────────────────
@@ -451,7 +451,7 @@ export function convertSwfToNitroBuffer(swfBuffer: Buffer, classname: string): B
export function convertSwfToNitroBatch(items: BatchConversionItem[]): BatchConversionResult[] {
return items.map((item) => {
try {
const result = convertSwfToNitro(item.swfBuffer, item.classname)
const result = convertSwfToNitro(item.swfBuffer, item.classname);
return {
classname: item.classname,
success: true,
@@ -460,28 +460,28 @@ export function convertSwfToNitroBatch(items: BatchConversionItem[]): BatchConve
directions: result.directions,
spriteCount: result.spriteCount,
warnings: result.warnings,
}
};
} catch (err) {
return {
classname: item.classname,
success: false,
warnings: [],
error: (err as Error).message,
}
};
}
})
});
}
// ── Utility: Try to decompress nitro-style data (zlib or gzip) ─────
export function nitroDecompress(data: Buffer): Buffer {
try {
return inflateSync(data)
return inflateSync(data);
} catch {
try {
return gunzipSync(data)
return gunzipSync(data);
} catch {
return data
return data;
}
}
}
+143 -143
View File
@@ -1,91 +1,91 @@
import { deflateSync, inflateSync } from 'node:zlib'
import type { SpriteImage } from './image-decoder'
import { deflateSync, inflateSync } from "node:zlib";
import type { SpriteImage } from "./image-decoder";
// ── Minimal PNG Encoder ────────────────────────────────────────────
const CRC_TABLE = (() => {
const table = new Uint32Array(256)
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c
table[n] = c;
}
return table
})()
return table;
})();
function crc32(data: Buffer): number {
let crc = 0xffffffff
let crc = 0xffffffff;
for (let i = 0; i < data.length; i++) {
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer): Buffer {
const buf = Buffer.alloc(4 + 4 + data.length + 4)
buf.writeUInt32BE(data.length, 0)
buf.write(type, 4, 4, 'ascii')
data.copy(buf, 8)
const crc = crc32(buf.subarray(4, 8 + data.length))
buf.writeUInt32BE(crc >>> 0, 8 + data.length)
return buf
const buf = Buffer.alloc(4 + 4 + data.length + 4);
buf.writeUInt32BE(data.length, 0);
buf.write(type, 4, 4, "ascii");
data.copy(buf, 8);
const crc = crc32(buf.subarray(4, 8 + data.length));
buf.writeUInt32BE(crc >>> 0, 8 + data.length);
return buf;
}
export function encodePng(width: number, height: number, rgba: Buffer): Buffer {
const rawData = Buffer.alloc(height * (1 + width * 4))
const rawData = Buffer.alloc(height * (1 + width * 4));
for (let y = 0; y < height; y++) {
const rowStart = y * (1 + width * 4)
rawData[rowStart] = 0 // filter: None
rgba.copy(rawData, rowStart + 1, y * width * 4, (y + 1) * width * 4)
const rowStart = y * (1 + width * 4);
rawData[rowStart] = 0; // filter: None
rgba.copy(rawData, rowStart + 1, y * width * 4, (y + 1) * width * 4);
}
const compressed = deflateSync(rawData)
const compressed = deflateSync(rawData);
const chunks: Buffer[] = []
const chunks: Buffer[] = [];
// Signature
chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
// IHDR
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(width, 0)
ihdr.writeUInt32BE(height, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // color type: RGBA
ihdr[10] = 0 // compression
ihdr[11] = 0 // filter
ihdr[12] = 0 // interlace
chunks.push(pngChunk('IHDR', ihdr))
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
chunks.push(pngChunk("IHDR", ihdr));
// IDAT
chunks.push(pngChunk('IDAT', compressed))
chunks.push(pngChunk("IDAT", compressed));
// IEND
chunks.push(pngChunk('IEND', Buffer.alloc(0)))
chunks.push(pngChunk("IEND", Buffer.alloc(0)));
return Buffer.concat(chunks)
return Buffer.concat(chunks);
}
// ── Bin-Packing Spritesheet Builder ────────────────────────────────
interface PackedRect {
x: number
y: number
w: number
h: number
name: string
x: number;
y: number;
w: number;
h: number;
name: string;
}
function nextPow2(v: number): number {
v--
v |= v >> 1
v |= v >> 2
v |= v >> 4
v |= v >> 8
v |= v >> 16
return v + 1
v--;
v |= v >> 1;
v |= v >> 2;
v |= v >> 4;
v |= v >> 8;
v |= v >> 16;
return v + 1;
}
/**
@@ -95,166 +95,166 @@ function nextPow2(v: number): number {
*/
function binPack(images: SpriteImage[]): { rects: PackedRect[]; width: number; height: number } {
if (images.length === 0) {
return { rects: [], width: 1, height: 1 }
return { rects: [], width: 1, height: 1 };
}
// Sort by height descending for better shelf packing
const sorted = [...images].sort((a, b) => b.height - a.height || b.width - a.width)
const sorted = [...images].sort((a, b) => b.height - a.height || b.width - a.width);
// Estimate initial atlas width based on total area
const totalArea = sorted.reduce((sum, img) => sum + img.width * img.height, 0)
const maxSingleWidth = Math.max(...sorted.map((i) => i.width))
let atlasWidth = Math.max(maxSingleWidth, Math.ceil(Math.sqrt(totalArea)))
const totalArea = sorted.reduce((sum, img) => sum + img.width * img.height, 0);
const maxSingleWidth = Math.max(...sorted.map((i) => i.width));
let atlasWidth = Math.max(maxSingleWidth, Math.ceil(Math.sqrt(totalArea)));
// Round up to power of 2 for GPU friendliness (max 2048)
atlasWidth = Math.min(2048, nextPow2(atlasWidth))
atlasWidth = Math.min(2048, nextPow2(atlasWidth));
const rects: PackedRect[] = []
let shelfY = 0
let shelfHeight = 0
let shelfX = 0
const rects: PackedRect[] = [];
let shelfY = 0;
let shelfHeight = 0;
let shelfX = 0;
for (const img of sorted) {
// If sprite doesn't fit on current shelf, start new shelf
if (shelfX + img.width > atlasWidth) {
shelfY += shelfHeight
shelfHeight = 0
shelfX = 0
shelfY += shelfHeight;
shelfHeight = 0;
shelfX = 0;
}
rects.push({ x: shelfX, y: shelfY, w: img.width, h: img.height, name: img.name })
shelfX += img.width
shelfHeight = Math.max(shelfHeight, img.height)
rects.push({ x: shelfX, y: shelfY, w: img.width, h: img.height, name: img.name });
shelfX += img.width;
shelfHeight = Math.max(shelfHeight, img.height);
}
const finalHeight = shelfY + shelfHeight
return { rects, width: atlasWidth, height: finalHeight || 1 }
const finalHeight = shelfY + shelfHeight;
return { rects, width: atlasWidth, height: finalHeight || 1 };
}
// ── Spritesheet Builder ────────────────────────────────────────────
interface FrameInfo {
x: number
y: number
w: number
h: number
x: number;
y: number;
w: number;
h: number;
}
export function buildSpritesheet(images: SpriteImage[]): {
png: Buffer
frames: Map<string, FrameInfo>
width: number
height: number
png: Buffer;
frames: Map<string, FrameInfo>;
width: number;
height: number;
} {
if (images.length === 0) {
const emptyPng = encodePng(1, 1, Buffer.alloc(4))
return { png: emptyPng, frames: new Map(), width: 1, height: 1 }
const emptyPng = encodePng(1, 1, Buffer.alloc(4));
return { png: emptyPng, frames: new Map(), width: 1, height: 1 };
}
const { rects, width: sheetWidth, height: sheetHeight } = binPack(images)
const { rects, width: sheetWidth, height: sheetHeight } = binPack(images);
// Build lookup by name for pixel data
const imageByName = new Map(images.map((img) => [img.name, img]))
const imageByName = new Map(images.map((img) => [img.name, img]));
const combined = Buffer.alloc(sheetWidth * sheetHeight * 4)
const frames = new Map<string, FrameInfo>()
const combined = Buffer.alloc(sheetWidth * sheetHeight * 4);
const frames = new Map<string, FrameInfo>();
for (const rect of rects) {
const img = imageByName.get(rect.name)
if (!img) continue
const img = imageByName.get(rect.name);
if (!img) continue;
// Copy pixels row by row
for (let y = 0; y < img.height; y++) {
const srcStart = y * img.width * 4
const dstStart = ((rect.y + y) * sheetWidth + rect.x) * 4
img.pixels.copy(combined, dstStart, srcStart, srcStart + img.width * 4)
const srcStart = y * img.width * 4;
const dstStart = ((rect.y + y) * sheetWidth + rect.x) * 4;
img.pixels.copy(combined, dstStart, srcStart, srcStart + img.width * 4);
}
frames.set(rect.name, { x: rect.x, y: rect.y, w: rect.w, h: rect.h })
frames.set(rect.name, { x: rect.x, y: rect.y, w: rect.w, h: rect.h });
}
const png = encodePng(sheetWidth, sheetHeight, combined)
return { png, frames, width: sheetWidth, height: sheetHeight }
const png = encodePng(sheetWidth, sheetHeight, combined);
return { png, frames, width: sheetWidth, height: sheetHeight };
}
// ── .nitro Bundle Creator ──────────────────────────────────────────
export function createNitroBundle(jsonData: object, pngData: Buffer, name: string): Buffer {
const jsonStr = JSON.stringify(jsonData)
const jsonCompressed = deflateSync(Buffer.from(jsonStr, 'utf-8'))
const pngCompressed = deflateSync(pngData)
const jsonStr = JSON.stringify(jsonData);
const jsonCompressed = deflateSync(Buffer.from(jsonStr, "utf-8"));
const pngCompressed = deflateSync(pngData);
const jsonFileName = `${name}.json`
const pngFileName = `${name}.png`
const jsonFileName = `${name}.json`;
const pngFileName = `${name}.png`;
const totalSize =
2 +
(2 + jsonFileName.length + 4 + jsonCompressed.length) +
(2 + pngFileName.length + 4 + pngCompressed.length)
(2 + pngFileName.length + 4 + pngCompressed.length);
const bundle = Buffer.alloc(totalSize)
let offset = 0
const bundle = Buffer.alloc(totalSize);
let offset = 0;
bundle.writeInt16BE(2, offset)
offset += 2
bundle.writeInt16BE(2, offset);
offset += 2;
bundle.writeInt16BE(jsonFileName.length, offset)
offset += 2
bundle.write(jsonFileName, offset, jsonFileName.length, 'utf-8')
offset += jsonFileName.length
bundle.writeInt32BE(jsonCompressed.length, offset)
offset += 4
jsonCompressed.copy(bundle, offset)
offset += jsonCompressed.length
bundle.writeInt16BE(jsonFileName.length, offset);
offset += 2;
bundle.write(jsonFileName, offset, jsonFileName.length, "utf-8");
offset += jsonFileName.length;
bundle.writeInt32BE(jsonCompressed.length, offset);
offset += 4;
jsonCompressed.copy(bundle, offset);
offset += jsonCompressed.length;
bundle.writeInt16BE(pngFileName.length, offset)
offset += 2
bundle.write(pngFileName, offset, pngFileName.length, 'utf-8')
offset += pngFileName.length
bundle.writeInt32BE(pngCompressed.length, offset)
offset += 4
pngCompressed.copy(bundle, offset)
bundle.writeInt16BE(pngFileName.length, offset);
offset += 2;
bundle.write(pngFileName, offset, pngFileName.length, "utf-8");
offset += pngFileName.length;
bundle.writeInt32BE(pngCompressed.length, offset);
offset += 4;
pngCompressed.copy(bundle, offset);
return bundle
return bundle;
}
// ── .nitro Bundle Parser ──────────────────────────────────────────
export function parseNitroBundle(buffer: Buffer): {
json: Record<string, unknown>
jsonFileName: string
png: Buffer
pngFileName: string
json: Record<string, unknown>;
jsonFileName: string;
png: Buffer;
pngFileName: string;
} {
let offset = 0
let offset = 0;
const fileCount = buffer.readInt16BE(offset)
offset += 2
const fileCount = buffer.readInt16BE(offset);
offset += 2;
if (fileCount < 2) {
throw new Error(`Expected at least 2 files in .nitro bundle, got ${fileCount}`)
throw new Error(`Expected at least 2 files in .nitro bundle, got ${fileCount}`);
}
// File 1: JSON
const jsonFileNameLen = buffer.readInt16BE(offset)
offset += 2
const jsonFileName = buffer.toString('utf-8', offset, offset + jsonFileNameLen)
offset += jsonFileNameLen
const jsonCompressedLen = buffer.readInt32BE(offset)
offset += 4
const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen)
offset += jsonCompressedLen
const jsonStr = inflateSync(jsonCompressed).toString('utf-8')
const json = JSON.parse(jsonStr)
const jsonFileNameLen = buffer.readInt16BE(offset);
offset += 2;
const jsonFileName = buffer.toString("utf-8", offset, offset + jsonFileNameLen);
offset += jsonFileNameLen;
const jsonCompressedLen = buffer.readInt32BE(offset);
offset += 4;
const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen);
offset += jsonCompressedLen;
const jsonStr = inflateSync(jsonCompressed).toString("utf-8");
const json = JSON.parse(jsonStr);
// File 2: PNG
const pngFileNameLen = buffer.readInt16BE(offset)
offset += 2
const pngFileName = buffer.toString('utf-8', offset, offset + pngFileNameLen)
offset += pngFileNameLen
const pngCompressedLen = buffer.readInt32BE(offset)
offset += 4
const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen)
offset += pngCompressedLen
const png = inflateSync(pngCompressed)
const pngFileNameLen = buffer.readInt16BE(offset);
offset += 2;
const pngFileName = buffer.toString("utf-8", offset, offset + pngFileNameLen);
offset += pngFileNameLen;
const pngCompressedLen = buffer.readInt32BE(offset);
offset += 4;
const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen);
offset += pngCompressedLen;
const png = inflateSync(pngCompressed);
return { json, jsonFileName, png, pngFileName }
return { json, jsonFileName, png, pngFileName };
}
+81 -81
View File
@@ -2,151 +2,151 @@
* Low-level SWF file parsing: decompression, tag reading, symbol class.
*/
import { inflateSync } from 'node:zlib'
import LZMA from 'lzma'
import { inflateSync } from "node:zlib";
import LZMA from "lzma";
// ── SWF Tag Types ──────────────────────────────────────────────────
export const TAG_END = 0
export const TAG_DEFINE_BITS_LOSSLESS = 20
export const TAG_DEFINE_BITS_JPEG2 = 21
export const TAG_DEFINE_BITS_JPEG3 = 35
export const TAG_DEFINE_BITS_LOSSLESS2 = 36
export const TAG_SYMBOL_CLASS = 76
export const TAG_DEFINE_BINARY_DATA = 87
export const TAG_END = 0;
export const TAG_DEFINE_BITS_LOSSLESS = 20;
export const TAG_DEFINE_BITS_JPEG2 = 21;
export const TAG_DEFINE_BITS_JPEG3 = 35;
export const TAG_DEFINE_BITS_LOSSLESS2 = 36;
export const TAG_SYMBOL_CLASS = 76;
export const TAG_DEFINE_BINARY_DATA = 87;
// ── Interfaces ─────────────────────────────────────────────────────
export interface SwfTag {
type: number
data: Buffer
type: number;
data: Buffer;
}
export interface SymbolClassResult {
/** charID → first symbol name (canonical name) */
classNames: Map<number, string>
classNames: Map<number, string>;
/** symbol name → charID (all symbols including aliases) */
symbols: Map<string, number>
symbols: Map<string, number>;
}
// ── SWF Header Parsing ─────────────────────────────────────────────
export function decompressSwf(buffer: Buffer): Buffer {
const sig = buffer.toString('ascii', 0, 3)
const sig = buffer.toString("ascii", 0, 3);
if (sig === 'FWS') {
return buffer
if (sig === "FWS") {
return buffer;
}
if (sig === 'CWS') {
const header = Buffer.alloc(8)
buffer.copy(header, 0, 0, 8)
header.write('FWS', 0, 3, 'ascii')
const compressed = buffer.subarray(8)
const decompressed = inflateSync(compressed)
return Buffer.concat([header, decompressed])
if (sig === "CWS") {
const header = Buffer.alloc(8);
buffer.copy(header, 0, 0, 8);
header.write("FWS", 0, 3, "ascii");
const compressed = buffer.subarray(8);
const decompressed = inflateSync(compressed);
return Buffer.concat([header, decompressed]);
}
if (sig === 'ZWS') {
const fileLength = buffer.readUInt32LE(4)
const compressedSize = buffer.readUInt32LE(8)
const lzmaData = buffer.subarray(12, 12 + compressedSize)
if (sig === "ZWS") {
const fileLength = buffer.readUInt32LE(4);
const compressedSize = buffer.readUInt32LE(8);
const lzmaData = buffer.subarray(12, 12 + compressedSize);
const props = lzmaData.subarray(0, 5)
const compressedPayload = lzmaData.subarray(5)
const props = lzmaData.subarray(0, 5);
const compressedPayload = lzmaData.subarray(5);
const uncompressedSize = fileLength - 8
const lzmaHeader = Buffer.alloc(13)
props.copy(lzmaHeader, 0)
lzmaHeader.writeUInt32LE(uncompressedSize, 5)
lzmaHeader.writeUInt32LE(0, 9)
const uncompressedSize = fileLength - 8;
const lzmaHeader = Buffer.alloc(13);
props.copy(lzmaHeader, 0);
lzmaHeader.writeUInt32LE(uncompressedSize, 5);
lzmaHeader.writeUInt32LE(0, 9);
const lzmaStream = Buffer.concat([lzmaHeader, compressedPayload])
const decompressed = Buffer.from(LZMA.decompress(lzmaStream) as number[])
const lzmaStream = Buffer.concat([lzmaHeader, compressedPayload]);
const decompressed = Buffer.from(LZMA.decompress(lzmaStream) as number[]);
const header = Buffer.alloc(8)
buffer.copy(header, 0, 0, 8)
header.write('FWS', 0, 3, 'ascii')
return Buffer.concat([header, decompressed])
const header = Buffer.alloc(8);
buffer.copy(header, 0, 0, 8);
header.write("FWS", 0, 3, "ascii");
return Buffer.concat([header, decompressed]);
}
throw new Error(`Unknown SWF signature: ${sig}`)
throw new Error(`Unknown SWF signature: ${sig}`);
}
function readRect(buf: Buffer, offset: number): number {
const nbits = (buf[offset] >> 3) & 0x1f
const totalBits = 5 + nbits * 4
const totalBytes = Math.ceil(totalBits / 8)
return offset + totalBytes
const nbits = (buf[offset] >> 3) & 0x1f;
const totalBits = 5 + nbits * 4;
const totalBytes = Math.ceil(totalBits / 8);
return offset + totalBytes;
}
// ── SWF Tag Reader ─────────────────────────────────────────────────
export function parseSwfTags(swfBuffer: Buffer): SwfTag[] {
const tags: SwfTag[] = []
const tags: SwfTag[] = [];
let offset = 8
offset = readRect(swfBuffer, offset)
offset += 4
let offset = 8;
offset = readRect(swfBuffer, offset);
offset += 4;
while (offset < swfBuffer.length) {
if (offset + 2 > swfBuffer.length) break
if (offset + 2 > swfBuffer.length) break;
const tagCodeAndLength = swfBuffer.readUInt16LE(offset)
offset += 2
const tagCodeAndLength = swfBuffer.readUInt16LE(offset);
offset += 2;
const tagType = (tagCodeAndLength >> 6) & 0x3ff
let tagLength = tagCodeAndLength & 0x3f
const tagType = (tagCodeAndLength >> 6) & 0x3ff;
let tagLength = tagCodeAndLength & 0x3f;
if (tagLength === 0x3f) {
if (offset + 4 > swfBuffer.length) break
tagLength = swfBuffer.readUInt32LE(offset)
offset += 4
if (offset + 4 > swfBuffer.length) break;
tagLength = swfBuffer.readUInt32LE(offset);
offset += 4;
}
if (tagType === TAG_END) break
if (offset + tagLength > swfBuffer.length) break
if (tagType === TAG_END) break;
if (offset + tagLength > swfBuffer.length) break;
const tagData = swfBuffer.subarray(offset, offset + tagLength)
tags.push({ type: tagType, data: Buffer.from(tagData) })
offset += tagLength
const tagData = swfBuffer.subarray(offset, offset + tagLength);
tags.push({ type: tagType, data: Buffer.from(tagData) });
offset += tagLength;
}
return tags
return tags;
}
// ── SymbolClass Parser ─────────────────────────────────────────────
export function parseSymbolClass(data: Buffer): SymbolClassResult {
const classNames = new Map<number, string>()
const symbols = new Map<string, number>()
let offset = 0
const classNames = new Map<number, string>();
const symbols = new Map<string, number>();
let offset = 0;
const numSymbols = data.readUInt16LE(offset)
offset += 2
const numSymbols = data.readUInt16LE(offset);
offset += 2;
for (let i = 0; i < numSymbols; i++) {
if (offset + 2 > data.length) break
const charId = data.readUInt16LE(offset)
offset += 2
if (offset + 2 > data.length) break;
const charId = data.readUInt16LE(offset);
offset += 2;
const strStart = offset
while (offset < data.length && data[offset] !== 0) offset++
const name = data.toString('utf-8', strStart, offset)
offset++
const strStart = offset;
while (offset < data.length && data[offset] !== 0) offset++;
const name = data.toString("utf-8", strStart, offset);
offset++;
symbols.set(name, charId)
symbols.set(name, charId);
if (!classNames.has(charId)) {
classNames.set(charId, name)
classNames.set(charId, name);
}
}
return { classNames, symbols }
return { classNames, symbols };
}
// ── DefineBinaryData Parser ────────────────────────────────────────
export function parseBinaryData(data: Buffer): { id: number; content: Buffer } {
const id = data.readUInt16LE(0)
const content = data.subarray(6)
return { id, content }
const id = data.readUInt16LE(0);
const content = data.subarray(6);
return { id, content };
}
+338 -344
View File
@@ -7,575 +7,569 @@
// ── Interfaces ─────────────────────────────────────────────────────
export interface AssetOffset {
x: number
y: number
x: number;
y: number;
}
export interface NitroVisualization {
layerCount: number
angle: number
size: number
layers?: Record<string, Record<string, unknown>>
directions?: Record<string, Record<string, unknown>>
colors?: Record<string, unknown>
animations?: Record<string, unknown>
postures?: { defaultPosture?: string; postures?: { id: string; animationId: number }[] }
gestures?: { id: string; animationId: number }[]
layerCount: number;
angle: number;
size: number;
layers?: Record<string, Record<string, unknown>>;
directions?: Record<string, Record<string, unknown>>;
colors?: Record<string, unknown>;
animations?: Record<string, unknown>;
postures?: { defaultPosture?: string; postures?: { id: string; animationId: number }[] };
gestures?: { id: string; animationId: number }[];
}
// ── Minimal XML Parser ─────────────────────────────────────────────
export interface XmlNode {
tag: string
attrs: Record<string, string>
children: XmlNode[]
text: string
tag: string;
attrs: Record<string, string>;
children: XmlNode[];
text: string;
}
export function parseXml(xml: string): XmlNode | null {
const stack: XmlNode[] = []
const nodes: XmlNode[] = []
const stack: XmlNode[] = [];
const nodes: XmlNode[] = [];
// Remove XML declaration and comments
const clean = xml
.replace(/<\?[^?]*\?>/g, '')
.replace(/<!--[\s\S]*?-->/g, '')
.trim()
.replace(/<\?[^?]*\?>/g, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
const tagRe = /<\/?([a-zA-Z_][\w.-]*)((?:\s+[a-zA-Z_][\w.-]*\s*=\s*"[^"]*")*)\s*\/?>/g
let lastIndex = 0
const tagRe = /<\/?([a-zA-Z_][\w.-]*)((?:\s+[a-zA-Z_][\w.-]*\s*=\s*"[^"]*")*)\s*\/?>/g;
let lastIndex = 0;
for (const match of clean.matchAll(tagRe)) {
const fullMatch = match[0]
const tagName = match[1]
const attrsStr = match[2] || ''
const matchIndex = match.index ?? 0
const fullMatch = match[0];
const tagName = match[1];
const attrsStr = match[2] || "";
const matchIndex = match.index ?? 0;
const text = clean.substring(lastIndex, matchIndex).trim()
const text = clean.substring(lastIndex, matchIndex).trim();
if (text && stack.length > 0) {
stack[stack.length - 1].text += text
stack[stack.length - 1].text += text;
}
lastIndex = matchIndex + fullMatch.length
lastIndex = matchIndex + fullMatch.length;
if (fullMatch.startsWith('</')) {
if (fullMatch.startsWith("</")) {
if (stack.length > 0) {
const node = stack.pop()!
const node = stack.pop()!;
if (stack.length > 0) {
stack[stack.length - 1].children.push(node)
stack[stack.length - 1].children.push(node);
} else {
nodes.push(node)
nodes.push(node);
}
}
} else {
const attrs: Record<string, string> = {}
const attrRe = /([a-zA-Z_][\w.-]*)\s*=\s*"([^"]*)"/g
const attrs: Record<string, string> = {};
const attrRe = /([a-zA-Z_][\w.-]*)\s*=\s*"([^"]*)"/g;
for (const am of attrsStr.matchAll(attrRe)) {
attrs[am[1]] = am[2]
attrs[am[1]] = am[2];
}
const node: XmlNode = { tag: tagName, attrs, children: [], text: '' }
const node: XmlNode = { tag: tagName, attrs, children: [], text: "" };
if (fullMatch.endsWith('/>')) {
if (fullMatch.endsWith("/>")) {
if (stack.length > 0) {
stack[stack.length - 1].children.push(node)
stack[stack.length - 1].children.push(node);
} else {
nodes.push(node)
nodes.push(node);
}
} else {
stack.push(node)
stack.push(node);
}
}
}
while (stack.length > 1) {
const node = stack.pop()!
stack[stack.length - 1].children.push(node)
const node = stack.pop()!;
stack[stack.length - 1].children.push(node);
}
if (stack.length === 1) nodes.push(stack[0])
if (stack.length === 1) nodes.push(stack[0]);
return nodes[0] || null
return nodes[0] || null;
}
export function findChild(node: XmlNode, tag: string): XmlNode | undefined {
return node.children.find((c) => c.tag === tag)
return node.children.find((c) => c.tag === tag);
}
export function findChildren(node: XmlNode, tag: string): XmlNode[] {
return node.children.filter((c) => c.tag === tag)
return node.children.filter((c) => c.tag === tag);
}
// ── XML Data Processors ────────────────────────────────────────────
export interface ManifestAsset {
name: string
x: number
y: number
name: string;
x: number;
y: number;
}
export function processManifest(xml: string): { assets: ManifestAsset[] } {
const assets: ManifestAsset[] = []
const root = parseXml(xml)
if (!root) return { assets }
const assets: ManifestAsset[] = [];
const root = parseXml(xml);
if (!root) return { assets };
const libNode = root.tag === 'manifest' ? findChild(root, 'library') : root
if (!libNode) return { assets }
const libNode = root.tag === "manifest" ? findChild(root, "library") : root;
if (!libNode) return { assets };
const assetsNode = findChild(libNode, 'assets')
if (!assetsNode) return { assets }
const assetsNode = findChild(libNode, "assets");
if (!assetsNode) return { assets };
for (const asset of findChildren(assetsNode, 'asset')) {
const name = asset.attrs.name || ''
if (!name) continue
for (const asset of findChildren(assetsNode, "asset")) {
const name = asset.attrs.name || "";
if (!name) continue;
// Skip shadow sprites
if (name.startsWith('sh_')) continue
if (name.startsWith("sh_")) continue;
let x = 0,
y = 0
const paramNode = findChild(asset, 'param')
if (paramNode && paramNode.attrs.key === 'offset') {
const parts = (paramNode.attrs.value || '').split(',')
x = parseInt(parts[0] || '0', 10)
y = parseInt(parts[1] || '0', 10)
y = 0;
const paramNode = findChild(asset, "param");
if (paramNode && paramNode.attrs.key === "offset") {
const parts = (paramNode.attrs.value || "").split(",");
x = parseInt(parts[0] || "0", 10);
y = parseInt(parts[1] || "0", 10);
}
assets.push({ name, x, y })
assets.push({ name, x, y });
}
return { assets }
return { assets };
}
export interface PaletteEntry {
id: number
source?: string
master?: boolean
tags?: string[]
breed?: number
colorTag?: number
color1?: string
color2?: string
id: number;
source?: string;
master?: boolean;
tags?: string[];
breed?: number;
colorTag?: number;
color1?: string;
color2?: string;
}
export function processAssetsXml(xml: string): {
offsets: Map<string, AssetOffset>
palettes: Record<string, PaletteEntry>
offsets: Map<string, AssetOffset>;
palettes: Record<string, PaletteEntry>;
} {
const offsets = new Map<string, AssetOffset>()
const palettes: Record<string, PaletteEntry> = {}
const root = parseXml(xml)
if (!root) return { offsets, palettes }
const offsets = new Map<string, AssetOffset>();
const palettes: Record<string, PaletteEntry> = {};
const root = parseXml(xml);
if (!root) return { offsets, palettes };
const assetsNode = root.tag === 'assets' ? root : findChild(root, 'assets') || root
const assetsNode = root.tag === "assets" ? root : findChild(root, "assets") || root;
for (const asset of findChildren(assetsNode, 'asset')) {
const name = asset.attrs.name || ''
if (!name) continue
for (const asset of findChildren(assetsNode, "asset")) {
const name = asset.attrs.name || "";
if (!name) continue;
const x = parseInt(asset.attrs.x || '0', 10)
const y = parseInt(asset.attrs.y || '0', 10)
const x = parseInt(asset.attrs.x || "0", 10);
const y = parseInt(asset.attrs.y || "0", 10);
offsets.set(name, { x, y })
offsets.set(name, { x, y });
}
// Parse palettes
for (const pal of findChildren(assetsNode, 'palette')) {
const id = parseInt(pal.attrs.id || '0', 10)
const entry: PaletteEntry = { id }
if (pal.attrs.source) entry.source = pal.attrs.source
if (pal.attrs.master === '1' || pal.attrs.master === 'true') entry.master = true
if (pal.attrs.tags) entry.tags = pal.attrs.tags.split(',')
if (pal.attrs.breed) entry.breed = parseInt(pal.attrs.breed, 10)
if (pal.attrs.colorTag) entry.colorTag = parseInt(pal.attrs.colorTag, 10)
if (pal.attrs.color1) entry.color1 = pal.attrs.color1
if (pal.attrs.color2) entry.color2 = pal.attrs.color2
palettes[String(id)] = entry
for (const pal of findChildren(assetsNode, "palette")) {
const id = parseInt(pal.attrs.id || "0", 10);
const entry: PaletteEntry = { id };
if (pal.attrs.source) entry.source = pal.attrs.source;
if (pal.attrs.master === "1" || pal.attrs.master === "true") entry.master = true;
if (pal.attrs.tags) entry.tags = pal.attrs.tags.split(",");
if (pal.attrs.breed) entry.breed = parseInt(pal.attrs.breed, 10);
if (pal.attrs.colorTag) entry.colorTag = parseInt(pal.attrs.colorTag, 10);
if (pal.attrs.color1) entry.color1 = pal.attrs.color1;
if (pal.attrs.color2) entry.color2 = pal.attrs.color2;
palettes[String(id)] = entry;
}
return { offsets, palettes }
return { offsets, palettes };
}
export function processIndex(
xml: string,
): { visualizationType: string; logicType: string; specialtype: number } | null {
const root = parseXml(xml)
if (!root) return null
const root = parseXml(xml);
if (!root) return null;
const obj = root.tag === 'object' ? root : findChild(root, 'object')
if (!obj) return null
const obj = root.tag === "object" ? root : findChild(root, "object");
if (!obj) return null;
return {
visualizationType: obj.attrs.visualization || '',
logicType: obj.attrs.logic || '',
specialtype: parseInt(obj.attrs.specialtype || '0', 10),
}
visualizationType: obj.attrs.visualization || "",
logicType: obj.attrs.logic || "",
specialtype: parseInt(obj.attrs.specialtype || "0", 10),
};
}
export interface LogicResult {
dimensions: { x: number; y: number; z: number; centerZ?: number }
directions: number[]
maskType: string
canstandon: boolean
cansiton: boolean
canlayon: boolean
action?: { link?: string; startState?: number }
credits?: string
soundSample?: { id: number; noPitch?: boolean }
customVars?: { variables: string[] }
particleSystems?: Record<string, unknown>[]
planetSystems?: Record<string, unknown>[]
dimensions: { x: number; y: number; z: number; centerZ?: number };
directions: number[];
maskType: string;
canstandon: boolean;
cansiton: boolean;
canlayon: boolean;
action?: { link?: string; startState?: number };
credits?: string;
soundSample?: { id: number; noPitch?: boolean };
customVars?: { variables: string[] };
particleSystems?: Record<string, unknown>[];
planetSystems?: Record<string, unknown>[];
}
export function processLogic(xml: string): LogicResult {
const defaults: LogicResult = {
dimensions: { x: 1, y: 1, z: 0 },
directions: [0],
maskType: '',
maskType: "",
canstandon: false,
cansiton: false,
canlayon: false,
}
const root = parseXml(xml)
if (!root) return defaults
};
const root = parseXml(xml);
if (!root) return defaults;
const model = findChild(root, 'model') || root
const dims = findChild(model, 'dimensions')
const dirsNode = findChild(model, 'directions')
const model = findChild(root, "model") || root;
const dims = findChild(model, "dimensions");
const dirsNode = findChild(model, "directions");
const dimensions: LogicResult['dimensions'] = {
x: parseInt(dims?.attrs.x || '1', 10),
y: parseInt(dims?.attrs.y || '1', 10),
z: parseFloat(dims?.attrs.z || '0'),
}
const dimensions: LogicResult["dimensions"] = {
x: parseInt(dims?.attrs.x || "1", 10),
y: parseInt(dims?.attrs.y || "1", 10),
z: parseFloat(dims?.attrs.z || "0"),
};
if (dims?.attrs.centerZ) {
dimensions.centerZ = parseFloat(dims.attrs.centerZ)
dimensions.centerZ = parseFloat(dims.attrs.centerZ);
}
const directions: number[] = []
const directions: number[] = [];
if (dirsNode) {
for (const d of findChildren(dirsNode, 'direction')) {
directions.push(parseInt(d.attrs.id || '0', 10))
for (const d of findChildren(dirsNode, "direction")) {
directions.push(parseInt(d.attrs.id || "0", 10));
}
}
// Default directions [0, 90] when missing (matches Retrosprite behavior)
if (directions.length === 0) directions.push(0, 90)
if (directions.length === 0) directions.push(0, 90);
// Parse mask type
const maskNode = findChild(root, 'mask')
const maskType = maskNode?.attrs.type || ''
const maskNode = findChild(root, "mask");
const maskType = maskNode?.attrs.type || "";
// Infer canstandon/cansiton/canlayon from action nodes
const actions = findChildren(root, 'action')
const cansiton = actions.some((a) => a.attrs.kind === 'sit')
const canlayon = actions.some((a) => a.attrs.kind === 'lay')
const canstandon = !cansiton && !canlayon && dimensions.z > 0
const actions = findChildren(root, "action");
const cansiton = actions.some((a) => a.attrs.kind === "sit");
const canlayon = actions.some((a) => a.attrs.kind === "lay");
const canstandon = !cansiton && !canlayon && dimensions.z > 0;
const result: LogicResult = { dimensions, directions, maskType, canstandon, cansiton, canlayon }
const result: LogicResult = { dimensions, directions, maskType, canstandon, cansiton, canlayon };
// Parse action link/startState
const actionNode = findChild(root, 'action')
const actionNode = findChild(root, "action");
if (actionNode && (actionNode.attrs.link || actionNode.attrs.startState)) {
const action: LogicResult['action'] = {}
if (actionNode.attrs.link) action.link = actionNode.attrs.link
if (actionNode.attrs.startState) action.startState = parseInt(actionNode.attrs.startState, 10)
result.action = action
const action: LogicResult["action"] = {};
if (actionNode.attrs.link) action.link = actionNode.attrs.link;
if (actionNode.attrs.startState) action.startState = parseInt(actionNode.attrs.startState, 10);
result.action = action;
}
// Parse credits
const creditsNode = findChild(root, 'credits')
const creditsNode = findChild(root, "credits");
if (creditsNode?.attrs.value) {
result.credits = creditsNode.attrs.value
result.credits = creditsNode.attrs.value;
}
// Parse soundSample
const soundNode = findChild(root, 'sound')
const soundNode = findChild(root, "sound");
if (soundNode) {
const sampleNode = findChild(soundNode, 'sample')
const sampleNode = findChild(soundNode, "sample");
if (sampleNode) {
const sample: LogicResult['soundSample'] = { id: parseInt(sampleNode.attrs.id || '0', 10) }
if (sampleNode.attrs.noPitch === '1') sample.noPitch = true
result.soundSample = sample
const sample: LogicResult["soundSample"] = { id: parseInt(sampleNode.attrs.id || "0", 10) };
if (sampleNode.attrs.noPitch === "1") sample.noPitch = true;
result.soundSample = sample;
}
}
// Parse customVars
const customVarsNode = findChild(root, 'customVars')
const customVarsNode = findChild(root, "customVars");
if (customVarsNode) {
const variables: string[] = []
for (const v of findChildren(customVarsNode, 'variable')) {
if (v.attrs.name) variables.push(v.attrs.name)
const variables: string[] = [];
for (const v of findChildren(customVarsNode, "variable")) {
if (v.attrs.name) variables.push(v.attrs.name);
}
if (variables.length > 0) result.customVars = { variables }
if (variables.length > 0) result.customVars = { variables };
}
// Parse particleSystems
const particleNode = findChild(root, 'particleSystems')
const particleNode = findChild(root, "particleSystems");
if (particleNode) {
const systems: Record<string, unknown>[] = []
for (const ps of findChildren(particleNode, 'particleSystem')) {
const system: Record<string, unknown> = {}
if (ps.attrs.size) system.size = parseInt(ps.attrs.size, 10)
if (ps.attrs.canvasId) system.canvasId = parseInt(ps.attrs.canvasId, 10)
if (ps.attrs.offsetY) system.offsetY = parseInt(ps.attrs.offsetY, 10)
if (ps.attrs.blend) system.blend = parseInt(ps.attrs.blend, 10)
if (ps.attrs.bgColor) system.bgColor = ps.attrs.bgColor
const systems: Record<string, unknown>[] = [];
for (const ps of findChildren(particleNode, "particleSystem")) {
const system: Record<string, unknown> = {};
if (ps.attrs.size) system.size = parseInt(ps.attrs.size, 10);
if (ps.attrs.canvasId) system.canvasId = parseInt(ps.attrs.canvasId, 10);
if (ps.attrs.offsetY) system.offsetY = parseInt(ps.attrs.offsetY, 10);
if (ps.attrs.blend) system.blend = parseInt(ps.attrs.blend, 10);
if (ps.attrs.bgColor) system.bgColor = ps.attrs.bgColor;
const emitters: Record<string, unknown>[] = []
for (const em of findChildren(ps, 'emitter')) {
const emitter: Record<string, unknown> = {}
if (em.attrs.id) emitter.id = parseInt(em.attrs.id, 10)
if (em.attrs.name) emitter.name = em.attrs.name
if (em.attrs.spriteId) emitter.spriteId = parseInt(em.attrs.spriteId, 10)
if (em.attrs.maxNumParticles)
emitter.maxNumParticles = parseInt(em.attrs.maxNumParticles, 10)
if (em.attrs.particlesPerFrame)
emitter.particlesPerFrame = parseInt(em.attrs.particlesPerFrame, 10)
if (em.attrs.burstPulse) emitter.burstPulse = parseInt(em.attrs.burstPulse, 10)
if (em.attrs.fuseTime) emitter.fuseTime = parseInt(em.attrs.fuseTime, 10)
const emitters: Record<string, unknown>[] = [];
for (const em of findChildren(ps, "emitter")) {
const emitter: Record<string, unknown> = {};
if (em.attrs.id) emitter.id = parseInt(em.attrs.id, 10);
if (em.attrs.name) emitter.name = em.attrs.name;
if (em.attrs.spriteId) emitter.spriteId = parseInt(em.attrs.spriteId, 10);
if (em.attrs.maxNumParticles) emitter.maxNumParticles = parseInt(em.attrs.maxNumParticles, 10);
if (em.attrs.particlesPerFrame) emitter.particlesPerFrame = parseInt(em.attrs.particlesPerFrame, 10);
if (em.attrs.burstPulse) emitter.burstPulse = parseInt(em.attrs.burstPulse, 10);
if (em.attrs.fuseTime) emitter.fuseTime = parseInt(em.attrs.fuseTime, 10);
const simNode = findChild(em, 'simulation')
const simNode = findChild(em, "simulation");
if (simNode) {
const simulation: Record<string, unknown> = {}
if (simNode.attrs.force) simulation.force = parseFloat(simNode.attrs.force)
if (simNode.attrs.direction) simulation.direction = parseFloat(simNode.attrs.direction)
if (simNode.attrs.gravity) simulation.gravity = parseFloat(simNode.attrs.gravity)
if (simNode.attrs.airFriction)
simulation.airFriction = parseFloat(simNode.attrs.airFriction)
if (simNode.attrs.shape) simulation.shape = simNode.attrs.shape
if (simNode.attrs.energy) simulation.energy = parseFloat(simNode.attrs.energy)
emitter.simulation = simulation
const simulation: Record<string, unknown> = {};
if (simNode.attrs.force) simulation.force = parseFloat(simNode.attrs.force);
if (simNode.attrs.direction) simulation.direction = parseFloat(simNode.attrs.direction);
if (simNode.attrs.gravity) simulation.gravity = parseFloat(simNode.attrs.gravity);
if (simNode.attrs.airFriction) simulation.airFriction = parseFloat(simNode.attrs.airFriction);
if (simNode.attrs.shape) simulation.shape = simNode.attrs.shape;
if (simNode.attrs.energy) simulation.energy = parseFloat(simNode.attrs.energy);
emitter.simulation = simulation;
}
const particles: Record<string, unknown>[] = []
for (const p of findChildren(em, 'particle')) {
const particle: Record<string, unknown> = {}
if (p.attrs.isEmitter === '1') particle.isEmitter = true
if (p.attrs.lifeTime) particle.lifeTime = parseInt(p.attrs.lifeTime, 10)
if (p.attrs.fade === '1') particle.fade = true
const pFrames: string[] = []
for (const pf of findChildren(p, 'frame')) {
if (pf.attrs.name) pFrames.push(pf.attrs.name)
const particles: Record<string, unknown>[] = [];
for (const p of findChildren(em, "particle")) {
const particle: Record<string, unknown> = {};
if (p.attrs.isEmitter === "1") particle.isEmitter = true;
if (p.attrs.lifeTime) particle.lifeTime = parseInt(p.attrs.lifeTime, 10);
if (p.attrs.fade === "1") particle.fade = true;
const pFrames: string[] = [];
for (const pf of findChildren(p, "frame")) {
if (pf.attrs.name) pFrames.push(pf.attrs.name);
}
if (pFrames.length > 0) particle.frames = pFrames
particles.push(particle)
if (pFrames.length > 0) particle.frames = pFrames;
particles.push(particle);
}
if (particles.length > 0) emitter.particles = particles
emitters.push(emitter)
if (particles.length > 0) emitter.particles = particles;
emitters.push(emitter);
}
if (emitters.length > 0) system.emitters = emitters
systems.push(system)
if (emitters.length > 0) system.emitters = emitters;
systems.push(system);
}
if (systems.length > 0) result.particleSystems = systems
if (systems.length > 0) result.particleSystems = systems;
}
// Parse planetSystems
const planetNode = findChild(root, 'planetSystems')
const planetNode = findChild(root, "planetSystems");
if (planetNode) {
const systems: Record<string, unknown>[] = []
const systems: Record<string, unknown>[] = [];
for (const ps of planetNode.children) {
const system: Record<string, unknown> = { ...ps.attrs }
const system: Record<string, unknown> = { ...ps.attrs };
// Convert numeric attrs
for (const key of Object.keys(system)) {
const val = system[key] as string
if (/^-?\d+(\.\d+)?$/.test(val)) system[key] = parseFloat(val)
const val = system[key] as string;
if (/^-?\d+(\.\d+)?$/.test(val)) system[key] = parseFloat(val);
}
systems.push(system)
systems.push(system);
}
if (systems.length > 0) result.planetSystems = systems
if (systems.length > 0) result.planetSystems = systems;
}
return result
return result;
}
export function processVisualization(xml: string): NitroVisualization[] {
const result: NitroVisualization[] = []
const root = parseXml(xml)
if (!root) return result
const result: NitroVisualization[] = [];
const root = parseXml(xml);
if (!root) return result;
const graphics = findChild(root, 'graphics') || root
const graphics = findChild(root, "graphics") || root;
for (const viz of findChildren(graphics, 'visualization')) {
const size = parseInt(viz.attrs.size || '64', 10)
for (const viz of findChildren(graphics, "visualization")) {
const size = parseInt(viz.attrs.size || "64", 10);
// Skip size 32 (small catalog icons) – only keep size 1 (special) and 64 (standard)
if (size === 32) continue
if (size === 32) continue;
const layerCount = parseInt(viz.attrs.layerCount || '1', 10)
const angle = parseInt(viz.attrs.angle || '45', 10)
const layerCount = parseInt(viz.attrs.layerCount || "1", 10);
const angle = parseInt(viz.attrs.angle || "45", 10);
const entry: NitroVisualization = { layerCount, angle, size }
const entry: NitroVisualization = { layerCount, angle, size };
// Layers (before directions to match reference key order)
const layersNode = findChild(viz, 'layers')
const layersNode = findChild(viz, "layers");
if (layersNode) {
const layers: Record<string, Record<string, unknown>> = {}
for (const l of findChildren(layersNode, 'layer')) {
const id = l.attrs.id || '0'
const layerObj: Record<string, unknown> = {}
if (l.attrs.x) layerObj.x = parseInt(l.attrs.x, 10)
if (l.attrs.y) layerObj.y = parseInt(l.attrs.y, 10)
if (l.attrs.z) layerObj.z = parseInt(l.attrs.z, 10)
if (l.attrs.alpha) layerObj.alpha = parseInt(l.attrs.alpha, 10)
if (l.attrs.ink) layerObj.ink = l.attrs.ink
if (l.attrs.tag) layerObj.tag = l.attrs.tag
if (l.attrs.ignoreMouse) layerObj.ignoreMouse = l.attrs.ignoreMouse === '1'
layers[id] = layerObj
const layers: Record<string, Record<string, unknown>> = {};
for (const l of findChildren(layersNode, "layer")) {
const id = l.attrs.id || "0";
const layerObj: Record<string, unknown> = {};
if (l.attrs.x) layerObj.x = parseInt(l.attrs.x, 10);
if (l.attrs.y) layerObj.y = parseInt(l.attrs.y, 10);
if (l.attrs.z) layerObj.z = parseInt(l.attrs.z, 10);
if (l.attrs.alpha) layerObj.alpha = parseInt(l.attrs.alpha, 10);
if (l.attrs.ink) layerObj.ink = l.attrs.ink;
if (l.attrs.tag) layerObj.tag = l.attrs.tag;
if (l.attrs.ignoreMouse) layerObj.ignoreMouse = l.attrs.ignoreMouse === "1";
layers[id] = layerObj;
}
if (Object.keys(layers).length > 0) entry.layers = layers
if (Object.keys(layers).length > 0) entry.layers = layers;
}
// Directions
const dirsNode = findChild(viz, 'directions')
const dirsNode = findChild(viz, "directions");
if (dirsNode) {
const dirs: Record<string, Record<string, unknown>> = {}
for (const d of findChildren(dirsNode, 'direction')) {
const id = d.attrs.id || '0'
const layersInDir = findChildren(d, 'layer')
const dirs: Record<string, Record<string, unknown>> = {};
for (const d of findChildren(dirsNode, "direction")) {
const id = d.attrs.id || "0";
const layersInDir = findChildren(d, "layer");
if (layersInDir.length > 0) {
const layerData: Record<string, Record<string, unknown>> = {}
const layerData: Record<string, Record<string, unknown>> = {};
for (const l of layersInDir) {
const lid = l.attrs.id || '0'
const layerObj: Record<string, unknown> = {}
if (l.attrs.x) layerObj.x = parseInt(l.attrs.x, 10)
if (l.attrs.y) layerObj.y = parseInt(l.attrs.y, 10)
if (l.attrs.z) layerObj.z = parseFloat(l.attrs.z)
if (l.attrs.alpha) layerObj.alpha = parseInt(l.attrs.alpha, 10)
if (l.attrs.ink) layerObj.ink = l.attrs.ink
if (l.attrs.tag) layerObj.tag = l.attrs.tag
if (l.attrs.ignoreMouse) layerObj.ignoreMouse = l.attrs.ignoreMouse === '1'
layerData[lid] = layerObj
const lid = l.attrs.id || "0";
const layerObj: Record<string, unknown> = {};
if (l.attrs.x) layerObj.x = parseInt(l.attrs.x, 10);
if (l.attrs.y) layerObj.y = parseInt(l.attrs.y, 10);
if (l.attrs.z) layerObj.z = parseFloat(l.attrs.z);
if (l.attrs.alpha) layerObj.alpha = parseInt(l.attrs.alpha, 10);
if (l.attrs.ink) layerObj.ink = l.attrs.ink;
if (l.attrs.tag) layerObj.tag = l.attrs.tag;
if (l.attrs.ignoreMouse) layerObj.ignoreMouse = l.attrs.ignoreMouse === "1";
layerData[lid] = layerObj;
}
dirs[id] = { layers: layerData }
dirs[id] = { layers: layerData };
} else {
dirs[id] = {}
dirs[id] = {};
}
}
entry.directions = dirs
entry.directions = dirs;
}
// Colors
const colorsNode = findChild(viz, 'colors')
const colorsNode = findChild(viz, "colors");
if (colorsNode) {
const colors: Record<string, { layers: Record<string, { color: number }> }> = {}
for (const color of findChildren(colorsNode, 'color')) {
const cid = color.attrs.id || '0'
const colorLayers: Record<string, { color: number }> = {}
for (const cl of findChildren(color, 'colorLayer')) {
colorLayers[cl.attrs.id || '0'] = { color: parseInt(cl.attrs.color || '0', 16) }
const colors: Record<string, { layers: Record<string, { color: number }> }> = {};
for (const color of findChildren(colorsNode, "color")) {
const cid = color.attrs.id || "0";
const colorLayers: Record<string, { color: number }> = {};
for (const cl of findChildren(color, "colorLayer")) {
colorLayers[cl.attrs.id || "0"] = { color: parseInt(cl.attrs.color || "0", 16) };
}
colors[cid] = { layers: colorLayers }
colors[cid] = { layers: colorLayers };
}
if (Object.keys(colors).length > 0) entry.colors = colors
if (Object.keys(colors).length > 0) entry.colors = colors;
}
// Animations
const animsNode = findChild(viz, 'animations')
const animsNode = findChild(viz, "animations");
if (animsNode) {
const animations: Record<string, unknown> = {}
for (const anim of findChildren(animsNode, 'animation')) {
const aid = anim.attrs.id || '0'
const animLayers: Record<string, unknown> = {}
for (const al of findChildren(anim, 'animationLayer')) {
const alid = al.attrs.id || '0'
const frameSeqs: Record<string, unknown> = {}
for (const fs of findChildren(al, 'frameSequence')) {
const fsid = fs.attrs.id || Object.keys(frameSeqs).length.toString()
const frames: Record<string, Record<string, unknown>> = {}
let fi = 0
for (const f of findChildren(fs, 'frame')) {
const rawId = f.attrs.id || '0'
const animations: Record<string, unknown> = {};
for (const anim of findChildren(animsNode, "animation")) {
const aid = anim.attrs.id || "0";
const animLayers: Record<string, unknown> = {};
for (const al of findChildren(anim, "animationLayer")) {
const alid = al.attrs.id || "0";
const frameSeqs: Record<string, unknown> = {};
for (const fs of findChildren(al, "frameSequence")) {
const fsid = fs.attrs.id || Object.keys(frameSeqs).length.toString();
const frames: Record<string, Record<string, unknown>> = {};
let fi = 0;
for (const f of findChildren(fs, "frame")) {
const rawId = f.attrs.id || "0";
const frameObj: Record<string, unknown> = {
id: (rawId === 'NaN' ? 0 : parseInt(rawId, 10)) || 0,
}
if (f.attrs.x) frameObj.x = parseInt(f.attrs.x, 10)
if (f.attrs.y) frameObj.y = parseInt(f.attrs.y, 10)
if (f.attrs.randomX) frameObj.randomX = parseInt(f.attrs.randomX, 10)
if (f.attrs.randomY) frameObj.randomY = parseInt(f.attrs.randomY, 10)
id: (rawId === "NaN" ? 0 : parseInt(rawId, 10)) || 0,
};
if (f.attrs.x) frameObj.x = parseInt(f.attrs.x, 10);
if (f.attrs.y) frameObj.y = parseInt(f.attrs.y, 10);
if (f.attrs.randomX) frameObj.randomX = parseInt(f.attrs.randomX, 10);
if (f.attrs.randomY) frameObj.randomY = parseInt(f.attrs.randomY, 10);
// Per-direction offsets (keyed by sequential index, matching Retrosprite format)
const offsetNodes = findChildren(f, 'offset')
const offsetNodes = findChildren(f, "offset");
if (offsetNodes.length > 0) {
const offsets: Record<string, { direction: number; x: number; y: number }> = {}
const offsets: Record<string, { direction: number; x: number; y: number }> = {};
for (let oi = 0; oi < offsetNodes.length; oi++) {
const o = offsetNodes[oi]
const o = offsetNodes[oi];
offsets[String(oi)] = {
direction: parseInt(o.attrs.direction || '0', 10),
x: parseInt(o.attrs.x || '0', 10),
y: parseInt(o.attrs.y || '0', 10),
}
direction: parseInt(o.attrs.direction || "0", 10),
x: parseInt(o.attrs.x || "0", 10),
y: parseInt(o.attrs.y || "0", 10),
};
}
frameObj.offsets = offsets
frameObj.offsets = offsets;
}
frames[String(fi)] = frameObj
fi++
frames[String(fi)] = frameObj;
fi++;
}
const seqObj: Record<string, unknown> = { frames }
if (fs.attrs.loopCount) seqObj.loopCount = parseInt(fs.attrs.loopCount, 10)
if (fs.attrs.random) seqObj.random = parseInt(fs.attrs.random, 10)
frameSeqs[fsid] = seqObj
const seqObj: Record<string, unknown> = { frames };
if (fs.attrs.loopCount) seqObj.loopCount = parseInt(fs.attrs.loopCount, 10);
if (fs.attrs.random) seqObj.random = parseInt(fs.attrs.random, 10);
frameSeqs[fsid] = seqObj;
}
const layerObj: Record<string, unknown> = {
loopCount: parseInt(al.attrs.loopCount || '0', 10),
loopCount: parseInt(al.attrs.loopCount || "0", 10),
frameSequences: frameSeqs,
}
if (al.attrs.frameRepeat) layerObj.frameRepeat = parseInt(al.attrs.frameRepeat, 10)
if (al.attrs.random) layerObj.random = parseInt(al.attrs.random, 10)
animLayers[alid] = layerObj
};
if (al.attrs.frameRepeat) layerObj.frameRepeat = parseInt(al.attrs.frameRepeat, 10);
if (al.attrs.random) layerObj.random = parseInt(al.attrs.random, 10);
animLayers[alid] = layerObj;
}
const animObj: Record<string, unknown> = { layers: animLayers }
if (anim.attrs.transitionTo) animObj.transitionTo = parseInt(anim.attrs.transitionTo, 10)
if (anim.attrs.transitionFrom)
animObj.transitionFrom = parseInt(anim.attrs.transitionFrom, 10)
if (anim.attrs.immediateChangeFrom)
animObj.immediateChangeFrom = anim.attrs.immediateChangeFrom
if (anim.attrs.randomStart === '1') animObj.randomStart = true
animations[aid] = animObj
const animObj: Record<string, unknown> = { layers: animLayers };
if (anim.attrs.transitionTo) animObj.transitionTo = parseInt(anim.attrs.transitionTo, 10);
if (anim.attrs.transitionFrom) animObj.transitionFrom = parseInt(anim.attrs.transitionFrom, 10);
if (anim.attrs.immediateChangeFrom) animObj.immediateChangeFrom = anim.attrs.immediateChangeFrom;
if (anim.attrs.randomStart === "1") animObj.randomStart = true;
animations[aid] = animObj;
}
if (Object.keys(animations).length > 0) entry.animations = animations
if (Object.keys(animations).length > 0) entry.animations = animations;
}
// Postures
const posturesNode = findChild(viz, 'postures')
const posturesNode = findChild(viz, "postures");
if (posturesNode) {
const posturesList: { id: string; animationId: number }[] = []
for (const p of findChildren(posturesNode, 'posture')) {
const posturesList: { id: string; animationId: number }[] = [];
for (const p of findChildren(posturesNode, "posture")) {
posturesList.push({
id: p.attrs.id || '',
animationId: parseInt(p.attrs.animationId || '0', 10),
})
id: p.attrs.id || "",
animationId: parseInt(p.attrs.animationId || "0", 10),
});
}
const posturesObj: NitroVisualization['postures'] = {}
if (posturesNode.attrs.defaultPosture)
posturesObj.defaultPosture = posturesNode.attrs.defaultPosture
if (posturesList.length > 0) posturesObj.postures = posturesList
entry.postures = posturesObj
const posturesObj: NitroVisualization["postures"] = {};
if (posturesNode.attrs.defaultPosture) posturesObj.defaultPosture = posturesNode.attrs.defaultPosture;
if (posturesList.length > 0) posturesObj.postures = posturesList;
entry.postures = posturesObj;
}
// Gestures
const gesturesNode = findChild(viz, 'gestures')
const gesturesNode = findChild(viz, "gestures");
if (gesturesNode) {
const gesturesList: { id: string; animationId: number }[] = []
for (const g of findChildren(gesturesNode, 'gesture')) {
const gesturesList: { id: string; animationId: number }[] = [];
for (const g of findChildren(gesturesNode, "gesture")) {
gesturesList.push({
id: g.attrs.id || '',
animationId: parseInt(g.attrs.animationId || '0', 10),
})
id: g.attrs.id || "",
animationId: parseInt(g.attrs.animationId || "0", 10),
});
}
if (gesturesList.length > 0) entry.gestures = gesturesList
if (gesturesList.length > 0) entry.gestures = gesturesList;
}
result.push(entry)
result.push(entry);
}
return result
return result;
}
// ── Metadata Helpers ────────────────────────────────────────────────
export function extractPartColors(visualizations: NitroVisualization[]): { color: string[] } {
const viz64 = visualizations.find((v) => v.size === 64)
if (!viz64?.colors) return { color: [] }
return { color: Object.keys(viz64.colors) }
const viz64 = visualizations.find((v) => v.size === 64);
if (!viz64?.colors) return { color: [] };
return { color: Object.keys(viz64.colors) };
}
export function computeDefaultDir(directions: number[]): number {
if (directions.includes(2)) return 2
if (directions.includes(0)) return 0
return directions[0] || 0
if (directions.includes(2)) return 2;
if (directions.includes(0)) return 0;
return directions[0] || 0;
}
+9 -9
View File
@@ -1,13 +1,13 @@
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma";
/**
* Returns the set of user IDs that the given staff member has marked as
* "watch". Cached 60s + tag-invalidated by toggleUserWatch.
*/
export async function getWatchedUserIds(staffId: number): Promise<Set<number>> {
const ids = await getCachedWatchedIds(staffId)
return new Set(ids)
const ids = await getCachedWatchedIds(staffId);
return new Set(ids);
}
const getCachedWatchedIds = unstable_cache(
@@ -15,9 +15,9 @@ const getCachedWatchedIds = unstable_cache(
const rows = await prisma.userWatch.findMany({
where: { staffId },
select: { targetUserId: true },
})
return rows.map((r) => r.targetUserId)
});
return rows.map((r) => r.targetUserId);
},
['user-watch-list'],
{ revalidate: 60, tags: ['user-watch'] },
)
["user-watch-list"],
{ revalidate: 60, tags: ["user-watch"] },
);
+40 -40
View File
@@ -1,16 +1,16 @@
import { env } from '@/env'
import { prisma } from '../prisma'
import { env } from "@/env";
import { prisma } from "../prisma";
export type { WebhookAction } from '@/types/admin'
export type { WebhookAction } from "@/types/admin";
import type { WebhookAction } from '@/types/admin'
import type { WebhookAction } from "@/types/admin";
interface WebhookPayload {
action: WebhookAction
actor: string
target: string
targetId?: number
details?: string
action: WebhookAction;
actor: string;
target: string;
targetId?: number;
details?: string;
}
const ACTION_COLORS: Record<WebhookAction, number> = {
@@ -31,82 +31,82 @@ const ACTION_COLORS: Record<WebhookAction, number> = {
event_cancel: 0xf87272,
poll_create: 0x36d399,
poll_close: 0xa78bfa,
}
};
async function getSetting(cmsKey: string, envValue?: string): Promise<string | null> {
if (envValue) return envValue
if (envValue) return envValue;
try {
const setting = await prisma.websiteSetting.findUnique({ where: { key: cmsKey } })
return setting?.value || null
const setting = await prisma.websiteSetting.findUnique({ where: { key: cmsKey } });
return setting?.value || null;
} catch {
return null
return null;
}
}
async function sendDiscord(payload: WebhookPayload): Promise<void> {
const url = await getSetting('webhook_discord_url', env.DISCORD_WEBHOOK_URL)
if (!url) return
const url = await getSetting("webhook_discord_url", env.DISCORD_WEBHOOK_URL);
if (!url) return;
const embed = {
title: `[${payload.action.toUpperCase().replace('_', ' ')}]`,
title: `[${payload.action.toUpperCase().replace("_", " ")}]`,
color: ACTION_COLORS[payload.action] ?? 0x666666,
fields: [
{ name: 'Staff', value: payload.actor, inline: true },
{ name: 'Target', value: payload.target, inline: true },
{ name: "Staff", value: payload.actor, inline: true },
{ name: "Target", value: payload.target, inline: true },
],
timestamp: new Date().toISOString(),
}
};
if (payload.details) {
embed.fields.push({ name: 'Details', value: payload.details, inline: false })
embed.fields.push({ name: "Details", value: payload.details, inline: false });
}
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ embeds: [embed] }),
signal: AbortSignal.timeout(5000),
})
});
} catch (err) {
console.error('[Webhook] Discord send failed:', {
console.error("[Webhook] Discord send failed:", {
action: payload.action,
error: (err as Error).message,
})
});
}
}
async function sendTelegram(payload: WebhookPayload): Promise<void> {
const token = await getSetting('webhook_telegram_bot_token', env.TELEGRAM_BOT_TOKEN)
const chatId = await getSetting('webhook_telegram_chat_id', env.TELEGRAM_CHAT_ID)
if (!token || !chatId) return
const token = await getSetting("webhook_telegram_bot_token", env.TELEGRAM_BOT_TOKEN);
const chatId = await getSetting("webhook_telegram_chat_id", env.TELEGRAM_CHAT_ID);
if (!token || !chatId) return;
const text = [
`<b>[${payload.action.toUpperCase().replace('_', ' ')}]</b>`,
`<b>[${payload.action.toUpperCase().replace("_", " ")}]</b>`,
`<b>Staff:</b> ${payload.actor}`,
`<b>Target:</b> ${payload.target}`,
payload.details ? `<b>Details:</b> ${payload.details}` : '',
payload.details ? `<b>Details:</b> ${payload.details}` : "",
]
.filter(Boolean)
.join('\n')
.join("\n");
try {
await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' }),
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text, parse_mode: "HTML" }),
signal: AbortSignal.timeout(5000),
})
});
} catch (err) {
console.error('[Webhook] Telegram send failed:', {
console.error("[Webhook] Telegram send failed:", {
action: payload.action,
error: (err as Error).message,
})
});
}
}
/** Fire-and-forget notification to Discord + Telegram */
export function notify(payload: WebhookPayload): void {
sendDiscord(payload).catch(() => {})
sendTelegram(payload).catch(() => {})
sendDiscord(payload).catch(() => {});
sendTelegram(payload).catch(() => {});
}
+42 -14
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { contrastRatio, deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import {
contrastRatio,
deriveAdminPalette,
derivePublicForegrounds,
readableColor,
} from "@/lib/theme-contrast";
import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
describe("contrastRatio", () => {
@@ -89,7 +94,9 @@ describe("deriveAdminPalette", () => {
it("does not copy hostile public structural colors into admin surfaces", () => {
const admin = deriveAdminPalette({
color_background: "#ff00ff", color_surface: "#00ff00", color_primary: "#ffff00",
color_background: "#ff00ff",
color_surface: "#00ff00",
color_primary: "#ffff00",
});
expect(admin.canvas).not.toBe("#ff00ff");
expect(admin.surface).not.toBe("#00ff00");
@@ -108,9 +115,18 @@ describe("derivePublicForegrounds", () => {
it("covers admin hierarchy and every semantic status", () => {
const required = [
"subtle", "disabled", "sidebar", "input",
"success-text", "success-solid", "warning-text", "warning-solid",
"error-text", "error-solid", "info-text", "info-solid",
"subtle",
"disabled",
"sidebar",
"input",
"success-text",
"success-solid",
"warning-text",
"warning-solid",
"error-text",
"error-solid",
"info-text",
"info-solid",
];
for (const preset of Object.values(PRESETS)) {
for (const palette of [preset.light, preset.dark]) {
@@ -126,16 +142,28 @@ describe("derivePublicForegrounds", () => {
it("repairs hostile white-on-white custom colors", () => {
const result = derivePublicForegrounds({
color_background: "#ffffff", color_surface: "#ffffff", color_dropdown: "#ffffff",
color_navbar: "#ffffff", color_navbar_text: "#ffffff", color_text: "#ffffff",
color_text_muted: "#ffffff", color_primary: "#ffffff", color_accent: "#ffffff",
button_primary_color: "#ffffff", button_text_color: "#ffffff",
button_secondary_color: "#ffffff", button_secondary_text_color: "#ffffff",
button_danger_color: "#ffffff", button_danger_text_color: "#ffffff",
button_outline_color: "#ffffff", button_outline_text_color: "#ffffff",
link_color: "#ffffff", link_hover_color: "#ffffff",
color_background: "#ffffff",
color_surface: "#ffffff",
color_dropdown: "#ffffff",
color_navbar: "#ffffff",
color_navbar_text: "#ffffff",
color_text: "#ffffff",
color_text_muted: "#ffffff",
color_primary: "#ffffff",
color_accent: "#ffffff",
button_primary_color: "#ffffff",
button_text_color: "#ffffff",
button_secondary_color: "#ffffff",
button_secondary_text_color: "#ffffff",
button_danger_color: "#ffffff",
button_danger_text_color: "#ffffff",
button_outline_color: "#ffffff",
button_outline_text_color: "#ffffff",
link_color: "#ffffff",
link_hover_color: "#ffffff",
});
expect(result.pairs.length).toBeGreaterThan(10);
for (const pair of result.pairs) expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
for (const pair of result.pairs)
expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
});
});
+20 -13
View File
@@ -1,7 +1,11 @@
type Rgb = readonly [number, number, number];
export type PublicThemeColors = Record<string, string>;
export interface ContrastPair { name: string; foreground: string; background: string }
export interface ContrastPair {
name: string;
foreground: string;
background: string;
}
export interface AdminPalette {
canvas: string;
@@ -64,7 +68,9 @@ export function derivePublicForegrounds(c: PublicThemeColors) {
const primary = value("color_primary", "#f59e0b");
const accent = value("color_accent", "#10b981");
const make = (name: string, preferred: string, bg: string): ContrastPair => ({
name, foreground: readableColor(preferred, [bg]), background: bg,
name,
foreground: readableColor(preferred, [bg]),
background: bg,
});
const pairs = [
make("body", value("color_text", "#0f172a"), background),
@@ -81,18 +87,23 @@ export function derivePublicForegrounds(c: PublicThemeColors) {
make("primary", value("button_text_color", "#1e293b"), primary),
make("accent", "#ffffff", accent),
make("button-primary", value("button_text_color", "#1e293b"), value("button_primary_color", primary)),
make("button-secondary", value("button_secondary_text_color", "#ffffff"), value("button_secondary_color", "#22c55e")),
make("button-danger", value("button_danger_text_color", "#ffffff"), value("button_danger_color", "#ef4444")),
make(
"button-secondary",
value("button_secondary_text_color", "#ffffff"),
value("button_secondary_color", "#22c55e"),
),
make(
"button-danger",
value("button_danger_text_color", "#ffffff"),
value("button_danger_color", "#ef4444"),
),
make("button-outline", value("button_outline_text_color", "#1a1a2e"), surface),
make("pill", value("color_text", "#0f172a"), surface),
make("badge", value("button_text_color", "#1e293b"), primary),
make("overlay", "#ffffff", "#111827"),
...(["success", "warning", "error", "info"] as const).flatMap((status) => {
const statusColor = value(`color_${status}`, "#0f172a");
return [
make(`${status}-text`, statusColor, surface),
make(`${status}-solid`, "#ffffff", statusColor),
];
return [make(`${status}-text`, statusColor, surface), make(`${status}-solid`, "#ffffff", statusColor)];
}),
];
return { pairs, byName: Object.fromEntries(pairs.map((pair) => [pair.name, pair.foreground])) };
@@ -108,11 +119,7 @@ export function contrastRatio(foreground: string, background: string): number |
return (light + 0.05) / (dark + 0.05);
}
export function readableColor(
preferred: string,
backgrounds: readonly string[],
minimumRatio = 4.5,
): string {
export function readableColor(preferred: string, backgrounds: readonly string[], minimumRatio = 4.5): string {
const preferredRatios = backgrounds.map((background) => contrastRatio(preferred, background));
if (preferredRatios.some((ratio) => ratio === null)) return preferred;
if (preferredRatios.every((ratio) => (ratio as number) >= minimumRatio)) return preferred;
+24 -11
View File
@@ -2,19 +2,32 @@ import { deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/li
import type { ThemePalette } from "@/lib/theme-presets";
const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
color_primary: "color-primary", color_background: "color-background",
color_surface: "color-surface", color_dropdown: "color-dropdown",
color_navbar: "color-navbar", color_navbar_text: "color-navbar-text",
color_text: "color-text", color_text_muted: "color-text-muted",
color_accent: "color-accent", color_success: "color-success",
color_warning: "color-warning", color_error: "color-error", color_info: "color-info",
button_primary_color: "button-color", button_text_color: "button-text-color",
color_primary: "color-primary",
color_background: "color-background",
color_surface: "color-surface",
color_dropdown: "color-dropdown",
color_navbar: "color-navbar",
color_navbar_text: "color-navbar-text",
color_text: "color-text",
color_text_muted: "color-text-muted",
color_accent: "color-accent",
color_success: "color-success",
color_warning: "color-warning",
color_error: "color-error",
color_info: "color-info",
button_primary_color: "button-color",
button_text_color: "button-text-color",
button_secondary_color: "button-secondary-color",
button_secondary_text_color: "button-secondary-text-color",
button_danger_color: "button-danger-color", button_danger_text_color: "button-danger-text-color",
button_outline_color: "button-outline-color", button_outline_text_color: "button-outline-text-color",
link_color: "link-color", link_hover_color: "link-hover-color",
border_color: "border-color", gradient_from: "gradient-from", gradient_to: "gradient-to",
button_danger_color: "button-danger-color",
button_danger_text_color: "button-danger-text-color",
button_outline_color: "button-outline-color",
button_outline_text_color: "button-outline-text-color",
link_color: "link-color",
link_hover_color: "link-hover-color",
border_color: "border-color",
gradient_from: "gradient-from",
gradient_to: "gradient-to",
};
export function themePaletteCss(selector: string, palette: ThemePalette): string {
+39 -15
View File
@@ -318,25 +318,49 @@ const LEGACY_PALETTES: Record<string, Partial<ThemePalette>> = {
};
const LIGHT_BASE: ThemePalette = {
color_primary: "#f59e0b", color_background: "#f8fafc", color_surface: "#ffffff",
color_dropdown: "#ffffff", color_navbar: "#ffffff", color_navbar_text: "#1e293b",
color_text: "#0f172a", color_text_muted: "#64748b", color_accent: "#10b981",
color_success: "#16a34a", color_warning: "#eab308", color_error: "#ef4444",
color_info: "#0ea5e9", button_primary_color: "#f59e0b", button_text_color: "#1e293b",
button_secondary_color: "#22c55e", button_secondary_text_color: "#ffffff",
button_danger_color: "#ef4444", button_danger_text_color: "#ffffff",
button_outline_color: "#eeb425", button_outline_text_color: "#1a1a2e",
link_color: "#b77900", link_hover_color: "#8a5b00", border_color: "#eeb425",
gradient_from: "#f59e0b", gradient_to: "#10b981",
color_primary: "#f59e0b",
color_background: "#f8fafc",
color_surface: "#ffffff",
color_dropdown: "#ffffff",
color_navbar: "#ffffff",
color_navbar_text: "#1e293b",
color_text: "#0f172a",
color_text_muted: "#64748b",
color_accent: "#10b981",
color_success: "#16a34a",
color_warning: "#eab308",
color_error: "#ef4444",
color_info: "#0ea5e9",
button_primary_color: "#f59e0b",
button_text_color: "#1e293b",
button_secondary_color: "#22c55e",
button_secondary_text_color: "#ffffff",
button_danger_color: "#ef4444",
button_danger_text_color: "#ffffff",
button_outline_color: "#eeb425",
button_outline_text_color: "#1a1a2e",
link_color: "#b77900",
link_hover_color: "#8a5b00",
border_color: "#eeb425",
gradient_from: "#f59e0b",
gradient_to: "#10b981",
};
const DARK_BASE: ThemePalette = {
...LIGHT_BASE,
color_background: "#0b0d14", color_surface: "#141820", color_dropdown: "#1d222b",
color_navbar: "#141820", color_navbar_text: "#e5e7eb", color_text: "#e5e7eb",
color_text_muted: "#94a3b8", button_text_color: "#0b0d14",
button_secondary_text_color: "#08130b", button_danger_text_color: "#160606",
button_outline_text_color: "#f8fafc", link_color: "#f6c453", link_hover_color: "#ffe08a",
color_background: "#0b0d14",
color_surface: "#141820",
color_dropdown: "#1d222b",
color_navbar: "#141820",
color_navbar_text: "#e5e7eb",
color_text: "#e5e7eb",
color_text_muted: "#94a3b8",
button_text_color: "#0b0d14",
button_secondary_text_color: "#08130b",
button_danger_text_color: "#160606",
button_outline_text_color: "#f8fafc",
link_color: "#f6c453",
link_hover_color: "#ffe08a",
};
const DARK_FIRST = new Set(["Midnight", "Galaxy", "Cyberpunk", "Neon", "Dusk"]);
+15 -15
View File
@@ -3,31 +3,31 @@
// for ease of search/filter/render; the on-disk format is the nested object
// produced by `unflattenObject`.
export function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
const result: Record<string, string> = {}
export function flattenObject(obj: Record<string, unknown>, prefix = ""): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(result, flattenObject(value as Record<string, unknown>, fullKey))
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
Object.assign(result, flattenObject(value as Record<string, unknown>, fullKey));
} else {
result[fullKey] = String(value ?? '')
result[fullKey] = String(value ?? "");
}
}
return result
return result;
}
export function unflattenObject(flat: Record<string, string>): Record<string, unknown> {
const result: Record<string, unknown> = {}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(flat)) {
const parts = key.split('.')
let current: Record<string, unknown> = result
const parts = key.split(".");
let current: Record<string, unknown> = result;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]] || typeof current[parts[i]] !== 'object') {
current[parts[i]] = {}
if (!current[parts[i]] || typeof current[parts[i]] !== "object") {
current[parts[i]] = {};
}
current = current[parts[i]] as Record<string, unknown>
current = current[parts[i]] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value
current[parts[parts.length - 1]] = value;
}
return result
return result;
}
+19 -19
View File
@@ -15,32 +15,32 @@
* length equals the max per-channel tick-sum × 2 seconds.
*/
export const TRAX_TICK_SECONDS = 2
export const TRAX_TICK_SECONDS = 2;
export interface TraxEvent {
sampleId: number
ticks: number
sampleId: number;
ticks: number;
}
export interface TraxChannel {
channel: number
events: TraxEvent[]
channel: number;
events: TraxEvent[];
}
/** Split a raw track string into per-channel event lists. */
export function parseTraxChannels(track: string | null | undefined): TraxChannel[] {
if (!track) return []
const channels: TraxChannel[] = []
if (!track) return [];
const channels: TraxChannel[] = [];
for (const m of track.matchAll(/(\d+):([^:]+)/g)) {
const channel = Number(m[1])
const events: TraxEvent[] = []
for (const raw of m[2].split(';')) {
const pair = raw.match(/^(\d+),(\d+)$/)
if (pair) events.push({ sampleId: Number(pair[1]), ticks: Number(pair[2]) })
const channel = Number(m[1]);
const events: TraxEvent[] = [];
for (const raw of m[2].split(";")) {
const pair = raw.match(/^(\d+),(\d+)$/);
if (pair) events.push({ sampleId: Number(pair[1]), ticks: Number(pair[2]) });
}
if (events.length > 0) channels.push({ channel, events })
if (events.length > 0) channels.push({ channel, events });
}
return channels
return channels;
}
/**
@@ -48,15 +48,15 @@ export function parseTraxChannels(track: string | null | undefined): TraxChannel
* Returns [] for empty or unparseable tracks (i.e. single-sample song disks).
*/
export function parseTrackSamples(track: string | null | undefined): number[] {
const set = new Set<number>()
const set = new Set<number>();
for (const ch of parseTraxChannels(track)) {
for (const ev of ch.events) {
if (ev.sampleId > 0) set.add(ev.sampleId)
if (ev.sampleId > 0) set.add(ev.sampleId);
}
}
return [...set].sort((a, b) => a - b)
return [...set].sort((a, b) => a - b);
}
export function detectSoundtrackKind(track: string | null | undefined): 'single' | 'composition' {
return parseTrackSamples(track).length > 0 ? 'composition' : 'single'
export function detectSoundtrackKind(track: string | null | undefined): "single" | "composition" {
return parseTrackSamples(track).length > 0 ? "composition" : "single";
}
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff. Show more