This commit is contained in:
1 parent
e85e4d74ea
commit
2e4ed76121
378 files changed
+22404
-20686
No files matched your search
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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" }));
|
||||
});
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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,
|
||||
}));
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 @@
|
||||
export * from './catalog-categories/index'
|
||||
export * from "./catalog-categories/index";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()}`;
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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>);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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(() => {});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"]);
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user