This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { ddosDetected } from "@/lib/services/alert";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ddosDetected } from "@/lib/services/alert";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
@@ -16,81 +16,88 @@ const CLEANUP_INTERVAL = 300_000;
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanupStaleEntries(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
|
||||
for (const [k, v] of buckets) {
|
||||
if (now >= v.resetAt) buckets.delete(k);
|
||||
}
|
||||
for (const [k, v] of buckets) {
|
||||
if (now >= v.resetAt) buckets.delete(k);
|
||||
}
|
||||
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
const toRemove = Math.floor(sorted.length * 0.2);
|
||||
const keys = sorted.slice(0, toRemove).map((entry) => entry[0]);
|
||||
for (const key of keys) buckets.delete(key);
|
||||
}
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort(
|
||||
(a, b) => a[1].resetAt - b[1].resetAt,
|
||||
);
|
||||
const toRemove = Math.floor(sorted.length * 0.2);
|
||||
const keys = sorted.slice(0, toRemove).map((entry) => entry[0]);
|
||||
for (const key of keys) buckets.delete(key);
|
||||
}
|
||||
|
||||
if (recentlyBlocked.size > MAX_RECENTLY_BLOCKED) {
|
||||
recentlyBlocked.clear();
|
||||
}
|
||||
if (recentlyBlocked.size > MAX_RECENTLY_BLOCKED) {
|
||||
recentlyBlocked.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivate(ip: string): boolean {
|
||||
return (
|
||||
!ip ||
|
||||
ip === "0.0.0.0" ||
|
||||
ip === "::1" ||
|
||||
ip.startsWith("127.") ||
|
||||
ip.startsWith("10.") ||
|
||||
ip.startsWith("192.168.")
|
||||
);
|
||||
return (
|
||||
!ip ||
|
||||
ip === "0.0.0.0" ||
|
||||
ip === "::1" ||
|
||||
ip.startsWith("127.") ||
|
||||
ip.startsWith("10.") ||
|
||||
ip.startsWith("192.168.")
|
||||
);
|
||||
}
|
||||
|
||||
export async function isIpBlacklisted(ip: string): Promise<boolean> {
|
||||
if (isPrivate(ip)) return false;
|
||||
const now = Date.now();
|
||||
if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
|
||||
try {
|
||||
const rows = await prisma.websiteIpBlacklist.findMany({ select: { ipAddress: true } });
|
||||
blacklist = new Set(rows.map((r) => r.ipAddress));
|
||||
blacklistLoadedAt = now;
|
||||
} catch {
|
||||
/* keep stale set on DB error */
|
||||
}
|
||||
}
|
||||
return blacklist.has(ip);
|
||||
if (isPrivate(ip)) return false;
|
||||
const now = Date.now();
|
||||
if (now - blacklistLoadedAt >= BLACKLIST_TTL) {
|
||||
try {
|
||||
const rows = await prisma.websiteIpBlacklist.findMany({
|
||||
select: { ipAddress: true },
|
||||
});
|
||||
blacklist = new Set(rows.map((r) => r.ipAddress));
|
||||
blacklistLoadedAt = now;
|
||||
} catch {
|
||||
/* keep stale set on DB error */
|
||||
}
|
||||
}
|
||||
return blacklist.has(ip);
|
||||
}
|
||||
|
||||
export async function recordRequest(ip: string): Promise<void> {
|
||||
if (isPrivate(ip)) return;
|
||||
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
|
||||
if (isPrivate(ip)) return;
|
||||
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
|
||||
|
||||
const limit = Number(await siteSettings.get("abuse_guard_threshold", "200")) || 200;
|
||||
const windowMs = (Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) * 1000;
|
||||
const limit =
|
||||
Number(await siteSettings.get("abuse_guard_threshold", "200")) || 200;
|
||||
const windowMs =
|
||||
(Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) *
|
||||
1000;
|
||||
|
||||
const now = Date.now();
|
||||
const now = Date.now();
|
||||
|
||||
cleanupStaleEntries();
|
||||
cleanupStaleEntries();
|
||||
|
||||
const b = buckets.get(ip);
|
||||
if (!b || now >= b.resetAt) {
|
||||
buckets.set(ip, { count: 1, resetAt: now + windowMs });
|
||||
return;
|
||||
}
|
||||
b.count += 1;
|
||||
const b = buckets.get(ip);
|
||||
if (!b || now >= b.resetAt) {
|
||||
buckets.set(ip, { count: 1, resetAt: now + windowMs });
|
||||
return;
|
||||
}
|
||||
b.count += 1;
|
||||
|
||||
if (b.count >= limit && !recentlyBlocked.has(ip)) {
|
||||
recentlyBlocked.add(ip);
|
||||
setTimeout(() => recentlyBlocked.delete(ip), 60_000);
|
||||
try {
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
blacklistLoadedAt = 0;
|
||||
await ddosDetected(ip, b.count);
|
||||
} catch {
|
||||
/* ignore — alert/blacklist best-effort */
|
||||
}
|
||||
}
|
||||
if (b.count >= limit && !recentlyBlocked.has(ip)) {
|
||||
recentlyBlocked.add(ip);
|
||||
setTimeout(() => recentlyBlocked.delete(ip), 60_000);
|
||||
try {
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
blacklistLoadedAt = 0;
|
||||
await ddosDetected(ip, b.count);
|
||||
} catch {
|
||||
/* ignore — alert/blacklist best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
-135
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
|
||||
// === Alert service (AtomCMS → Next.js) ===========================================
|
||||
//
|
||||
@@ -20,53 +20,62 @@ import { logger } from "@/lib/logger";
|
||||
// no-op when their env var is unset). Add them to env.ts later if you want them
|
||||
// validated at boot.
|
||||
|
||||
export type AlertSeverity = "info" | "notice" | "warning" | "error" | "critical";
|
||||
export type AlertSeverity =
|
||||
| "info"
|
||||
| "notice"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "critical";
|
||||
|
||||
export interface SendAlertInput {
|
||||
/** Machine-readable category, e.g. "emulator", "security", "ddos". */
|
||||
type: string;
|
||||
/** Free-text severity; drives Discord embed colour + email subject prefix. */
|
||||
severity: AlertSeverity | string;
|
||||
/** Human-readable message body. */
|
||||
message: string;
|
||||
/** Optional structured payload stored verbatim in alert_logs.context (JSON). */
|
||||
context?: Record<string, unknown>;
|
||||
/** Machine-readable category, e.g. "emulator", "security", "ddos". */
|
||||
type: string;
|
||||
/** Free-text severity; drives Discord embed colour + email subject prefix. */
|
||||
severity: AlertSeverity | string;
|
||||
/** Human-readable message body. */
|
||||
message: string;
|
||||
/** Optional structured payload stored verbatim in alert_logs.context (JSON). */
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SendAlertResult {
|
||||
logged: boolean;
|
||||
sentViaDiscord: boolean;
|
||||
sentViaEmail: boolean;
|
||||
logged: boolean;
|
||||
sentViaDiscord: boolean;
|
||||
sentViaEmail: boolean;
|
||||
}
|
||||
|
||||
// Discord embed sidebar colours (decimal RGB) keyed by normalised severity.
|
||||
const DISCORD_COLORS: Record<string, number> = {
|
||||
critical: 0xc0392b,
|
||||
error: 0xe74c3c,
|
||||
danger: 0xe74c3c,
|
||||
warning: 0xf39c12,
|
||||
warn: 0xf39c12,
|
||||
success: 0x2ecc71,
|
||||
info: 0x3498db,
|
||||
notice: 0x9b59b6,
|
||||
critical: 0xc0392b,
|
||||
error: 0xe74c3c,
|
||||
danger: 0xe74c3c,
|
||||
warning: 0xf39c12,
|
||||
warn: 0xf39c12,
|
||||
success: 0x2ecc71,
|
||||
info: 0x3498db,
|
||||
notice: 0x9b59b6,
|
||||
};
|
||||
|
||||
function severityColor(severity: string): number {
|
||||
return DISCORD_COLORS[severity.toLowerCase()] ?? 0x95a5a6;
|
||||
return DISCORD_COLORS[severity.toLowerCase()] ?? 0x95a5a6;
|
||||
}
|
||||
|
||||
function discordWebhookUrl(): string | undefined {
|
||||
const url = process.env.DISCORD_WEBHOOK_URL?.trim();
|
||||
return url ? url : undefined;
|
||||
const url = process.env.DISCORD_WEBHOOK_URL?.trim();
|
||||
return url ? url : undefined;
|
||||
}
|
||||
|
||||
function alertEmail(): string | undefined {
|
||||
const addr = process.env.ALERT_EMAIL?.trim();
|
||||
return addr ? addr : undefined;
|
||||
const addr = process.env.ALERT_EMAIL?.trim();
|
||||
return addr ? addr : undefined;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,48 +83,57 @@ function escapeHtml(s: string): string {
|
||||
* when the webhook is unset, the request fails, or Discord returns non-2xx.
|
||||
*/
|
||||
async function postDiscord(input: SendAlertInput): Promise<boolean> {
|
||||
const url = discordWebhookUrl();
|
||||
if (!url) return false;
|
||||
const url = discordWebhookUrl();
|
||||
if (!url) return false;
|
||||
|
||||
const fields = input.context
|
||||
? Object.entries(input.context)
|
||||
.slice(0, 10)
|
||||
.map(([name, value]) => ({
|
||||
name: String(name).slice(0, 256) || "",
|
||||
value: String(value ?? "").slice(0, 1024) || "",
|
||||
inline: true,
|
||||
}))
|
||||
: undefined;
|
||||
const fields = input.context
|
||||
? Object.entries(input.context)
|
||||
.slice(0, 10)
|
||||
.map(([name, value]) => ({
|
||||
name: String(name).slice(0, 256) || "",
|
||||
value: String(value ?? "").slice(0, 1024) || "",
|
||||
inline: true,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const body = {
|
||||
username: `${env.HOTEL_NAME} Alerts`,
|
||||
embeds: [
|
||||
{
|
||||
title: `[${String(input.severity).toUpperCase()}] ${input.type}`.slice(0, 256),
|
||||
description: input.message.slice(0, 4096),
|
||||
color: severityColor(input.severity),
|
||||
timestamp: new Date().toISOString(),
|
||||
...(fields && fields.length ? { fields } : {}),
|
||||
footer: { text: env.HOTEL_NAME },
|
||||
},
|
||||
],
|
||||
};
|
||||
const body = {
|
||||
username: `${env.HOTEL_NAME} Alerts`,
|
||||
embeds: [
|
||||
{
|
||||
title: `[${String(input.severity).toUpperCase()}] ${input.type}`.slice(
|
||||
0,
|
||||
256,
|
||||
),
|
||||
description: input.message.slice(0, 4096),
|
||||
color: severityColor(input.severity),
|
||||
timestamp: new Date().toISOString(),
|
||||
...(fields?.length ? { fields } : {}),
|
||||
footer: { text: env.HOTEL_NAME },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.error("Discord webhook returned non-OK status", { module: "alert", status: res.status });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("Discord webhook failed", { module: "alert", error: (e as Error).message });
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.error("Discord webhook returned non-OK status", {
|
||||
module: "alert",
|
||||
status: res.status,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("Discord webhook failed", {
|
||||
module: "alert",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,32 +142,37 @@ async function postDiscord(input: SendAlertInput): Promise<boolean> {
|
||||
* already swallows its own errors, but we guard defensively anyway.
|
||||
*/
|
||||
async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
const to = alertEmail();
|
||||
if (!to) return false;
|
||||
const to = alertEmail();
|
||||
if (!to) return false;
|
||||
|
||||
const subject = `[${env.HOTEL_NAME}] ${String(input.severity).toUpperCase()} · ${input.type}`;
|
||||
const contextRows = input.context
|
||||
? Object.entries(input.context)
|
||||
.map(
|
||||
([k, v]) =>
|
||||
`<tr><td style="padding:4px 10px;font-weight:600">${escapeHtml(String(k))}</td>` +
|
||||
`<td style="padding:4px 10px">${escapeHtml(String(v ?? ""))}</td></tr>`,
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
const html =
|
||||
`<h2 style="margin:0 0 8px">${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}</h2>` +
|
||||
`<p style="margin:0 0 12px">${escapeHtml(input.message)}</p>` +
|
||||
(contextRows ? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>` : "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
|
||||
const subject = `[${env.HOTEL_NAME}] ${String(input.severity).toUpperCase()} · ${input.type}`;
|
||||
const contextRows = input.context
|
||||
? Object.entries(input.context)
|
||||
.map(
|
||||
([k, v]) =>
|
||||
`<tr><td style="padding:4px 10px;font-weight:600">${escapeHtml(String(k))}</td>` +
|
||||
`<td style="padding:4px 10px">${escapeHtml(String(v ?? ""))}</td></tr>`,
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
const html =
|
||||
`<h2 style="margin:0 0 8px">${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}</h2>` +
|
||||
`<p style="margin:0 0 12px">${escapeHtml(input.message)}</p>` +
|
||||
(contextRows
|
||||
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
||||
: "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
|
||||
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
} catch (e) {
|
||||
logger.error("Staff email failed", { module: "alert", error: (e as Error).message });
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
} catch (e) {
|
||||
logger.error("Staff email failed", {
|
||||
module: "alert",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,33 +181,41 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
* does not block the others. The returned result reports which channels
|
||||
* succeeded (also reflected in the alert_logs row's sent_via_* flags).
|
||||
*/
|
||||
export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult> {
|
||||
// Fan out Discord + email first so we can record their outcome on the row.
|
||||
const [sentViaDiscord, sentViaEmail] = await Promise.all([postDiscord(input), emailStaff(input)]);
|
||||
export async function sendAlert(
|
||||
input: SendAlertInput,
|
||||
): Promise<SendAlertResult> {
|
||||
// Fan out Discord + email first so we can record their outcome on the row.
|
||||
const [sentViaDiscord, sentViaEmail] = await Promise.all([
|
||||
postDiscord(input),
|
||||
emailStaff(input),
|
||||
]);
|
||||
|
||||
let logged = false;
|
||||
try {
|
||||
await prisma.alertLogs.create({
|
||||
data: {
|
||||
type: input.type.slice(0, 255),
|
||||
severity: String(input.severity).slice(0, 255),
|
||||
message: input.message,
|
||||
context: input.context ? (input.context as object) : undefined,
|
||||
sentViaDiscord,
|
||||
sentViaEmail,
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
logged = true;
|
||||
} catch (e) {
|
||||
// DB unreachable / schema drift: keep the alert best-effort. We already
|
||||
// notified Discord/email above, so the alert isn't lost.
|
||||
logger.error("Failed to persist alert_logs row", { module: "alert", error: (e as Error).message });
|
||||
}
|
||||
let logged = false;
|
||||
try {
|
||||
await prisma.alertLogs.create({
|
||||
data: {
|
||||
type: input.type.slice(0, 255),
|
||||
severity: String(input.severity).slice(0, 255),
|
||||
message: input.message,
|
||||
context: input.context ? (input.context as object) : undefined,
|
||||
sentViaDiscord,
|
||||
sentViaEmail,
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
logged = true;
|
||||
} catch (e) {
|
||||
// DB unreachable / schema drift: keep the alert best-effort. We already
|
||||
// notified Discord/email above, so the alert isn't lost.
|
||||
logger.error("Failed to persist alert_logs row", {
|
||||
module: "alert",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
return { logged, sentViaDiscord, sentViaEmail };
|
||||
return { logged, sentViaDiscord, sentViaEmail };
|
||||
}
|
||||
|
||||
// === Helpers =====================================================================
|
||||
@@ -194,29 +225,32 @@ export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult>
|
||||
* (e.g. raised by a health-check cron when the RCON socket can't connect).
|
||||
*/
|
||||
export function emulatorOffline(detail?: string): Promise<SendAlertResult> {
|
||||
return sendAlert({
|
||||
type: "emulator",
|
||||
severity: "critical",
|
||||
message: detail
|
||||
? `Emulator appears offline: ${detail}`
|
||||
: "Emulator appears offline — RCON connection could not be established.",
|
||||
context: {
|
||||
rconHost: env.RCON_HOST,
|
||||
rconPort: env.RCON_PORT,
|
||||
...(detail ? { detail } : {}),
|
||||
},
|
||||
});
|
||||
return sendAlert({
|
||||
type: "emulator",
|
||||
severity: "critical",
|
||||
message: detail
|
||||
? `Emulator appears offline: ${detail}`
|
||||
: "Emulator appears offline — RCON connection could not be established.",
|
||||
context: {
|
||||
rconHost: env.RCON_HOST,
|
||||
rconPort: env.RCON_PORT,
|
||||
...(detail ? { detail } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a WARNING alert that a possible DDoS / abusive request pattern was
|
||||
* detected from a single IP (count = requests seen in the sampling window).
|
||||
*/
|
||||
export function ddosDetected(ip: string, count: number): Promise<SendAlertResult> {
|
||||
return sendAlert({
|
||||
type: "ddos",
|
||||
severity: count >= 1000 ? "critical" : "warning",
|
||||
message: `Possible DDoS detected from ${ip} — ${count} requests in window.`,
|
||||
context: { ip, count },
|
||||
});
|
||||
export function ddosDetected(
|
||||
ip: string,
|
||||
count: number,
|
||||
): Promise<SendAlertResult> {
|
||||
return sendAlert({
|
||||
type: "ddos",
|
||||
severity: count >= 1000 ? "critical" : "warning",
|
||||
message: `Possible DDoS detected from ${ip} — ${count} requests in window.`,
|
||||
context: { ip, count },
|
||||
});
|
||||
}
|
||||
+87
-81
@@ -1,114 +1,120 @@
|
||||
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;
|
||||
/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> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SENSITIVE_KEY_RE.test(key) ? REDACTED : sanitizeAuditPayload(val, depth + 1);
|
||||
}
|
||||
return out;
|
||||
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);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeDiff(
|
||||
before?: Record<string, unknown>,
|
||||
after?: Record<string, unknown>,
|
||||
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] };
|
||||
}
|
||||
}
|
||||
for (const key of allKeys) {
|
||||
if (JSON.stringify(before[key]) !== JSON.stringify(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;
|
||||
const sanitizedAfter = entry.after
|
||||
? (sanitizeAuditPayload(entry.after) as Record<string, unknown>)
|
||||
: undefined;
|
||||
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
||||
const sanitizedBefore = entry.before
|
||||
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
|
||||
: undefined;
|
||||
const sanitizedAfter = entry.after
|
||||
? (sanitizeAuditPayload(entry.after) as Record<string, unknown>)
|
||||
: undefined;
|
||||
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
||||
|
||||
await prisma.adminAuditLog.create({
|
||||
data: {
|
||||
userId: entry.userId,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
targetId: entry.targetId,
|
||||
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
||||
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
||||
diff: diff ? JSON.stringify(diff) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await prisma.adminAuditLog.create({
|
||||
data: {
|
||||
userId: entry.userId,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
targetId: entry.targetId,
|
||||
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
||||
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
||||
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 where = search
|
||||
? {
|
||||
OR: [
|
||||
{ action: { contains: search } },
|
||||
{ target: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { id: "desc" },
|
||||
skip,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.adminAuditLog.count({ where }),
|
||||
]);
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { id: "desc" },
|
||||
skip,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.adminAuditLog.count({ where }),
|
||||
]);
|
||||
|
||||
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 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 enrichedRows = rows.map((r) => ({
|
||||
...r,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
}));
|
||||
const enrichedRows = rows.map((r) => ({
|
||||
...r,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
}));
|
||||
|
||||
return {
|
||||
rows: enrichedRows,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
lastPage: Math.ceil(total / perPage),
|
||||
};
|
||||
return {
|
||||
rows: enrichedRows,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
lastPage: Math.ceil(total / perPage),
|
||||
};
|
||||
}
|
||||
+55
-48
@@ -13,64 +13,71 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
* recaptcha_secret / recaptcha_site_key
|
||||
*/
|
||||
export interface CaptchaConfig {
|
||||
provider: "turnstile" | "recaptcha" | "none";
|
||||
siteKey: string;
|
||||
/** Form field the widget writes the token into. */
|
||||
field: string;
|
||||
provider: "turnstile" | "recaptcha" | "none";
|
||||
siteKey: string;
|
||||
/** Form field the widget writes the token into. */
|
||||
field: string;
|
||||
}
|
||||
|
||||
const TURNSTILE_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const TURNSTILE_URL =
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
||||
|
||||
/** Public config the register page needs to render the widget (no secrets). */
|
||||
export async function captchaConfig(): Promise<CaptchaConfig> {
|
||||
const provider = ((await siteSettings.get("captcha_provider", "none")) ?? "none").toLowerCase();
|
||||
if (provider === "turnstile") {
|
||||
return {
|
||||
provider: "turnstile",
|
||||
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
||||
field: "cf-turnstile-response",
|
||||
};
|
||||
}
|
||||
if (provider === "recaptcha") {
|
||||
return {
|
||||
provider: "recaptcha",
|
||||
siteKey: (await siteSettings.get("recaptcha_site_key", "")) ?? "",
|
||||
field: "g-recaptcha-response",
|
||||
};
|
||||
}
|
||||
return { provider: "none", siteKey: "", field: "" };
|
||||
const provider = (
|
||||
(await siteSettings.get("captcha_provider", "none")) ?? "none"
|
||||
).toLowerCase();
|
||||
if (provider === "turnstile") {
|
||||
return {
|
||||
provider: "turnstile",
|
||||
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
||||
field: "cf-turnstile-response",
|
||||
};
|
||||
}
|
||||
if (provider === "recaptcha") {
|
||||
return {
|
||||
provider: "recaptcha",
|
||||
siteKey: (await siteSettings.get("recaptcha_site_key", "")) ?? "",
|
||||
field: "g-recaptcha-response",
|
||||
};
|
||||
}
|
||||
return { provider: "none", siteKey: "", field: "" };
|
||||
}
|
||||
|
||||
/** Verify a submitted token. Returns true when allowed (incl. fail-open). */
|
||||
export async function verifyCaptcha(token: string | null, remoteIp?: string): Promise<boolean> {
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider === "none" || !cfg.siteKey) return true;
|
||||
export async function verifyCaptcha(
|
||||
token: string | null,
|
||||
remoteIp?: string,
|
||||
): Promise<boolean> {
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider === "none" || !cfg.siteKey) return true;
|
||||
|
||||
const secretKey = cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
|
||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
||||
if (!secret) return true; // configured but no secret — don't hard-block
|
||||
if (!token) return false;
|
||||
const secretKey =
|
||||
cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
|
||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
||||
if (!secret) return true; // configured but no secret — don't hard-block
|
||||
if (!token) return false;
|
||||
|
||||
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
|
||||
const body = new URLSearchParams({ secret, response: token });
|
||||
if (remoteIp) body.set("remoteip", remoteIp);
|
||||
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
|
||||
const body = new URLSearchParams({ secret, response: token });
|
||||
if (remoteIp) body.set("remoteip", remoteIp);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data?.success === true;
|
||||
} catch {
|
||||
// Network/timeout — fail-open so a provider outage can't lock out signups.
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data?.success === true;
|
||||
} catch {
|
||||
// Network/timeout — fail-open so a provider outage can't lock out signups.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -5,170 +5,191 @@ 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 }>;
|
||||
};
|
||||
wallitemtypes?: {
|
||||
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>;
|
||||
};
|
||||
roomitemtypes?: {
|
||||
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
|
||||
|
||||
async function getFurnitureData() {
|
||||
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;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
} catch {
|
||||
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 rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
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 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 ?? ""),
|
||||
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 ?? ""),
|
||||
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"),
|
||||
}));
|
||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||
id: Number(r.id),
|
||||
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 ?? ""),
|
||||
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"),
|
||||
}));
|
||||
|
||||
const [allPages, interactionTypesRaw] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { caption: "asc" },
|
||||
select: { id: true, caption: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
||||
const [allPages, interactionTypesRaw] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
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 baseItemIds = [
|
||||
...new Set(
|
||||
itemIdStrings.flatMap((s) =>
|
||||
s
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.filter((n) => n > 0),
|
||||
),
|
||||
),
|
||||
];
|
||||
// Resolve item names from items_base for enrichment + translate
|
||||
const itemIdStrings = items.map((i) => i.itemIds).filter(Boolean);
|
||||
const baseItemIds = [
|
||||
...new Set(
|
||||
itemIdStrings.flatMap((s) =>
|
||||
s
|
||||
.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.
|
||||
const baseItems: BaseItem[] =
|
||||
baseItemIds.length > 0
|
||||
? (
|
||||
await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
// 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.
|
||||
const baseItems: BaseItem[] =
|
||||
baseItemIds.length > 0
|
||||
? (
|
||||
await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
||||
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
||||
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
||||
@@ -177,120 +198,123 @@ export async function loadCatalogItemsData(pageId: number): Promise<CatalogItems
|
||||
clothing_on_walk
|
||||
FROM items_base WHERE id IN (${Prisma.join(baseItemIds)})
|
||||
`
|
||||
).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"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
allowWalk: Number(r.allow_walk ?? 0),
|
||||
allowGift: Number(r.allow_gift ?? 1),
|
||||
allowTrade: Number(r.allow_trade ?? 1),
|
||||
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"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
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 ?? ""),
|
||||
}))
|
||||
: [];
|
||||
).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"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
allowWalk: Number(r.allow_walk ?? 0),
|
||||
allowGift: Number(r.allow_gift ?? 1),
|
||||
allowTrade: Number(r.allow_trade ?? 1),
|
||||
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"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
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 ?? ""),
|
||||
}))
|
||||
: [];
|
||||
|
||||
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> = {};
|
||||
for (const item of items) {
|
||||
const firstBaseId = item.itemIds
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.find((n) => n > 0);
|
||||
if (firstBaseId && !catalogNameMap[firstBaseId]) {
|
||||
catalogNameMap[firstBaseId] = item.catalogName;
|
||||
}
|
||||
}
|
||||
// Build mapping: baseItemId -> catalogName
|
||||
const catalogNameMap: Record<number, string> = {};
|
||||
for (const item of items) {
|
||||
const firstBaseId = item.itemIds
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.find((n) => n > 0);
|
||||
if (firstBaseId && !catalogNameMap[firstBaseId]) {
|
||||
catalogNameMap[firstBaseId] = item.catalogName;
|
||||
}
|
||||
}
|
||||
|
||||
const catalogItems: CatalogItemEnriched[] = items.map((item) => {
|
||||
const firstBaseId = item.itemIds
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.find((n) => n > 0);
|
||||
const base = firstBaseId ? baseItemMap[firstBaseId] : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
catalogName: item.catalogName,
|
||||
itemIds: item.itemIds,
|
||||
costCredits: item.costCredits,
|
||||
costPoints: item.costPoints,
|
||||
pointsType: item.pointsType,
|
||||
amount: item.amount,
|
||||
limitedSells: item.limitedSells,
|
||||
limitedStack: item.limitedStack,
|
||||
orderNumber: item.orderNumber,
|
||||
offerId: item.offerId,
|
||||
songId: item.songId,
|
||||
haveOffer: item.haveOffer,
|
||||
clubOnly: item.clubOnly,
|
||||
extradata: item.extradata,
|
||||
baseName: base?.publicName || base?.itemName || "",
|
||||
baseItemName: base?.itemName || "",
|
||||
spriteId: base?.spriteId ?? 0,
|
||||
baseItem: base ? { ...base } : null,
|
||||
};
|
||||
});
|
||||
const catalogItems: CatalogItemEnriched[] = items.map((item) => {
|
||||
const firstBaseId = item.itemIds
|
||||
.split(";")
|
||||
.map(Number)
|
||||
.find((n) => n > 0);
|
||||
const base = firstBaseId ? baseItemMap[firstBaseId] : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
catalogName: item.catalogName,
|
||||
itemIds: item.itemIds,
|
||||
costCredits: item.costCredits,
|
||||
costPoints: item.costPoints,
|
||||
pointsType: item.pointsType,
|
||||
amount: item.amount,
|
||||
limitedSells: item.limitedSells,
|
||||
limitedStack: item.limitedStack,
|
||||
orderNumber: item.orderNumber,
|
||||
offerId: item.offerId,
|
||||
songId: item.songId,
|
||||
haveOffer: item.haveOffer,
|
||||
clubOnly: item.clubOnly,
|
||||
extradata: item.extradata,
|
||||
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>();
|
||||
for (const b of baseItems) {
|
||||
spriteToBaseId.set(b.spriteId, b.id);
|
||||
}
|
||||
// Build spriteId -> items_base.id mapping for FurnitureData.json lookup
|
||||
const spriteToBaseId = new Map<number, number>();
|
||||
for (const b of baseItems) {
|
||||
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();
|
||||
if (furniData) {
|
||||
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);
|
||||
if (baseId !== undefined) {
|
||||
foundBaseIds.add(baseId);
|
||||
furniDescriptionMap[baseId] = item.description ?? "";
|
||||
furniRevisionMap[baseId] = {
|
||||
classname: item.classname ?? "",
|
||||
revision: item.revision ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
if (furniData) {
|
||||
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);
|
||||
if (baseId !== undefined) {
|
||||
foundBaseIds.add(baseId);
|
||||
furniDescriptionMap[baseId] = item.description ?? "";
|
||||
furniRevisionMap[baseId] = {
|
||||
classname: item.classname ?? "",
|
||||
revision: item.revision ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: catalogItems,
|
||||
baseItems: baseItems.map((b) => ({
|
||||
id: b.id,
|
||||
publicName: b.publicName,
|
||||
itemName: b.itemName,
|
||||
spriteId: b.spriteId,
|
||||
type: b.type,
|
||||
})),
|
||||
catalogNameMap,
|
||||
furniDataIdList: [...foundBaseIds],
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap,
|
||||
interactionTypes,
|
||||
allPages,
|
||||
};
|
||||
return {
|
||||
items: catalogItems,
|
||||
baseItems: baseItems.map((b) => ({
|
||||
id: b.id,
|
||||
publicName: b.publicName,
|
||||
itemName: b.itemName,
|
||||
spriteId: b.spriteId,
|
||||
type: b.type,
|
||||
})),
|
||||
catalogNameMap,
|
||||
furniDataIdList: [...foundBaseIds],
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap,
|
||||
interactionTypes,
|
||||
allPages,
|
||||
};
|
||||
}
|
||||
+194
-186
@@ -10,54 +10,54 @@ import type { TreeNode } from "@/types/catalog";
|
||||
* Depth is computed from parentId hierarchy (not stored in DB).
|
||||
*/
|
||||
export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
const [allPages, itemCounts] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { orderNum: "asc" },
|
||||
}),
|
||||
prisma.catalogItems.groupBy({
|
||||
by: ["pageId"],
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
const [allPages, itemCounts] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { orderNum: "asc" },
|
||||
}),
|
||||
prisma.catalogItems.groupBy({
|
||||
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>();
|
||||
for (const p of allPages) {
|
||||
childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1);
|
||||
}
|
||||
// Count children per page
|
||||
const childCountMap = new Map<number, number>();
|
||||
for (const p of allPages) {
|
||||
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>();
|
||||
// Compute depth from parent hierarchy
|
||||
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 (!page || page.parentId <= 0) {
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(page.parentId) + 1;
|
||||
depthCache.set(pageId, d);
|
||||
return d;
|
||||
}
|
||||
function computeDepth(pageId: number): number {
|
||||
if (depthCache.has(pageId)) return depthCache.get(pageId)!;
|
||||
const page = pageMap.get(pageId);
|
||||
if (!page || page.parentId <= 0) {
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(page.parentId) + 1;
|
||||
depthCache.set(pageId, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
return allPages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parentId,
|
||||
depth: computeDepth(p.id),
|
||||
orderNum: p.orderNum,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
iconImage: p.iconImage,
|
||||
iconColor: p.iconColor,
|
||||
pageLayout: p.pageLayout,
|
||||
childCount: childCountMap.get(p.id) ?? 0,
|
||||
itemCount: itemCountMap.get(p.id) ?? 0,
|
||||
}));
|
||||
return allPages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parentId,
|
||||
depth: computeDepth(p.id),
|
||||
orderNum: p.orderNum,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
iconImage: p.iconImage,
|
||||
iconColor: p.iconColor,
|
||||
pageLayout: p.pageLayout,
|
||||
childCount: childCountMap.get(p.id) ?? 0,
|
||||
itemCount: itemCountMap.get(p.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,111 +65,117 @@ export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
*/
|
||||
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>();
|
||||
export function buildNestedTree(
|
||||
flat: TreeNode[],
|
||||
rootParentId = -1,
|
||||
): 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Attach children
|
||||
for (const node of nodeMap.values()) {
|
||||
node.children = childrenMap.get(node.id) ?? [];
|
||||
}
|
||||
// Attach children
|
||||
for (const node of nodeMap.values()) {
|
||||
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);
|
||||
// Auto-generate captionSave if not provided
|
||||
const captionSave =
|
||||
data.captionSave ||
|
||||
data.caption
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "_")
|
||||
.substring(0, 25);
|
||||
|
||||
// Get next orderNum if not provided
|
||||
let orderNum = data.orderNum;
|
||||
if (orderNum === undefined) {
|
||||
const lastSibling = await prisma.catalogPages.findFirst({
|
||||
where: { parentId: data.parentId },
|
||||
orderBy: { orderNum: "desc" },
|
||||
select: { orderNum: true },
|
||||
});
|
||||
orderNum = (lastSibling?.orderNum ?? 0) + 1;
|
||||
}
|
||||
// Get next orderNum if not provided
|
||||
let orderNum = data.orderNum;
|
||||
if (orderNum === undefined) {
|
||||
const lastSibling = await prisma.catalogPages.findFirst({
|
||||
where: { parentId: data.parentId },
|
||||
orderBy: { orderNum: "desc" },
|
||||
select: { orderNum: true },
|
||||
});
|
||||
orderNum = (lastSibling?.orderNum ?? 0) + 1;
|
||||
}
|
||||
|
||||
const created = await prisma.catalogPages.create({
|
||||
data: {
|
||||
parentId: data.parentId,
|
||||
caption: data.caption,
|
||||
captionSave,
|
||||
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: "",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const created = await prisma.catalogPages.create({
|
||||
data: {
|
||||
parentId: data.parentId,
|
||||
caption: data.caption,
|
||||
captionSave,
|
||||
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: "",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return created;
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a page to a new parent.
|
||||
* Validates against circular hierarchy (A → B → C → A).
|
||||
*/
|
||||
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;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: pageId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: pageId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,80 +184,82 @@ export async function movePage(pageId: number, newParentId: number): Promise<voi
|
||||
* - reparent: moves children to the deleted page's parent
|
||||
*/
|
||||
export async function deletePage(
|
||||
pageId: number,
|
||||
mode: "cascade" | "reparent" = "reparent",
|
||||
pageId: number,
|
||||
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 };
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: pageId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||
|
||||
if (mode === "reparent") {
|
||||
// Move children to page's parent
|
||||
const result = await prisma.catalogPages.updateMany({
|
||||
where: { parentId: pageId },
|
||||
data: { parentId: page.parentId },
|
||||
});
|
||||
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 } });
|
||||
// Delete the page
|
||||
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||
// Delete items in this page
|
||||
await prisma.catalogItems.deleteMany({ where: { pageId } });
|
||||
// Delete the page
|
||||
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 };
|
||||
// Cascade: delete all descendants
|
||||
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];
|
||||
// Collect all descendant IDs iteratively to avoid N+1 recursive queries
|
||||
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;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const children = await prisma.catalogPages.findMany({
|
||||
where: { parentId: { in: queue } },
|
||||
select: { id: true },
|
||||
});
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
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 } } });
|
||||
// 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] } });
|
||||
}
|
||||
// Delete all items and pages in bulk (children first, then parents)
|
||||
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] } });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
return ancestors;
|
||||
}
|
||||
@@ -2,18 +2,18 @@ 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,42 @@ interface FurniNitroJson {
|
||||
* `.nitro` instead of serving a separate `{classname}_icon.png`.
|
||||
*/
|
||||
export function extractFurniIconPng(nitro: Buffer): Buffer | null {
|
||||
let json: FurniNitroJson;
|
||||
let png: Buffer;
|
||||
try {
|
||||
const parsed = parseNitroBundle(nitro);
|
||||
json = parsed.json as FurniNitroJson;
|
||||
png = parsed.png;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const name = json.name;
|
||||
if (!name || !json.assets) return null;
|
||||
let json: FurniNitroJson;
|
||||
let png: Buffer;
|
||||
try {
|
||||
const parsed = parseNitroBundle(nitro);
|
||||
json = parsed.json as FurniNitroJson;
|
||||
png = parsed.png;
|
||||
} catch {
|
||||
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;
|
||||
// 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;
|
||||
|
||||
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 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;
|
||||
|
||||
try {
|
||||
const sheet = decodePng(png);
|
||||
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h);
|
||||
return encodePng(w, h, px);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const sheet = decodePng(png);
|
||||
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h);
|
||||
return encodePng(w, h, px);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,142 +3,172 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
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", () => ({
|
||||
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)),
|
||||
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", () => ({
|
||||
getFurniAssetDirs: vi.fn<AnyFn>(async () => ({
|
||||
nitroDir: "/tmp/nitro",
|
||||
iconDir: "/tmp/icons",
|
||||
swfDir: "/tmp/swf",
|
||||
})),
|
||||
getFurniAssetDirs: vi.fn<AnyFn>(async () => ({
|
||||
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");
|
||||
return {
|
||||
...real,
|
||||
promises: { ...real.promises, readFile: fsReadFile as AnyFn, unlink: fsUnlink as AnyFn },
|
||||
};
|
||||
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", () => ({
|
||||
prisma: {
|
||||
$executeRaw: execRaw as AnyFn,
|
||||
$queryRaw: queryRaw as AnyFn,
|
||||
$executeRawUnsafe: execRaw as AnyFn,
|
||||
$queryRawUnsafe: queryRaw as AnyFn,
|
||||
},
|
||||
prisma: {
|
||||
$executeRaw: execRaw as AnyFn,
|
||||
$queryRaw: queryRaw as AnyFn,
|
||||
$executeRawUnsafe: execRaw as AnyFn,
|
||||
$queryRawUnsafe: queryRaw as AnyFn,
|
||||
},
|
||||
}));
|
||||
|
||||
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",
|
||||
xdim: 2,
|
||||
ydim: 1,
|
||||
canstandon: false,
|
||||
cansiton: true,
|
||||
canlayon: false,
|
||||
customparams: "",
|
||||
id: 5,
|
||||
classname: "bc_sofa",
|
||||
name: "BC Sofa",
|
||||
description: "d",
|
||||
xdim: 2,
|
||||
ydim: 1,
|
||||
canstandon: false,
|
||||
cansiton: true,
|
||||
canlayon: false,
|
||||
customparams: "",
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
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();
|
||||
// First call: dedup check (no existing row); second call: allocateItemsBaseId MAX(id)+1.
|
||||
queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ next: 42 }]);
|
||||
appendFurniEntry.mockReset();
|
||||
appendFurniEntry.mockResolvedValue(undefined);
|
||||
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();
|
||||
// First call: dedup check (no existing row); second call: allocateItemsBaseId MAX(id)+1.
|
||||
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);
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
"https://x/furni/bc_sofa.nitro",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
"https://x/icons/bc_sofa_icon.png",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ validate: "png" }),
|
||||
);
|
||||
expect(appendFurniEntry).toHaveBeenCalled();
|
||||
expect(execRaw).toHaveBeenCalled(); // items_base insert + catalog
|
||||
});
|
||||
const r = await cloneSingleFurni({
|
||||
source: SOURCE,
|
||||
entry: { ...ENTRY, itemType: "s" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
"https://x/furni/bc_sofa.nitro",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
"https://x/icons/bc_sofa_icon.png",
|
||||
expect.any(String),
|
||||
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();
|
||||
// Dedup query: no existing row.
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
// allocateItemsBaseId: MAX(id)+1 query returns next id.
|
||||
queryRaw.mockResolvedValueOnce([{ next: 7 }]);
|
||||
// items_base INSERT rejects.
|
||||
execRaw.mockRejectedValueOnce(new Error("Duplicate entry"));
|
||||
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([]);
|
||||
// allocateItemsBaseId: MAX(id)+1 query returns next id.
|
||||
queryRaw.mockResolvedValueOnce([{ next: 7 }]);
|
||||
// items_base INSERT rejects.
|
||||
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"));
|
||||
// (b) icon must also be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining("bc_sofa_icon.png"));
|
||||
// (c) appendFurniEntry must NOT have been called.
|
||||
expect(appendFurniEntry).not.toHaveBeenCalled();
|
||||
// (d) result must be ok === false.
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/items_base insert failed/);
|
||||
});
|
||||
// (a) .nitro must be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bc_sofa.nitro"),
|
||||
);
|
||||
// (b) icon must also be unlinked.
|
||||
expect(fsUnlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bc_sofa_icon.png"),
|
||||
);
|
||||
// (c) appendFurniEntry must NOT have been called.
|
||||
expect(appendFurniEntry).not.toHaveBeenCalled();
|
||||
// (d) result must be ok === false.
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/items_base insert failed/);
|
||||
});
|
||||
});
|
||||
+292
-236
@@ -6,88 +6,98 @@ 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,
|
||||
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";
|
||||
|
||||
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 {
|
||||
return {
|
||||
...raw,
|
||||
id: Number(raw.id ?? 0),
|
||||
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 ?? ""),
|
||||
itemType,
|
||||
};
|
||||
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 ?? ""),
|
||||
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 ?? ""),
|
||||
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;
|
||||
|
||||
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[];
|
||||
try {
|
||||
const json = await res.json();
|
||||
list = parseFurnidata(json);
|
||||
} catch (err) {
|
||||
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`);
|
||||
}
|
||||
cache.set(url, { list, ts: now });
|
||||
return list;
|
||||
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[];
|
||||
try {
|
||||
const json = await res.json();
|
||||
list = parseFurnidata(json);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`furnidata parse failed for ${url}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
cache.set(url, { list, ts: now });
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,222 +107,268 @@ export async function fetchSourceFurnidata(url: string, now = Date.now()): Promi
|
||||
*/
|
||||
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
async function allocateItemsBaseId<T>(insertFn: (nextId: number) => Promise<T>): Promise<T> {
|
||||
const prev = itemsBaseIdAllocChain;
|
||||
let settle!: () => void;
|
||||
itemsBaseIdAllocChain = new Promise<void>((r) => {
|
||||
settle = r;
|
||||
});
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
|
||||
async function allocateItemsBaseId<T>(
|
||||
insertFn: (nextId: number) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const prev = itemsBaseIdAllocChain;
|
||||
let settle!: () => void;
|
||||
itemsBaseIdAllocChain = new Promise<void>((r) => {
|
||||
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);
|
||||
} finally {
|
||||
settle();
|
||||
}
|
||||
const nextId = Number(idRow[0]?.next ?? 1);
|
||||
return await insertFn(nextId);
|
||||
} finally {
|
||||
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" };
|
||||
}
|
||||
// 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" };
|
||||
}
|
||||
|
||||
// Dedup by classname.
|
||||
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
// Dedup by classname.
|
||||
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
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" };
|
||||
}
|
||||
if (existing.length > 0) {
|
||||
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`);
|
||||
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" };
|
||||
}
|
||||
// Validate it is a real Nitro bundle.
|
||||
try {
|
||||
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" };
|
||||
}
|
||||
const iconDl = await downloadFile(`${source.iconBaseUrl}/${classname}_icon.png`, iconPath, {
|
||||
maxRetries: 1,
|
||||
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));
|
||||
if (icon) {
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon);
|
||||
} else {
|
||||
warnings.push("no icon (not in source or bundle)");
|
||||
}
|
||||
} catch {
|
||||
warnings.push("icon extraction failed");
|
||||
}
|
||||
}
|
||||
// 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`,
|
||||
);
|
||||
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" };
|
||||
}
|
||||
// Validate it is a real Nitro bundle.
|
||||
try {
|
||||
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" };
|
||||
}
|
||||
const iconDl = await downloadFile(
|
||||
`${source.iconBaseUrl}/${classname}_icon.png`,
|
||||
iconPath,
|
||||
{
|
||||
maxRetries: 1,
|
||||
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),
|
||||
);
|
||||
if (icon) {
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon);
|
||||
} else {
|
||||
warnings.push("no icon (not in source or bundle)");
|
||||
}
|
||||
} catch {
|
||||
warnings.push("icon extraction failed");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await prisma.$executeRaw`
|
||||
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;
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO items_base
|
||||
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
||||
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;
|
||||
});
|
||||
} 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);
|
||||
return {
|
||||
ok: false,
|
||||
classname,
|
||||
warnings,
|
||||
error: `items_base insert failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
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,
|
||||
);
|
||||
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");
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
// FurnitureData entry — reuse the source's furnitype object, with our id.
|
||||
onProgress?.("writing_furnidata");
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Catalog entry (category sub-page + auto price), serialized id allocation.
|
||||
try {
|
||||
const pageId = await getOrCreateCategoryPage(classname, itemType);
|
||||
const price = autoPriceFurni(classname);
|
||||
await allocateCatalogItemId(async (nextCatalogId) => {
|
||||
await prisma.$executeRaw`
|
||||
// Catalog entry (category sub-page + auto price), serialized id allocation.
|
||||
try {
|
||||
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;
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[clone-import] catalog entry failed for", classname, (err as Error).message);
|
||||
warnings.push(`catalog entry failed: ${(err as Error).message}`);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
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 searched = term
|
||||
? all.filter((e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term))
|
||||
: all;
|
||||
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;
|
||||
|
||||
// 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>();
|
||||
if (allNames.length) {
|
||||
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);
|
||||
}
|
||||
// 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>();
|
||||
if (allNames.length) {
|
||||
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);
|
||||
}
|
||||
|
||||
const filtered =
|
||||
filter === "missing"
|
||||
? searched.filter((e) => !present.has(e.classname))
|
||||
: filter === "present"
|
||||
? searched.filter((e) => present.has(e.classname))
|
||||
: searched;
|
||||
const filtered =
|
||||
filter === "missing"
|
||||
? searched.filter((e) => !present.has(e.classname))
|
||||
: filter === "present"
|
||||
? searched.filter((e) => present.has(e.classname))
|
||||
: searched;
|
||||
|
||||
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 },
|
||||
};
|
||||
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,
|
||||
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>();
|
||||
if (classnames.length) {
|
||||
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);
|
||||
}
|
||||
const present = all.filter((e) => have.has(e.classname)).length;
|
||||
return { total: all.length, present, clonable: all.length - present };
|
||||
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 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);
|
||||
}
|
||||
const present = all.filter((e) => have.has(e.classname)).length;
|
||||
return { total: all.length, present, clonable: all.length - present };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the classnames of every furni in the source that is NOT yet present
|
||||
* 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>();
|
||||
if (classnames.length) {
|
||||
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);
|
||||
}
|
||||
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
|
||||
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>();
|
||||
if (classnames.length) {
|
||||
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);
|
||||
}
|
||||
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
|
||||
}
|
||||
@@ -2,46 +2,48 @@ 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";
|
||||
|
||||
export async function listSources(): Promise<CloneSource[]> {
|
||||
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -3,63 +3,88 @@ 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 importSingleFigure = vi.fn(async () => ({
|
||||
ok: true,
|
||||
lib: "",
|
||||
warnings: [],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/figure-official", () => ({
|
||||
fetchOfficialFigureData: vi.fn(async () => ({
|
||||
palettes: [{ id: 3, colors: [] }],
|
||||
setTypes: [
|
||||
{
|
||||
type: "ch",
|
||||
paletteId: 3,
|
||||
sets: [
|
||||
{
|
||||
id: 5000,
|
||||
gender: "M",
|
||||
club: 0,
|
||||
colorable: true,
|
||||
selectable: true,
|
||||
preselectable: false,
|
||||
sellable: true,
|
||||
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" }] },
|
||||
]),
|
||||
fetchOfficialFigureData: vi.fn(async () => ({
|
||||
palettes: [{ id: 3, colors: [] }],
|
||||
setTypes: [
|
||||
{
|
||||
type: "ch",
|
||||
paletteId: 3,
|
||||
sets: [
|
||||
{
|
||||
id: 5000,
|
||||
gender: "M",
|
||||
club: 0,
|
||||
colorable: true,
|
||||
selectable: true,
|
||||
preselectable: false,
|
||||
sellable: true,
|
||||
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" }] },
|
||||
]),
|
||||
}));
|
||||
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>()),
|
||||
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", () => ({
|
||||
mergeLibrary: (...a: unknown[]) => mergeLibrary(...(a as Parameters<typeof mergeLibrary>)),
|
||||
listFigureLibraries: vi.fn(async () => []),
|
||||
mergeLibrary: (...a: unknown[]) =>
|
||||
mergeLibrary(...(a as Parameters<typeof mergeLibrary>)),
|
||||
listFigureLibraries: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("@/lib/services/figure-import", () => ({
|
||||
importSingleFigure: (...a: unknown[]) =>
|
||||
importSingleFigure(...(a as Parameters<typeof importSingleFigure>)),
|
||||
ensureFigureDirs: vi.fn(async () => {}),
|
||||
importSingleFigure: (...a: unknown[]) =>
|
||||
importSingleFigure(...(a as Parameters<typeof importSingleFigure>)),
|
||||
ensureFigureDirs: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
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 }));
|
||||
});
|
||||
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,199 +1,221 @@
|
||||
import { ensureFigureDirs, importSingleFigure } from "@/lib/services/figure-import";
|
||||
import {
|
||||
fetchOfficialFigureData,
|
||||
fetchOfficialFigureMap,
|
||||
type OfficialFigureData,
|
||||
type OfficialLibrary,
|
||||
type OfficialSet,
|
||||
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 {
|
||||
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>();
|
||||
for (const lib of officialLibs) {
|
||||
for (const part of lib.parts) {
|
||||
const key = partKey(part.type, part.id);
|
||||
if (!index.has(key)) {
|
||||
index.set(key, lib);
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
function buildPartIndex(
|
||||
officialLibs: OfficialLibrary[],
|
||||
): 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);
|
||||
if (!index.has(key)) {
|
||||
index.set(key, lib);
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function mapOfficialSet(set: OfficialSet): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {
|
||||
id: set.id,
|
||||
gender: set.gender,
|
||||
club: set.club,
|
||||
colorable: set.colorable,
|
||||
selectable: set.selectable,
|
||||
preselectable: set.preselectable,
|
||||
sellable: set.sellable,
|
||||
parts: set.parts.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
colorable: p.colorable,
|
||||
index: p.index,
|
||||
colorindex: p.colorindex,
|
||||
})),
|
||||
};
|
||||
if (set.hiddenLayers?.length) {
|
||||
result.hiddenLayers = set.hiddenLayers;
|
||||
}
|
||||
return result;
|
||||
const result: Record<string, unknown> = {
|
||||
id: set.id,
|
||||
gender: set.gender,
|
||||
club: set.club,
|
||||
colorable: set.colorable,
|
||||
selectable: set.selectable,
|
||||
preselectable: set.preselectable,
|
||||
sellable: set.sellable,
|
||||
parts: set.parts.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
colorable: p.colorable,
|
||||
index: p.index,
|
||||
colorindex: p.colorindex,
|
||||
})),
|
||||
};
|
||||
if (set.hiddenLayers?.length) {
|
||||
result.hiddenLayers = set.hiddenLayers;
|
||||
}
|
||||
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);
|
||||
if (!st) {
|
||||
return { ok: false, warnings, error: `setType "${setType}" not found in official figuredata` };
|
||||
}
|
||||
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 st = officialFd.setTypes.find((s) => s.type === setType);
|
||||
if (!st) {
|
||||
return {
|
||||
ok: false,
|
||||
warnings,
|
||||
error: `setType "${setType}" not found in official figuredata`,
|
||||
};
|
||||
}
|
||||
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>();
|
||||
for (const part of set.parts) {
|
||||
const lib = partIndex.get(partKey(part.type, part.id));
|
||||
if (lib) {
|
||||
requiredLibIds.add(lib.id);
|
||||
} else {
|
||||
warnings.push(`No library found for part ${part.type}:${part.id}`);
|
||||
}
|
||||
}
|
||||
const partIndex = buildPartIndex(officialLibs);
|
||||
const requiredLibIds = new Set<string>();
|
||||
for (const part of set.parts) {
|
||||
const lib = partIndex.get(partKey(part.type, part.id));
|
||||
if (lib) {
|
||||
requiredLibIds.add(lib.id);
|
||||
} else {
|
||||
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;
|
||||
for (const libId of requiredLibIds) {
|
||||
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 });
|
||||
if (!result.ok) {
|
||||
warnings.push(`Library import failed for ${libId}: ${result.error ?? "unknown error"}`);
|
||||
}
|
||||
if (result.warnings.length) {
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
}
|
||||
if (!localLibIds.has(libId)) {
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
if (result.warnings.length) {
|
||||
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);
|
||||
}
|
||||
if (palette) {
|
||||
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[] = [];
|
||||
for (const st of officialFd.setTypes) {
|
||||
if (setType && st.type !== setType) continue;
|
||||
const presentIds = await listLocalSetIds(st.type);
|
||||
for (const set of st.sets) {
|
||||
allItems.push({
|
||||
setType: st.type,
|
||||
setId: set.id,
|
||||
gender: set.gender,
|
||||
club: set.club,
|
||||
sellable: set.sellable,
|
||||
present: presentIds.has(set.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
const allItems: ClothingSetItem[] = [];
|
||||
for (const st of officialFd.setTypes) {
|
||||
if (setType && st.type !== setType) continue;
|
||||
const presentIds = await listLocalSetIds(st.type);
|
||||
for (const set of st.sets) {
|
||||
allItems.push({
|
||||
setType: st.type,
|
||||
setId: set.id,
|
||||
gender: set.gender,
|
||||
club: set.club,
|
||||
sellable: set.sellable,
|
||||
present: presentIds.has(set.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const term = search.trim().toLowerCase();
|
||||
const filtered = term
|
||||
? allItems.filter((it) => String(it.setId).includes(term) || it.setType.toLowerCase().includes(term))
|
||||
: allItems;
|
||||
const term = search.trim().toLowerCase();
|
||||
const filtered = term
|
||||
? 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
return { total, present, missing: total - present };
|
||||
return { total, present, missing: total - present };
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import { describe, expect, it } from "vitest";
|
||||
import { CurrencyType, currencyLabel } from "./currency";
|
||||
|
||||
describe("CurrencyType", () => {
|
||||
it("uses the emulator-fixed numeric values", () => {
|
||||
expect(CurrencyType.Credits).toBe(-1);
|
||||
expect(CurrencyType.Duckets).toBe(0);
|
||||
expect(CurrencyType.Diamonds).toBe(5);
|
||||
expect(CurrencyType.Points).toBe(101);
|
||||
});
|
||||
it("uses the emulator-fixed numeric values", () => {
|
||||
expect(CurrencyType.Credits).toBe(-1);
|
||||
expect(CurrencyType.Duckets).toBe(0);
|
||||
expect(CurrencyType.Diamonds).toBe(5);
|
||||
expect(CurrencyType.Points).toBe(101);
|
||||
});
|
||||
|
||||
it("maps each type to a human label", () => {
|
||||
expect(currencyLabel(CurrencyType.Credits)).toBe("Credits");
|
||||
expect(currencyLabel(CurrencyType.Diamonds)).toBe("Diamonds");
|
||||
});
|
||||
it("maps each type to a human label", () => {
|
||||
expect(currencyLabel(CurrencyType.Credits)).toBe("Credits");
|
||||
expect(currencyLabel(CurrencyType.Diamonds)).toBe("Diamonds");
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,19 @@
|
||||
// Numeric values are dictated by the Arcturus emulator and MUST NOT change.
|
||||
export enum CurrencyType {
|
||||
Credits = -1,
|
||||
Duckets = 0,
|
||||
Diamonds = 5,
|
||||
Points = 101,
|
||||
Credits = -1,
|
||||
Duckets = 0,
|
||||
Diamonds = 5,
|
||||
Points = 101,
|
||||
}
|
||||
|
||||
const LABELS: Record<CurrencyType, string> = {
|
||||
[CurrencyType.Credits]: "Credits",
|
||||
[CurrencyType.Duckets]: "Duckets",
|
||||
[CurrencyType.Diamonds]: "Diamonds",
|
||||
[CurrencyType.Points]: "Points",
|
||||
[CurrencyType.Credits]: "Credits",
|
||||
[CurrencyType.Duckets]: "Duckets",
|
||||
[CurrencyType.Diamonds]: "Diamonds",
|
||||
[CurrencyType.Points]: "Points",
|
||||
};
|
||||
|
||||
export function currencyLabel(type: CurrencyType): string {
|
||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyType enum
|
||||
return LABELS[type];
|
||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyType enum
|
||||
return LABELS[type];
|
||||
}
|
||||
@@ -2,53 +2,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/services/import/core/download", () => ({
|
||||
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
|
||||
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
|
||||
}));
|
||||
vi.mock("@/lib/services/effect-source", () => ({
|
||||
resolveEffectSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
|
||||
listOfficialEffects: vi.fn(async () => []),
|
||||
resolveEffectSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
|
||||
listOfficialEffects: vi.fn(async () => []),
|
||||
}));
|
||||
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 () => []),
|
||||
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: [],
|
||||
}));
|
||||
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>)),
|
||||
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")),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
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")),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
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();
|
||||
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();
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,132 +1,169 @@
|
||||
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 {
|
||||
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 type {
|
||||
EffectListItem,
|
||||
EffectMapEntry,
|
||||
ImportEffectResult,
|
||||
} from "@/types/effects";
|
||||
|
||||
export const EFFECT_NITRO_DIR = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/effect",
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/effect",
|
||||
);
|
||||
export const EFFECT_SWF_DIR = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/gordon",
|
||||
);
|
||||
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 })),
|
||||
);
|
||||
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;
|
||||
try {
|
||||
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}` };
|
||||
// Download SWF
|
||||
onProgress?.("downloading");
|
||||
const swfPath = path.join(
|
||||
/*turbopackIgnore: true*/ EFFECT_SWF_DIR,
|
||||
`${lib}.swf`,
|
||||
);
|
||||
let swfUrl: string;
|
||||
try {
|
||||
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}`,
|
||||
};
|
||||
|
||||
// Convert SWF → Nitro
|
||||
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);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
lib,
|
||||
warnings,
|
||||
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
// Convert SWF → Nitro
|
||||
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);
|
||||
} 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");
|
||||
try {
|
||||
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" };
|
||||
}
|
||||
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
|
||||
onProgress?.("writing_map");
|
||||
try {
|
||||
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" };
|
||||
}
|
||||
|
||||
return { ok: true, lib, warnings };
|
||||
return { ok: true, lib, warnings };
|
||||
}
|
||||
|
||||
export async function deleteImportedEffect(
|
||||
idOrLib: string,
|
||||
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;
|
||||
if (entry) {
|
||||
const p = nitroPathFor(entry.lib);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
deletedFile = true;
|
||||
}
|
||||
}
|
||||
return { removed, deletedFile };
|
||||
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);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
deletedFile = true;
|
||||
}
|
||||
}
|
||||
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 merged = official.map((e) => {
|
||||
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),
|
||||
)
|
||||
: merged;
|
||||
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);
|
||||
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),
|
||||
)
|
||||
: 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,
|
||||
};
|
||||
}
|
||||
@@ -10,39 +10,49 @@ const CACHE_TTL = 30 * 60 * 1000;
|
||||
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;
|
||||
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 });
|
||||
}
|
||||
return out;
|
||||
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,
|
||||
});
|
||||
}
|
||||
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) });
|
||||
if (!res.ok) {
|
||||
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 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 ?? [];
|
||||
}
|
||||
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,65 @@
|
||||
import { resolveGamedataFile } from "@/lib/services/import/core/asset-paths";
|
||||
import {
|
||||
readGamedataJson,
|
||||
withGamedataLock,
|
||||
writeGamedataJsonAtomic,
|
||||
readGamedataJson,
|
||||
withGamedataLock,
|
||||
writeGamedataJsonAtomic,
|
||||
} 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;
|
||||
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();
|
||||
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 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 });
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeEffect(idOrLib: string): Promise<boolean> {
|
||||
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 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;
|
||||
});
|
||||
}
|
||||
+106
-78
@@ -1,6 +1,6 @@
|
||||
import { exec } from "child_process";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { exec } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "@/env";
|
||||
@@ -10,95 +10,123 @@ let transporter: Transporter | null = null;
|
||||
let resend: Resend | null = null;
|
||||
|
||||
function getTransport(): Transporter | null {
|
||||
if (!env.SMTP_HOST) return null;
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT ?? 587,
|
||||
secure: env.SMTP_SECURE,
|
||||
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
if (!env.SMTP_HOST) return null;
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT ?? 587,
|
||||
secure: env.SMTP_SECURE,
|
||||
auth: env.SMTP_USER
|
||||
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
function getResend(): Resend | null {
|
||||
if (!env.RESEND_API_KEY) return null;
|
||||
if (!resend) resend = new Resend(env.RESEND_API_KEY);
|
||||
return resend;
|
||||
if (!env.RESEND_API_KEY) return null;
|
||||
if (!resend) resend = new Resend(env.RESEND_API_KEY);
|
||||
return resend;
|
||||
}
|
||||
|
||||
function sendViaSendmail(to: string, subject: string, html: string, from: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const headers = [
|
||||
`From: ${from}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
"MIME-Version: 1.0",
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
].join("\r\n");
|
||||
function sendViaSendmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
from: string,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const headers = [
|
||||
`From: ${from}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
"MIME-Version: 1.0",
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
].join("\r\n");
|
||||
|
||||
const child = exec("sendmail -t", (error) => {
|
||||
if (error) {
|
||||
logger.error("Sendmail failed", { module: "email", error: error.message });
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
const child = exec("sendmail -t", (error) => {
|
||||
if (error) {
|
||||
logger.error("Sendmail failed", {
|
||||
module: "email",
|
||||
error: error.message,
|
||||
});
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (child.stdin) {
|
||||
child.stdin.write(`${headers}\r\n\r\n${html}`);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
if (child.stdin) {
|
||||
child.stdin.write(`${headers}\r\n\r\n${html}`);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function writeToFile(to: string, subject: string, html: string, from: string): Promise<boolean> {
|
||||
try {
|
||||
const logDir = resolve(process.cwd(), "storage", "logs");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(logDir, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `email-${timestamp}.html`;
|
||||
const content = `<!-- To: ${to} | From: ${from} | Subject: ${subject} -->\n${html}`;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(resolve(logDir, filename), content, "utf-8");
|
||||
logger.info("Email written to file", { module: "email", filename });
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("Failed to write email to file", { module: "email", error: (e as Error).message });
|
||||
return false;
|
||||
}
|
||||
async function writeToFile(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
from: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const logDir = resolve(process.cwd(), "storage", "logs");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(logDir, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `email-${timestamp}.html`;
|
||||
const content = `<!-- To: ${to} | From: ${from} | Subject: ${subject} -->\n${html}`;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(resolve(logDir, filename), content, "utf-8");
|
||||
logger.info("Email written to file", { module: "email", filename });
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("Failed to write email to file", {
|
||||
module: "email",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send an HTML email. Tries Resend → SMTP → local sendmail → file fallback. Always returns true. */
|
||||
export async function sendMail(to: string, subject: string, html: string): Promise<boolean> {
|
||||
const from = env.SMTP_FROM ?? `no-reply@${env.HOTEL_NAME}`;
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
): Promise<boolean> {
|
||||
const from = env.SMTP_FROM ?? `no-reply@${env.HOTEL_NAME}`;
|
||||
|
||||
const r = getResend();
|
||||
if (r) {
|
||||
try {
|
||||
await r.emails.send({ from, to, subject, html });
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("Resend API failed", { module: "email", error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
const r = getResend();
|
||||
if (r) {
|
||||
try {
|
||||
await r.emails.send({ from, to, subject, html });
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("Resend API failed", {
|
||||
module: "email",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const t = getTransport();
|
||||
if (t) {
|
||||
try {
|
||||
await t.sendMail({ from, to, subject, html });
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("SMTP failed", { module: "email", error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
const t = getTransport();
|
||||
if (t) {
|
||||
try {
|
||||
await t.sendMail({ from, to, subject, html });
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error("SMTP failed", {
|
||||
module: "email",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ok = await sendViaSendmail(to, subject, html, from);
|
||||
if (ok) return true;
|
||||
const ok = await sendViaSendmail(to, subject, html, from);
|
||||
if (ok) return true;
|
||||
|
||||
await writeToFile(to, subject, html, from);
|
||||
return true;
|
||||
await writeToFile(to, subject, html, from);
|
||||
return true;
|
||||
}
|
||||
@@ -2,65 +2,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/services/import/core/download", () => ({
|
||||
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
|
||||
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
|
||||
}));
|
||||
vi.mock("@/lib/services/figure-source", () => ({
|
||||
resolveFigureSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
|
||||
resolveFigureSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
|
||||
}));
|
||||
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 },
|
||||
]),
|
||||
listFigureLibraries: vi.fn(async () => [
|
||||
{ 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: [],
|
||||
}));
|
||||
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>)),
|
||||
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")),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
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")),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
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 () => {
|
||||
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");
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -6,118 +6,144 @@ 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;
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
|
||||
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",
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/figure",
|
||||
);
|
||||
export const FIGURE_SWF_DIR = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/gordon",
|
||||
);
|
||||
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 })),
|
||||
);
|
||||
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;
|
||||
try {
|
||||
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}` };
|
||||
onProgress?.("downloading");
|
||||
const swfPath = path.join(
|
||||
/*turbopackIgnore: true*/ FIGURE_SWF_DIR,
|
||||
`${lib}.swf`,
|
||||
);
|
||||
let swfUrl: string;
|
||||
try {
|
||||
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}`,
|
||||
};
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
lib,
|
||||
warnings,
|
||||
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
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);
|
||||
} 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);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
return { deletedFile: true };
|
||||
}
|
||||
return { deletedFile: false };
|
||||
export async function deleteImportedFigure(
|
||||
lib: string,
|
||||
): Promise<{ deletedFile: boolean }> {
|
||||
const p = nitroPathFor(lib);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
return { deletedFile: true };
|
||||
}
|
||||
return { deletedFile: false };
|
||||
}
|
||||
|
||||
export async function getFigureList(params: {
|
||||
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 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 } };
|
||||
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 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 } };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
+140
-122
@@ -2,51 +2,51 @@ 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;
|
||||
@@ -55,107 +55,125 @@ 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];
|
||||
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[] = [];
|
||||
for (const pm of xml.matchAll(/<palette\b([^>]*)>([\s\S]*?)<\/palette>/g)) {
|
||||
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")),
|
||||
hexCode: cm[2].trim(),
|
||||
});
|
||||
}
|
||||
palettes.push({ id, colors });
|
||||
}
|
||||
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[] = [];
|
||||
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")),
|
||||
hexCode: cm[2].trim(),
|
||||
});
|
||||
}
|
||||
palettes.push({ id, colors });
|
||||
}
|
||||
|
||||
const setTypes: OfficialSetType[] = [];
|
||||
for (const sm of xml.matchAll(/<settype\b([^>]*)>([\s\S]*?)<\/settype>/g)) {
|
||||
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[] = [];
|
||||
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")),
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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")),
|
||||
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")),
|
||||
sets,
|
||||
});
|
||||
}
|
||||
const setTypes: OfficialSetType[] = [];
|
||||
for (const sm of xml.matchAll(/<settype\b([^>]*)>([\s\S]*?)<\/settype>/g)) {
|
||||
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[] = [];
|
||||
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")),
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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")),
|
||||
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")),
|
||||
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[] = [];
|
||||
for (const lm of xml.matchAll(/<lib\b([^>]*)>([\s\S]*?)<\/lib>/g)) {
|
||||
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") ?? "" });
|
||||
}
|
||||
libs.push({ id: attr(lm[1], "id") ?? "", revision: num(attr(lm[1], "revision")), parts });
|
||||
}
|
||||
fmCache = { libs, ts: now };
|
||||
return libs;
|
||||
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 }[] = [];
|
||||
for (const pm of lm[2].matchAll(/<part\b([^>]*)\/?>/g)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
fmCache = { libs, ts: now };
|
||||
return libs;
|
||||
}
|
||||
@@ -2,8 +2,10 @@ 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,93 @@
|
||||
import { resolveGamedataFile } from "@/lib/services/import/core/asset-paths";
|
||||
import {
|
||||
readGamedataJson,
|
||||
withGamedataLock,
|
||||
writeGamedataJsonAtomic,
|
||||
readGamedataJson,
|
||||
withGamedataLock,
|
||||
writeGamedataJsonAtomic,
|
||||
} 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;
|
||||
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();
|
||||
await withGamedataLock(file, async () => {
|
||||
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.sets ??= [];
|
||||
const idx = st.sets.findIndex((s) => s.id === set.id);
|
||||
if (idx >= 0) {
|
||||
st.sets[idx] = set;
|
||||
} else {
|
||||
st.sets.push(set);
|
||||
}
|
||||
await writeGamedataJsonAtomic(file, fd);
|
||||
});
|
||||
export async function mergeSet(
|
||||
setType: string,
|
||||
paletteId: number,
|
||||
set: FdSet,
|
||||
): Promise<void> {
|
||||
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);
|
||||
if (!st) {
|
||||
st = { type: setType, paletteId, sets: [] };
|
||||
fd.setTypes.push(st);
|
||||
}
|
||||
st.sets ??= [];
|
||||
const idx = st.sets.findIndex((s) => s.id === set.id);
|
||||
if (idx >= 0) {
|
||||
st.sets[idx] = set;
|
||||
} else {
|
||||
st.sets.push(set);
|
||||
}
|
||||
await writeGamedataJsonAtomic(file, fd);
|
||||
});
|
||||
}
|
||||
|
||||
export async function mergePalette(palette: FdPalette): Promise<void> {
|
||||
const file = await getFigureDataPath();
|
||||
await withGamedataLock(file, async () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
const file = await getFigureDataPath();
|
||||
await withGamedataLock(file, async () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,49 +1,55 @@
|
||||
import { resolveGamedataFile } from "@/lib/services/import/core/asset-paths";
|
||||
import {
|
||||
readGamedataJson,
|
||||
withGamedataLock,
|
||||
writeGamedataJsonAtomic,
|
||||
readGamedataJson,
|
||||
withGamedataLock,
|
||||
writeGamedataJsonAtomic,
|
||||
} 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;
|
||||
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();
|
||||
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);
|
||||
if (idx >= 0) {
|
||||
libraries[idx] = lib;
|
||||
} else {
|
||||
libraries.push(lib);
|
||||
}
|
||||
await writeGamedataJsonAtomic(file, { ...data, libraries });
|
||||
});
|
||||
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);
|
||||
if (idx >= 0) {
|
||||
libraries[idx] = lib;
|
||||
} else {
|
||||
libraries.push(lib);
|
||||
}
|
||||
await writeGamedataJsonAtomic(file, { ...data, libraries });
|
||||
});
|
||||
}
|
||||
@@ -3,115 +3,142 @@ 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",
|
||||
/*turbopackIgnore: true*/ path.dirname(process.cwd()),
|
||||
"DEV",
|
||||
"Nitro-Files",
|
||||
);
|
||||
export const DEFAULT_FURNI_ICON_DIR = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/dcr/hof_furni/icons",
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/dcr/hof_furni/icons",
|
||||
);
|
||||
export const DEFAULT_FURNI_SWF_DIR = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/dcr/hof_furni/swf",
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/dcr/hof_furni/swf",
|
||||
);
|
||||
export const DEFAULT_FURNI_NITRO_DIR = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/furniture",
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"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;
|
||||
while (normalized.length > root.length && /[\\/]$/.test(normalized)) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
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);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function uniqueDirs(dirs: string[]): 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);
|
||||
}
|
||||
return out;
|
||||
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);
|
||||
}
|
||||
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)
|
||||
: "";
|
||||
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"),
|
||||
};
|
||||
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",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
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),
|
||||
]);
|
||||
|
||||
return {
|
||||
swfDir: normalizeDir(swfDir, DEFAULT_FURNI_SWF_DIR),
|
||||
iconDir: normalizeDir(iconDir, DEFAULT_FURNI_ICON_DIR),
|
||||
nitroDir: normalizeDir(nitroDir, 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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
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
|
||||
);
|
||||
});
|
||||
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);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...primary,
|
||||
mirrorDirs: mirrorDirs.map((dirs) => ({
|
||||
swfDir: uniqueDirs([dirs.swfDir])[0],
|
||||
iconDir: uniqueDirs([dirs.iconDir])[0],
|
||||
nitroDir: uniqueDirs([dirs.nitroDir])[0],
|
||||
})),
|
||||
};
|
||||
return {
|
||||
...primary,
|
||||
mirrorDirs: mirrorDirs.map((dirs) => ({
|
||||
swfDir: uniqueDirs([dirs.swfDir])[0],
|
||||
iconDir: uniqueDirs([dirs.iconDir])[0],
|
||||
nitroDir: uniqueDirs([dirs.nitroDir])[0],
|
||||
})),
|
||||
};
|
||||
}
|
||||
+219
-180
@@ -14,41 +14,47 @@ 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(/^\/+/, "");
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
|
||||
async function getFurnitureDataWritePaths(): Promise<string[]> {
|
||||
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")
|
||||
: "";
|
||||
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",
|
||||
)
|
||||
: "";
|
||||
|
||||
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 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;
|
||||
});
|
||||
}
|
||||
|
||||
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 ────────────────────────────────
|
||||
@@ -56,185 +62,218 @@ async function getFurnitureDataReadPath(): Promise<string> {
|
||||
let furniDataLock: Promise<void> = Promise.resolve();
|
||||
|
||||
async function acquireDiskLock(lockPath: string): Promise<void> {
|
||||
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;
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code !== "EEXIST") throw err;
|
||||
try {
|
||||
const stat = await fs.stat(lockPath);
|
||||
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
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;
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code !== "EEXIST") throw err;
|
||||
try {
|
||||
const stat = await fs.stat(lockPath);
|
||||
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await fs.unlink(lockPath).catch(() => {});
|
||||
release!();
|
||||
}
|
||||
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);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
export async function writeFurniData(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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: [] });
|
||||
// Merge partcolors: prefer habbo.it (has real hex colors), fallback to .nitro extraction
|
||||
const partcolors = habboIt?.partcolors?.color?.length
|
||||
? habboIt.partcolors
|
||||
: (metadata?.partcolors ?? { color: [] });
|
||||
|
||||
return {
|
||||
id,
|
||||
classname,
|
||||
revision: revision || habboIt?.revision || 0,
|
||||
category: category || habboIt?.category || "unknown",
|
||||
defaultdir: metadata?.defaultdir ?? habboIt?.defaultdir ?? 0,
|
||||
xdim: dims.x,
|
||||
ydim: dims.y,
|
||||
partcolors,
|
||||
name,
|
||||
description: description || "",
|
||||
adurl: "",
|
||||
offerid: spriteId,
|
||||
buyout: !!habboIt?.bc,
|
||||
rentofferid: -1,
|
||||
rentbuyout: false,
|
||||
bc: habboIt?.bc ?? false,
|
||||
excludeddynamic: false,
|
||||
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 ?? "",
|
||||
rare: habboIt?.rare ?? false,
|
||||
};
|
||||
return {
|
||||
id,
|
||||
classname,
|
||||
revision: revision || habboIt?.revision || 0,
|
||||
category: category || habboIt?.category || "unknown",
|
||||
defaultdir: metadata?.defaultdir ?? habboIt?.defaultdir ?? 0,
|
||||
xdim: dims.x,
|
||||
ydim: dims.y,
|
||||
partcolors,
|
||||
name,
|
||||
description: description || "",
|
||||
adurl: "",
|
||||
offerid: spriteId,
|
||||
buyout: !!habboIt?.bc,
|
||||
rentofferid: -1,
|
||||
rentbuyout: false,
|
||||
bc: habboIt?.bc ?? false,
|
||||
excludeddynamic: false,
|
||||
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 ?? "",
|
||||
rare: habboIt?.rare ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertEntryInSection(
|
||||
section: { furnitype: Array<Record<string, unknown>> },
|
||||
entry: Record<string, unknown>,
|
||||
section: { furnitype: Array<Record<string, unknown>> },
|
||||
entry: Record<string, unknown>,
|
||||
) {
|
||||
const classname = entry.classname as string | undefined;
|
||||
if (classname) {
|
||||
const idx = section.furnitype.findIndex((e) => e.classname === classname);
|
||||
if (idx >= 0) {
|
||||
section.furnitype[idx] = entry;
|
||||
return;
|
||||
}
|
||||
}
|
||||
section.furnitype.push(entry);
|
||||
const classname = entry.classname as string | undefined;
|
||||
if (classname) {
|
||||
const idx = section.furnitype.findIndex((e) => e.classname === classname);
|
||||
if (idx >= 0) {
|
||||
section.furnitype[idx] = entry;
|
||||
return;
|
||||
}
|
||||
}
|
||||
section.furnitype.push(entry);
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] };
|
||||
upsertEntryInSection(furniData.roomitemtypes, entry);
|
||||
}
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
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);
|
||||
} else {
|
||||
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] };
|
||||
upsertEntryInSection(furniData.roomitemtypes, entry);
|
||||
}
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFurniEntry(classname: string): Promise<boolean> {
|
||||
return withFurniDataLock(async () => {
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
let removed = false;
|
||||
return withFurniDataLock(async () => {
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
let removed = false;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
}
|
||||
return removed;
|
||||
});
|
||||
if (removed) {
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
}
|
||||
return removed;
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendFurniEntriesBatch(
|
||||
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
|
||||
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
|
||||
): Promise<void> {
|
||||
await withFurniDataLock(async () => {
|
||||
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);
|
||||
} else {
|
||||
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] };
|
||||
upsertEntryInSection(furniData.roomitemtypes as { furnitype: Array<Record<string, unknown>> }, entry);
|
||||
}
|
||||
}
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
await withFurniDataLock(async () => {
|
||||
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,
|
||||
);
|
||||
} else {
|
||||
if (!furniData.roomitemtypes)
|
||||
furniData.roomitemtypes = { furnitype: [] };
|
||||
upsertEntryInSection(
|
||||
furniData.roomitemtypes as {
|
||||
furnitype: Array<Record<string, unknown>>;
|
||||
},
|
||||
entry,
|
||||
);
|
||||
}
|
||||
}
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
@@ -2,45 +2,55 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {},
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/furni-data", () => ({
|
||||
appendFurniEntry: vi.fn(),
|
||||
buildFurniEntry: vi.fn(),
|
||||
removeFurniEntry: vi.fn(),
|
||||
appendFurniEntry: vi.fn(),
|
||||
buildFurniEntry: vi.fn(),
|
||||
removeFurniEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
||||
lookupHabboIt: vi.fn(),
|
||||
lookupHabboIt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/import/core/download", () => ({
|
||||
downloadFile: vi.fn(),
|
||||
validatePngBytes: vi.fn(),
|
||||
validateSwfBytes: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
validatePngBytes: vi.fn(),
|
||||
validateSwfBytes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/swf-to-nitro", () => ({
|
||||
convertSwfToNitro: vi.fn(),
|
||||
extractIconFromSwf: vi.fn(),
|
||||
convertSwfToNitro: vi.fn(),
|
||||
extractIconFromSwf: vi.fn(),
|
||||
}));
|
||||
|
||||
import { buildItemsBaseEnrichmentUpdate } from "./furni-import";
|
||||
|
||||
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",
|
||||
});
|
||||
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",
|
||||
});
|
||||
|
||||
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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+674
-555
File diff suppressed because it is too large.
Load diff
+363
-322
@@ -14,46 +14,52 @@ 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 {
|
||||
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 interface MissingItem {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
/** True when habbo.com will never serve this asset (bad classname or 404 across all known revisions). */
|
||||
unrepairable?: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,13 +67,13 @@ export interface RepairResult {
|
||||
* Currently: whitespace or control chars (habbo.com only uses [A-Za-z0-9_*.-]).
|
||||
*/
|
||||
export function isStructurallyImpossible(classname: string): boolean {
|
||||
// Whitespace or ASCII control chars — habbo.com URL would 404.
|
||||
if (/\s/.test(classname)) return true;
|
||||
for (let i = 0; i < classname.length; i++) {
|
||||
const code = classname.charCodeAt(i);
|
||||
if (code < 0x20) return true;
|
||||
}
|
||||
return false;
|
||||
// Whitespace or ASCII control chars — habbo.com URL would 404.
|
||||
if (/\s/.test(classname)) return true;
|
||||
for (let i = 0; i < classname.length; i++) {
|
||||
const code = classname.charCodeAt(i);
|
||||
if (code < 0x20) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Audit ────────────────────────────────────────────────────────────
|
||||
@@ -77,81 +83,98 @@ 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 rows = await prisma.itemsBase.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
itemName: true,
|
||||
publicName: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs();
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
itemName: true,
|
||||
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;
|
||||
for (const row of rows) {
|
||||
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;
|
||||
if (!hasNitro && !hasIcon) {
|
||||
missing = "both";
|
||||
missingBoth++;
|
||||
} else if (!hasNitro) {
|
||||
missing = "nitro";
|
||||
missingNitro++;
|
||||
} else {
|
||||
missing = "icon";
|
||||
missingIcon++;
|
||||
}
|
||||
let missing: MissingKind;
|
||||
if (!hasNitro && !hasIcon) {
|
||||
missing = "both";
|
||||
missingBoth++;
|
||||
} else if (!hasNitro) {
|
||||
missing = "nitro";
|
||||
missingNitro++;
|
||||
} else {
|
||||
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",
|
||||
publicName: row.publicName ?? classname,
|
||||
hasSwf,
|
||||
hasIcon,
|
||||
hasNitro,
|
||||
missing,
|
||||
unrepairable: unrep,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
id: row.id,
|
||||
classname,
|
||||
baseClassname,
|
||||
revision: 0,
|
||||
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 };
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
order[a.missing] - order[b.missing] ||
|
||||
Number(a.unrepairable) - Number(b.unrepairable) ||
|
||||
a.classname.localeCompare(b.classname),
|
||||
);
|
||||
// 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 };
|
||||
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,129 +190,143 @@ 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 },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
type: true,
|
||||
publicName: true,
|
||||
},
|
||||
});
|
||||
const row = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: classname },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
type: true,
|
||||
publicName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
classname,
|
||||
ok: false,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
warnings,
|
||||
error: "Item not found in DB",
|
||||
};
|
||||
}
|
||||
if (!row) {
|
||||
return {
|
||||
classname,
|
||||
ok: false,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
warnings,
|
||||
error: "Item not found in DB",
|
||||
};
|
||||
}
|
||||
|
||||
// Fast path: shapes habbo.com can never serve (whitespace, control chars).
|
||||
// Skip every network call — safe us ~60s of useless 15s-timeout retries.
|
||||
if (isStructurallyImpossible(classname)) {
|
||||
return {
|
||||
classname,
|
||||
ok: false,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
warnings: ["Classname contains whitespace / control chars — habbo.com URL would 404"],
|
||||
error: "Unrepairable classname",
|
||||
unrepairable: true,
|
||||
};
|
||||
}
|
||||
// Fast path: shapes habbo.com can never serve (whitespace, control chars).
|
||||
// Skip every network call — safe us ~60s of useless 15s-timeout retries.
|
||||
if (isStructurallyImpossible(classname)) {
|
||||
return {
|
||||
classname,
|
||||
ok: false,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
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 {
|
||||
classname,
|
||||
ok: true,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
warnings: ["Nothing to repair"],
|
||||
};
|
||||
}
|
||||
if (!needsNitro && !needsIcon) {
|
||||
return {
|
||||
classname,
|
||||
ok: true,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
warnings: ["Nothing to repair"],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Resolve candidate revisions ─────────────────────────────────────
|
||||
// Priority order:
|
||||
// 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 pushRev = (r?: number | null) => {
|
||||
if (r && r > 0 && !revsToTry.includes(r)) revsToTry.push(r);
|
||||
};
|
||||
// ── Resolve candidate revisions ─────────────────────────────────────
|
||||
// Priority order:
|
||||
// 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 pushRev = (r?: number | null) => {
|
||||
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);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
try {
|
||||
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);
|
||||
// Fallback: revision 0 is valid for many habbo.com paths
|
||||
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;
|
||||
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" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
swfOk = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!swfOk && needsNitro) {
|
||||
warnings.push(`SWF not found on habbo.com (tried revisions: ${revsToTry.join(", ")})`);
|
||||
}
|
||||
}
|
||||
// ── 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;
|
||||
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" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
swfOk = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!swfOk && needsNitro) {
|
||||
warnings.push(
|
||||
`SWF not found on habbo.com (tried revisions: ${revsToTry.join(", ")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regenerate .nitro from SWF ──────────────────────────────────────
|
||||
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;
|
||||
// ── Regenerate .nitro from SWF ──────────────────────────────────────
|
||||
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;
|
||||
|
||||
// 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";
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
// 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";
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE items_base
|
||||
SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z ?? 0},
|
||||
allow_sit = ${cansiton ? "1" : "0"},
|
||||
@@ -299,126 +336,130 @@ export async function repairItem(classname: string): Promise<RepairResult> {
|
||||
canlayon = ${canlayon ? "1" : "0"}
|
||||
WHERE id = ${row.id}
|
||||
`;
|
||||
} catch (err) {
|
||||
warnings.push(`items_base dim update failed: ${(err as Error).message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
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;
|
||||
try {
|
||||
habboIt = await lookupHabboIt(classname);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
const entry = buildFurniEntry({
|
||||
id: row.id,
|
||||
classname,
|
||||
revision: habboIt?.revision ?? 0,
|
||||
category: habboIt?.category || "unknown",
|
||||
name: row.publicName || habboIt?.name || classname,
|
||||
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);
|
||||
} catch (err) {
|
||||
warnings.push(`FurnitureData.json sync failed: ${(err as Error).message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`SWF → Nitro conversion 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;
|
||||
try {
|
||||
habboIt = await lookupHabboIt(classname);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
const entry = buildFurniEntry({
|
||||
id: row.id,
|
||||
classname,
|
||||
revision: habboIt?.revision ?? 0,
|
||||
category: habboIt?.category || "unknown",
|
||||
name: row.publicName || habboIt?.name || classname,
|
||||
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);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`FurnitureData.json sync failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`SWF → Nitro conversion failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Repair icon (Nitro-style cascade) ───────────────────────────────
|
||||
//
|
||||
// Nitro client icon URL template: `${hof.furni.url}/icons/%libname%%param%_icon.png`
|
||||
// - %libname% = base classname (before `*`)
|
||||
// - %param% = `_N` for color variants, empty otherwise
|
||||
//
|
||||
// So for `bonusrare18_1*0` habbo.com can host either:
|
||||
// - bonusrare18_1_0_icon.png (color-specific) ← try first
|
||||
// - bonusrare18_1_icon.png (base fallback, Nitro reuses for all colors)
|
||||
//
|
||||
// Cascade per revision:
|
||||
// 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;
|
||||
if (needsIcon) {
|
||||
const hasColor = starIdx !== -1;
|
||||
const colorAwareName = classname.replace(/\*/g, "_");
|
||||
// ── Repair icon (Nitro-style cascade) ───────────────────────────────
|
||||
//
|
||||
// Nitro client icon URL template: `${hof.furni.url}/icons/%libname%%param%_icon.png`
|
||||
// - %libname% = base classname (before `*`)
|
||||
// - %param% = `_N` for color variants, empty otherwise
|
||||
//
|
||||
// So for `bonusrare18_1*0` habbo.com can host either:
|
||||
// - bonusrare18_1_0_icon.png (color-specific) ← try first
|
||||
// - bonusrare18_1_icon.png (base fallback, Nitro reuses for all colors)
|
||||
//
|
||||
// Cascade per revision:
|
||||
// 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;
|
||||
if (needsIcon) {
|
||||
const hasColor = starIdx !== -1;
|
||||
const colorAwareName = classname.replace(/\*/g, "_");
|
||||
|
||||
for (const tryRev of revsToTry) {
|
||||
// 1. color-specific icon (authoritative for color variants)
|
||||
if (hasColor) {
|
||||
const dl = await downloadFile(
|
||||
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(colorAwareName)}_icon.png`,
|
||||
iconPath,
|
||||
{ validate: "png" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
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" },
|
||||
);
|
||||
if (dlBase.ok) {
|
||||
iconRepaired = true;
|
||||
iconSource = "habbo-base";
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const tryRev of revsToTry) {
|
||||
// 1. color-specific icon (authoritative for color variants)
|
||||
if (hasColor) {
|
||||
const dl = await downloadFile(
|
||||
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(colorAwareName)}_icon.png`,
|
||||
iconPath,
|
||||
{ validate: "png" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
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" },
|
||||
);
|
||||
if (dlBase.ok) {
|
||||
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);
|
||||
if (iconPng) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
if (iconPng) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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)");
|
||||
}
|
||||
}
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// produced a result.
|
||||
const unrepairable =
|
||||
!ok &&
|
||||
!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")));
|
||||
// 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)
|
||||
// produced a result.
|
||||
const unrepairable =
|
||||
!ok &&
|
||||
!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")));
|
||||
|
||||
return { classname, ok, iconRepaired, nitroRepaired, warnings, unrepairable };
|
||||
return { classname, ok, iconRepaired, nitroRepaired, warnings, unrepairable };
|
||||
}
|
||||
@@ -18,145 +18,149 @@ 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();
|
||||
export async function getHabboItFurnidata(): Promise<
|
||||
Map<string, HabboItFurniEntry>
|
||||
> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cache if still valid
|
||||
if (cache && now - cacheTimestamp < CACHE_TTL) {
|
||||
return cache;
|
||||
}
|
||||
// Return cache if still valid
|
||||
if (cache && now - cacheTimestamp < CACHE_TTL) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests
|
||||
if (loading) return loading;
|
||||
// Deduplicate concurrent requests
|
||||
if (loading) return loading;
|
||||
|
||||
loading = (async () => {
|
||||
try {
|
||||
console.debug("[habbo-it] Fetching furnidata from habbo.it...");
|
||||
const res = await fetch(FURNIDATA_URL, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
loading = (async () => {
|
||||
try {
|
||||
console.debug("[habbo-it] Fetching furnidata from habbo.it...");
|
||||
const res = await fetch(FURNIDATA_URL, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[habbo-it] Fetch failed: ${res.status}`);
|
||||
return cache || new Map();
|
||||
}
|
||||
if (!res.ok) {
|
||||
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;
|
||||
if (Array.isArray(roomTypes)) {
|
||||
for (const item of roomTypes) {
|
||||
if (!item.classname) continue;
|
||||
map.set(item.classname, {
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
revision: item.revision ?? 0,
|
||||
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 ?? "",
|
||||
specialtype: item.specialtype ?? 0,
|
||||
canstandon: item.canstandon ?? false,
|
||||
cansiton: item.cansiton ?? false,
|
||||
canlayon: item.canlayon ?? false,
|
||||
furniline: item.furniline ?? "",
|
||||
environment: item.environment ?? "",
|
||||
rare: item.rare ?? false,
|
||||
tradeable: item.tradeable,
|
||||
bc: item.bc,
|
||||
customparams: item.customparams,
|
||||
itemType: "s",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Process room items (floor furniture)
|
||||
const roomTypes = data?.roomitemtypes?.furnitype;
|
||||
if (Array.isArray(roomTypes)) {
|
||||
for (const item of roomTypes) {
|
||||
if (!item.classname) continue;
|
||||
map.set(item.classname, {
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
revision: item.revision ?? 0,
|
||||
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 ?? "",
|
||||
specialtype: item.specialtype ?? 0,
|
||||
canstandon: item.canstandon ?? false,
|
||||
cansiton: item.cansiton ?? false,
|
||||
canlayon: item.canlayon ?? false,
|
||||
furniline: item.furniline ?? "",
|
||||
environment: item.environment ?? "",
|
||||
rare: item.rare ?? false,
|
||||
tradeable: item.tradeable,
|
||||
bc: item.bc,
|
||||
customparams: item.customparams,
|
||||
itemType: "s",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process wall items
|
||||
const wallTypes = data?.wallitemtypes?.furnitype;
|
||||
if (Array.isArray(wallTypes)) {
|
||||
for (const item of wallTypes) {
|
||||
if (!item.classname) continue;
|
||||
map.set(item.classname, {
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
revision: item.revision ?? 0,
|
||||
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 ?? "",
|
||||
specialtype: item.specialtype ?? 0,
|
||||
canstandon: item.canstandon ?? false,
|
||||
cansiton: item.cansiton ?? false,
|
||||
canlayon: item.canlayon ?? false,
|
||||
furniline: item.furniline ?? "",
|
||||
environment: item.environment ?? "",
|
||||
rare: item.rare ?? false,
|
||||
tradeable: item.tradeable,
|
||||
bc: item.bc,
|
||||
customparams: item.customparams,
|
||||
itemType: "i",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Process wall items
|
||||
const wallTypes = data?.wallitemtypes?.furnitype;
|
||||
if (Array.isArray(wallTypes)) {
|
||||
for (const item of wallTypes) {
|
||||
if (!item.classname) continue;
|
||||
map.set(item.classname, {
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
revision: item.revision ?? 0,
|
||||
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 ?? "",
|
||||
specialtype: item.specialtype ?? 0,
|
||||
canstandon: item.canstandon ?? false,
|
||||
cansiton: item.cansiton ?? false,
|
||||
canlayon: item.canlayon ?? false,
|
||||
furniline: item.furniline ?? "",
|
||||
environment: item.environment ?? "",
|
||||
rare: item.rare ?? false,
|
||||
tradeable: item.tradeable,
|
||||
bc: item.bc,
|
||||
customparams: item.customparams,
|
||||
itemType: "i",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`[habbo-it] Cached ${map.size} items (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
|
||||
);
|
||||
cache = map;
|
||||
cacheTimestamp = now;
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.warn("[habbo-it] Fetch error:", (err as Error).message);
|
||||
return cache || new Map();
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
})();
|
||||
console.debug(
|
||||
`[habbo-it] Cached ${map.size} items (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
|
||||
);
|
||||
cache = map;
|
||||
cacheTimestamp = now;
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.warn("[habbo-it] Fetch error:", (err as Error).message);
|
||||
return cache || new Map();
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return loading;
|
||||
return loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a single item by classname from habbo.it furnidata.
|
||||
* Tries exact match first, then base classname (without color variant).
|
||||
*/
|
||||
export async function lookupHabboIt(classname: string): Promise<HabboItFurniEntry | null> {
|
||||
const map = await getHabboItFurnidata();
|
||||
export async function lookupHabboIt(
|
||||
classname: string,
|
||||
): Promise<HabboItFurniEntry | null> {
|
||||
const map = await getHabboItFurnidata();
|
||||
|
||||
// 1. Exact match
|
||||
const exact = map.get(classname);
|
||||
if (exact) return exact;
|
||||
// 1. Exact match
|
||||
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];
|
||||
// Try base with *0 first (most common default)
|
||||
const variant0 = map.get(`${base}*0`);
|
||||
if (variant0) return variant0;
|
||||
// Try just the base
|
||||
const baseMatch = map.get(base);
|
||||
if (baseMatch) return baseMatch;
|
||||
}
|
||||
// 2. If classname has color variant (e.g. "chair*4"), try base
|
||||
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;
|
||||
// Try just the base
|
||||
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;
|
||||
}
|
||||
// 3. If no variant in classname, try *0
|
||||
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;
|
||||
}
|
||||
+108
-105
@@ -10,14 +10,15 @@ export 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 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;
|
||||
@@ -29,90 +30,92 @@ let loadingPromise: Promise<BadgeEntry[]> | null = null;
|
||||
* 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 || [];
|
||||
}
|
||||
loadingPromise = (async () => {
|
||||
try {
|
||||
if (!EXTERNAL_TEXTS_URL) {
|
||||
console.warn("[badges] HABBO_EXTERNAL_TEXTS_URL not configured");
|
||||
return badgeCache || [];
|
||||
}
|
||||
|
||||
const res = await fetch(EXTERNAL_TEXTS_URL, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
const res = await fetch(EXTERNAL_TEXTS_URL, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[badges] Fetch failed: ${res.status}`);
|
||||
return badgeCache || [];
|
||||
}
|
||||
if (!res.ok) {
|
||||
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>();
|
||||
// Parse key=value format, extract badge_name_CODE and badge_desc_CODE
|
||||
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();
|
||||
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();
|
||||
|
||||
// Format: badge_name_CODE or CODE_badge_name
|
||||
let match = key.match(/^badge_name_(.+)$/);
|
||||
if (match) {
|
||||
nameMap.set(match[1], value);
|
||||
continue;
|
||||
}
|
||||
match = key.match(/^(.+)_badge_name$/);
|
||||
if (match) {
|
||||
nameMap.set(match[1], value);
|
||||
continue;
|
||||
}
|
||||
// Format: badge_name_CODE or CODE_badge_name
|
||||
let match = key.match(/^badge_name_(.+)$/);
|
||||
if (match) {
|
||||
nameMap.set(match[1], value);
|
||||
continue;
|
||||
}
|
||||
match = key.match(/^(.+)_badge_name$/);
|
||||
if (match) {
|
||||
nameMap.set(match[1], value);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = key.match(/^badge_desc_(.+)$/);
|
||||
if (match) {
|
||||
descMap.set(match[1], value);
|
||||
continue;
|
||||
}
|
||||
match = key.match(/^(.+)_badge_desc$/);
|
||||
if (match) {
|
||||
descMap.set(match[1], value);
|
||||
}
|
||||
}
|
||||
match = key.match(/^badge_desc_(.+)$/);
|
||||
if (match) {
|
||||
descMap.set(match[1], value);
|
||||
continue;
|
||||
}
|
||||
match = key.match(/^(.+)_badge_desc$/);
|
||||
if (match) {
|
||||
descMap.set(match[1], value);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge into badge entries
|
||||
const allCodes = new Set([...nameMap.keys(), ...descMap.keys()]);
|
||||
const badges: BadgeEntry[] = [];
|
||||
// Merge into badge entries
|
||||
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) || "",
|
||||
});
|
||||
}
|
||||
for (const code of allCodes) {
|
||||
badges.push({
|
||||
code,
|
||||
name: nameMap.get(code) || "",
|
||||
description: descMap.get(code) || "",
|
||||
});
|
||||
}
|
||||
|
||||
// Sort alphabetically by code
|
||||
badges.sort((a, b) => a.code.localeCompare(b.code));
|
||||
// Sort alphabetically by 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;
|
||||
} catch (err) {
|
||||
console.warn("[badges] Load error:", (err as Error).message);
|
||||
return badgeCache || [];
|
||||
} finally {
|
||||
loadingPromise = null;
|
||||
}
|
||||
})();
|
||||
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 || [];
|
||||
} finally {
|
||||
loadingPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return loadingPromise;
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,42 +123,42 @@ async function loadBadges(): Promise<BadgeEntry[]> {
|
||||
* Returns badges with image URLs from images.habbo.com.
|
||||
*/
|
||||
export async function searchBadges(
|
||||
term: string,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
_hotel: string | null = null, // kept for API compat, ignored (single source)
|
||||
term: string,
|
||||
limit = 100,
|
||||
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;
|
||||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
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",
|
||||
url_habbo: `${BADGE_IMAGE_BASE}/${b.code}.gif`,
|
||||
url_habboassets: `${BADGE_IMAGE_BASE}/${b.code}.gif`, // same source, no fallback needed
|
||||
}));
|
||||
return page.map((b) => ({
|
||||
id: 0,
|
||||
code: b.code,
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
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;
|
||||
}
|
||||
+107
-100
@@ -17,77 +17,77 @@ export type { FurniListResult, HabboFurniItem };
|
||||
* Data comes from habbo.it/gamedata/furnidata_json/1 (cached 30 min).
|
||||
*/
|
||||
export async function listFurniture(
|
||||
options: {
|
||||
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>;
|
||||
} = {},
|
||||
options: {
|
||||
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>;
|
||||
} = {},
|
||||
): 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 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;
|
||||
|
||||
try {
|
||||
const map = await getHabboItFurnidata();
|
||||
if (map.size === 0) return empty;
|
||||
try {
|
||||
const map = await getHabboItFurnidata();
|
||||
if (map.size === 0) return empty;
|
||||
|
||||
// Convert to array and optionally filter by search
|
||||
let items = Array.from(map.values());
|
||||
// Convert to array and optionally filter by search
|
||||
let items = Array.from(map.values());
|
||||
|
||||
if (search) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.classname.toLowerCase().includes(search) ||
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.description.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
if (search) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
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;
|
||||
});
|
||||
} else {
|
||||
// Sort by id descending (newest first)
|
||||
items.sort((a, b) => b.id - a.id);
|
||||
}
|
||||
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;
|
||||
});
|
||||
} else {
|
||||
// Sort by id descending (newest first)
|
||||
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",
|
||||
revision: item.revision,
|
||||
category: item.category || item.furniline || "",
|
||||
})),
|
||||
meta: { currentPage: page, lastPage, total, perPage },
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("[furni] List error:", (err as Error).message);
|
||||
return empty;
|
||||
}
|
||||
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",
|
||||
revision: item.revision,
|
||||
category: item.category || item.furniline || "",
|
||||
})),
|
||||
meta: { currentPage: page, lastPage, total, perPage },
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("[furni] List error:", (err as Error).message);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search endpoint ────────────────────────────────────────────────
|
||||
@@ -95,9 +95,12 @@ 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;
|
||||
export async function searchFurniture(
|
||||
query: string,
|
||||
perPage = 50,
|
||||
): Promise<HabboFurniItem[]> {
|
||||
const result = await listFurniture({ search: query, perPage });
|
||||
return result.items;
|
||||
}
|
||||
|
||||
// ── Direct lookup by classname ─────────────────────────────────────
|
||||
@@ -106,34 +109,36 @@ export async function searchFurniture(query: string, perPage = 50): Promise<Habb
|
||||
* Get a single furniture item by classname.
|
||||
* Uses the cached habbo.it furnidata.
|
||||
*/
|
||||
export async function getFurnitureByClassname(classname: string): Promise<HabboFurniItem | null> {
|
||||
const map = await getHabboItFurnidata();
|
||||
export async function getFurnitureByClassname(
|
||||
classname: string,
|
||||
): Promise<HabboFurniItem | null> {
|
||||
const map = await getHabboItFurnidata();
|
||||
|
||||
// Try exact match
|
||||
let entry = map.get(classname);
|
||||
// Try exact match
|
||||
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);
|
||||
}
|
||||
// Try without color variant
|
||||
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`);
|
||||
}
|
||||
// Try with *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",
|
||||
revision: entry.revision,
|
||||
category: entry.category || entry.furniline || "",
|
||||
};
|
||||
return {
|
||||
id: entry.id,
|
||||
classname: entry.classname,
|
||||
name: entry.name || entry.classname,
|
||||
description: entry.description || "",
|
||||
type: entry.itemType === "i" ? "wallitem" : "flooritem",
|
||||
revision: entry.revision,
|
||||
category: entry.category || entry.furniline || "",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Batch lookup ───────────────────────────────────────────────────
|
||||
@@ -142,16 +147,18 @@ 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);
|
||||
if (item?.name) {
|
||||
result.set(cn, item);
|
||||
}
|
||||
}
|
||||
for (const cn of classnames) {
|
||||
const item = await getFurnitureByClassname(cn);
|
||||
if (item?.name) {
|
||||
result.set(cn, item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
@@ -3,44 +3,46 @@ 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;
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
const dec = decodePng(encodePng(w, h, rgba));
|
||||
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);
|
||||
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;
|
||||
}
|
||||
const dec = decodePng(encodePng(w, h, rgba));
|
||||
expect(Buffer.compare(dec.rgba, rgba)).toBe(0);
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
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]);
|
||||
});
|
||||
|
||||
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,49 +1,55 @@
|
||||
import { inflateSync } from "node:zlib";
|
||||
|
||||
export interface DecodedPng {
|
||||
width: number;
|
||||
height: number;
|
||||
/** RGBA8888, row-major, length = width * height * 4 */
|
||||
rgba: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
/** RGBA8888, row-major, length = width * height * 4 */
|
||||
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;
|
||||
switch (filter) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown PNG filter ${filter}`);
|
||||
}
|
||||
function unfilter(
|
||||
filter: number,
|
||||
cur: Buffer,
|
||||
prev: Buffer,
|
||||
bpp: number,
|
||||
): void {
|
||||
const n = cur.length;
|
||||
switch (filter) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown PNG filter ${filter}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,77 +58,92 @@ function unfilter(filter: number, cur: Buffer, prev: Buffer, bpp: number): void
|
||||
* Returns RGBA pixels. Throws on unsupported formats.
|
||||
*/
|
||||
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");
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 (
|
||||
buf.length < 8 ||
|
||||
buf[0] !== 0x89 ||
|
||||
buf[1] !== 0x50 ||
|
||||
buf[2] !== 0x4e ||
|
||||
buf[3] !== 0x47
|
||||
) {
|
||||
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[] = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
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 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;
|
||||
}
|
||||
|
||||
if (channels === 4) return { width, height, rgba: planar };
|
||||
if (channels === 4) return { width, height, rgba: planar };
|
||||
|
||||
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];
|
||||
} else {
|
||||
const g = planar[i];
|
||||
rgba[j++] = g;
|
||||
rgba[j++] = g;
|
||||
rgba[j++] = g;
|
||||
}
|
||||
rgba[j++] = 255;
|
||||
}
|
||||
return { width, height, rgba };
|
||||
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];
|
||||
} else {
|
||||
const g = planar[i];
|
||||
rgba[j++] = g;
|
||||
rgba[j++] = g;
|
||||
rgba[j++] = g;
|
||||
}
|
||||
rgba[j++] = 255;
|
||||
}
|
||||
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);
|
||||
for (let row = 0; row < h; row++) {
|
||||
const src = ((y + row) * sheetW + x) * 4;
|
||||
rgba.copy(out, row * w * 4, src, src + w * 4);
|
||||
}
|
||||
return out;
|
||||
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);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -3,19 +3,21 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveGamedataFile } from "./asset-paths";
|
||||
|
||||
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",
|
||||
);
|
||||
it("uses absolute external paths as-is", () => {
|
||||
const absolutePath = path.win32.join(
|
||||
"E:\\",
|
||||
"Users",
|
||||
"simol",
|
||||
"Desktop",
|
||||
"DEV",
|
||||
"Nitro-Files",
|
||||
"nitro-assets",
|
||||
"gamedata",
|
||||
"FigureMap.json",
|
||||
);
|
||||
|
||||
expect(resolveGamedataFile(absolutePath, "FigureMap.json")).toBe(absolutePath);
|
||||
});
|
||||
expect(resolveGamedataFile(absolutePath, "FigureMap.json")).toBe(
|
||||
absolutePath,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,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,14 @@ 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;
|
||||
}
|
||||
@@ -2,19 +2,23 @@ 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);
|
||||
});
|
||||
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,76 @@
|
||||
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" },
|
||||
url: string,
|
||||
destPath: string,
|
||||
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)));
|
||||
}
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
||||
if (!res.ok) {
|
||||
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 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length < 8) {
|
||||
if (attempt === maxRetries) return { ok: false, size: 0 };
|
||||
continue;
|
||||
}
|
||||
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
|
||||
if (attempt === maxRetries) {
|
||||
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
|
||||
return { ok: false, size: 0 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (options?.validate === "png" && !validatePngBytes(buffer)) {
|
||||
if (attempt === maxRetries) {
|
||||
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
|
||||
return { ok: false, size: 0 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: false, size: 0 };
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||
}
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
||||
if (!res.ok) {
|
||||
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 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length < 8) {
|
||||
if (attempt === maxRetries) return { ok: false, size: 0 };
|
||||
continue;
|
||||
}
|
||||
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
|
||||
if (attempt === maxRetries) {
|
||||
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
|
||||
return { ok: false, size: 0 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (options?.validate === "png" && !validatePngBytes(buffer)) {
|
||||
if (attempt === maxRetries) {
|
||||
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
|
||||
return { ok: false, size: 0 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: false, size: 0 };
|
||||
}
|
||||
@@ -2,42 +2,51 @@ 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 {
|
||||
readGamedataJson,
|
||||
withGamedataLock,
|
||||
writeGamedataJsonAtomic,
|
||||
} from "./gamedata-json";
|
||||
|
||||
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);
|
||||
});
|
||||
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 () => {
|
||||
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
|
||||
});
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -8,56 +8,67 @@ const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
|
||||
const chains = new Map<string, Promise<void>>();
|
||||
|
||||
async function acquireDiskLock(lockPath: string): Promise<void> {
|
||||
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;
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code !== "EEXIST") throw err;
|
||||
try {
|
||||
const stat = await fs.stat(/*turbopackIgnore: true*/ lockPath);
|
||||
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
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;
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code !== "EEXIST") throw err;
|
||||
try {
|
||||
const stat = await fs.stat(/*turbopackIgnore: true*/ lockPath);
|
||||
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function withGamedataLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
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);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {});
|
||||
release();
|
||||
}
|
||||
export async function withGamedataLock<T>(
|
||||
filePath: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
@@ -2,30 +2,39 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __clearGordonCache, resolveGordonBuildUrl } from "./gordon";
|
||||
|
||||
beforeEach(() => {
|
||||
__clearGordonCache();
|
||||
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/");
|
||||
});
|
||||
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 () => {
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
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,30 @@
|
||||
const EXTERNAL_VARIABLES_URL = "https://www.habbo.com/gamedata/external_variables/1";
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -2,27 +2,42 @@ 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();
|
||||
return text
|
||||
.split("\n\n")
|
||||
.filter((l) => l.startsWith("data: "))
|
||||
.map((l) => JSON.parse(l.slice(6)));
|
||||
const text = await res.text();
|
||||
return text
|
||||
.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 () => {
|
||||
const res = runSseBatch({
|
||||
items: ["a", "b", "c"],
|
||||
concurrency: 2,
|
||||
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 });
|
||||
});
|
||||
it("emits batch_start, per-item, and batch_complete with correct counts", async () => {
|
||||
const res = runSseBatch({
|
||||
items: ["a", "b", "c"],
|
||||
concurrency: 2,
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,20 @@
|
||||
export interface SseWorkerResult {
|
||||
ok: boolean;
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
ok: boolean;
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunSseBatchOptions<T> {
|
||||
items: T[];
|
||||
concurrency: number;
|
||||
/** Label used as the `classname` field on item_progress (kept for the existing client parser). */
|
||||
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>;
|
||||
items: T[];
|
||||
concurrency: number;
|
||||
/** Label used as the `classname` field on item_progress (kept for the existing client parser). */
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,88 +22,97 @@ 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`));
|
||||
} catch {
|
||||
/* stream closed by client */
|
||||
}
|
||||
};
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
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);
|
||||
await Promise.allSettled(
|
||||
chunk.map(async (item, chunkIdx) => {
|
||||
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 }),
|
||||
);
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings?.length) withWarnings++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname,
|
||||
status: "done",
|
||||
index,
|
||||
warnings: result.warnings?.length ? result.warnings : undefined,
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < items.length; 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,
|
||||
});
|
||||
try {
|
||||
const result = await worker(item, index, (status) =>
|
||||
send({ type: "item_progress", classname, status, index }),
|
||||
);
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings?.length) withWarnings++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname,
|
||||
status: "done",
|
||||
index,
|
||||
warnings: result.warnings?.length
|
||||
? result.warnings
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,45 +10,61 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
* "ipqualityscore"), vpn_api_key.
|
||||
*/
|
||||
export interface IpVerdict {
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const PRIVATE_RE = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|fc|fd|localhost$|0\.0\.0\.0$)/i;
|
||||
const PRIVATE_RE =
|
||||
/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|fc|fd|localhost$|0\.0\.0\.0$)/i;
|
||||
|
||||
export async function checkVpn(ip: string): Promise<IpVerdict> {
|
||||
if (!ip || PRIVATE_RE.test(ip)) return { blocked: false };
|
||||
if (!(await siteSettings.getBool("vpn_block_enabled", false))) return { blocked: false };
|
||||
if (!ip || PRIVATE_RE.test(ip)) return { blocked: false };
|
||||
if (!(await siteSettings.getBool("vpn_block_enabled", false)))
|
||||
return { blocked: false };
|
||||
|
||||
const provider = ((await siteSettings.get("vpn_provider", "proxycheck")) ?? "proxycheck").toLowerCase();
|
||||
const apiKey = (await siteSettings.get("vpn_api_key", "")) ?? "";
|
||||
const provider = (
|
||||
(await siteSettings.get("vpn_provider", "proxycheck")) ?? "proxycheck"
|
||||
).toLowerCase();
|
||||
const apiKey = (await siteSettings.get("vpn_api_key", "")) ?? "";
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 4000);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 4000);
|
||||
|
||||
if (provider === "ipqualityscore") {
|
||||
if (!apiKey) return { blocked: false };
|
||||
const res = await fetch(
|
||||
`https://ipqualityscore.com/api/json/ip/${encodeURIComponent(apiKey)}/${encodeURIComponent(ip)}`,
|
||||
{ signal: controller.signal, cache: "no-store" },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
const d = (await res.json()) as { proxy?: boolean; vpn?: boolean; tor?: boolean };
|
||||
if (d?.vpn || d?.tor || d?.proxy) return { blocked: true, reason: "VPN/proxy detected" };
|
||||
return { blocked: false };
|
||||
}
|
||||
if (provider === "ipqualityscore") {
|
||||
if (!apiKey) return { blocked: false };
|
||||
const res = await fetch(
|
||||
`https://ipqualityscore.com/api/json/ip/${encodeURIComponent(apiKey)}/${encodeURIComponent(ip)}`,
|
||||
{ signal: controller.signal, cache: "no-store" },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
const d = (await res.json()) as {
|
||||
proxy?: boolean;
|
||||
vpn?: boolean;
|
||||
tor?: boolean;
|
||||
};
|
||||
if (d?.vpn || d?.tor || d?.proxy)
|
||||
return { blocked: true, reason: "VPN/proxy detected" };
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// Default: proxycheck.io (works keyless at a low rate; key raises limits).
|
||||
const url = `https://proxycheck.io/v2/${encodeURIComponent(ip)}?vpn=1&risk=1${apiKey ? `&key=${encodeURIComponent(apiKey)}` : ""}`;
|
||||
const res = await fetch(url, { signal: controller.signal, cache: "no-store" });
|
||||
clearTimeout(timer);
|
||||
const d = (await res.json()) as Record<string, { proxy?: string; type?: string }>;
|
||||
// eslint-disable-next-line security/detect-object-injection -- ip is the API response key from proxycheck
|
||||
const entry = d?.[ip];
|
||||
if (entry?.proxy === "yes") return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
|
||||
return { blocked: false };
|
||||
} catch {
|
||||
return { blocked: false };
|
||||
}
|
||||
// Default: proxycheck.io (works keyless at a low rate; key raises limits).
|
||||
const url = `https://proxycheck.io/v2/${encodeURIComponent(ip)}?vpn=1&risk=1${apiKey ? `&key=${encodeURIComponent(apiKey)}` : ""}`;
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const d = (await res.json()) as Record<
|
||||
string,
|
||||
{ proxy?: string; type?: string }
|
||||
>;
|
||||
// eslint-disable-next-line security/detect-object-injection -- ip is the API response key from proxycheck
|
||||
const entry = d?.[ip];
|
||||
if (entry?.proxy === "yes")
|
||||
return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
|
||||
return { blocked: false };
|
||||
} catch {
|
||||
return { blocked: false };
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ import { prisma } from "@/lib/prisma";
|
||||
// explicitly-flagged AI result). Pure server module; uses global fetch only.
|
||||
|
||||
export interface ModerationResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
|
||||
@@ -33,26 +33,31 @@ let wordFilterLoadedAt = 0;
|
||||
const WORD_FILTER_TTL_MS = 60_000;
|
||||
|
||||
async function loadWordFilter(): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
if (wordFilterCache === null || now - wordFilterLoadedAt > WORD_FILTER_TTL_MS) {
|
||||
try {
|
||||
const rows = await prisma.websiteWordfilter.findMany({
|
||||
select: { word: true },
|
||||
});
|
||||
wordFilterCache = rows.map((r) => r.word.trim().toLowerCase()).filter((w) => w.length > 0);
|
||||
wordFilterLoadedAt = now;
|
||||
} catch {
|
||||
// DB unavailable — return an empty filter WITHOUT caching, so the next
|
||||
// call retries. Fail-open: a missing blocklist must not block content.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return wordFilterCache;
|
||||
const now = Date.now();
|
||||
if (
|
||||
wordFilterCache === null ||
|
||||
now - wordFilterLoadedAt > WORD_FILTER_TTL_MS
|
||||
) {
|
||||
try {
|
||||
const rows = await prisma.websiteWordfilter.findMany({
|
||||
select: { word: true },
|
||||
});
|
||||
wordFilterCache = rows
|
||||
.map((r) => r.word.trim().toLowerCase())
|
||||
.filter((w) => w.length > 0);
|
||||
wordFilterLoadedAt = now;
|
||||
} catch {
|
||||
// DB unavailable — return an empty filter WITHOUT caching, so the next
|
||||
// call retries. Fail-open: a missing blocklist must not block content.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return wordFilterCache;
|
||||
}
|
||||
|
||||
/** Invalidate the cached word filter after the blocklist is edited. */
|
||||
export function reloadWordFilter(): void {
|
||||
wordFilterCache = null;
|
||||
wordFilterCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,13 +66,13 @@ export function reloadWordFilter(): void {
|
||||
* AtomCMS's behaviour (filtered words are blocked even inside other words).
|
||||
*/
|
||||
async function wordFilterHit(text: string): Promise<string | null> {
|
||||
const words = await loadWordFilter();
|
||||
if (words.length === 0) return null;
|
||||
const haystack = text.toLowerCase();
|
||||
for (const word of words) {
|
||||
if (haystack.includes(word)) return word;
|
||||
}
|
||||
return null;
|
||||
const words = await loadWordFilter();
|
||||
if (words.length === 0) return null;
|
||||
const haystack = text.toLowerCase();
|
||||
for (const word of words) {
|
||||
if (haystack.includes(word)) return word;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,34 +81,35 @@ async function wordFilterHit(text: string): Promise<string | null> {
|
||||
* malformed body, timeout) returns false — fail-open.
|
||||
*/
|
||||
async function openAiFlagged(text: string): Promise<boolean> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) return false;
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) return false;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(OPENAI_MODERATIONS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ input: text }),
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(OPENAI_MODERATIONS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ input: text }),
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const results = (data as { results?: Array<{ flagged?: boolean }> })?.results;
|
||||
if (!Array.isArray(results)) return false;
|
||||
return results.some((r) => r?.flagged === true);
|
||||
} catch {
|
||||
// Network error / abort / parse failure — fail-open.
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const data: unknown = await res.json();
|
||||
const results = (data as { results?: Array<{ flagged?: boolean }> })
|
||||
?.results;
|
||||
if (!Array.isArray(results)) return false;
|
||||
return results.some((r) => r?.flagged === true);
|
||||
} catch {
|
||||
// Network error / abort / parse failure — fail-open.
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,22 +118,22 @@ async function openAiFlagged(text: string): Promise<boolean> {
|
||||
* or non-string input is treated as allowed (nothing to moderate).
|
||||
*/
|
||||
export async function isAllowed(text: string): Promise<ModerationResult> {
|
||||
if (typeof text !== "string" || text.trim().length === 0) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (typeof text !== "string" || text.trim().length === 0) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Layer 1: local blocklist (cheap, cached).
|
||||
const hit = await wordFilterHit(text);
|
||||
if (hit) {
|
||||
return { ok: false, reason: `Blocked by word filter: "${hit}"` };
|
||||
}
|
||||
// Layer 1: local blocklist (cheap, cached).
|
||||
const hit = await wordFilterHit(text);
|
||||
if (hit) {
|
||||
return { ok: false, reason: `Blocked by word filter: "${hit}"` };
|
||||
}
|
||||
|
||||
// Layer 2: OpenAI moderation (only when configured).
|
||||
if (await openAiFlagged(text)) {
|
||||
return { ok: false, reason: "Blocked by automated content moderation" };
|
||||
}
|
||||
// Layer 2: OpenAI moderation (only when configured).
|
||||
if (await openAiFlagged(text)) {
|
||||
return { ok: false, reason: "Blocked by automated content moderation" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,8 +142,8 @@ export async function isAllowed(text: string): Promise<ModerationResult> {
|
||||
* early. Resolves silently when the content is allowed.
|
||||
*/
|
||||
export async function moderateOrThrow(text: string): Promise<void> {
|
||||
const result = await isAllowed(text);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.reason ?? "Content not allowed");
|
||||
}
|
||||
const result = await isAllowed(text);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.reason ?? "Content not allowed");
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
* 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,35 +20,35 @@ 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 */
|
||||
export function slugifyPath(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "uncategorized"
|
||||
);
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "uncategorized"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the flat furniture folder for .nitro files.
|
||||
*/
|
||||
export async function scanFlatFolder(): Promise<string[]> {
|
||||
try {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const entries = await fs.readdir(nitroDir);
|
||||
return entries.filter((e) => e.endsWith(".nitro"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const entries = await fs.readdir(nitroDir);
|
||||
return entries.filter((e) => e.endsWith(".nitro"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,19 +56,19 @@ export async function scanFlatFolder(): Promise<string[]> {
|
||||
* Returns a Set of classnames (filename without .nitro extension).
|
||||
*/
|
||||
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>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
return classnames;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
return classnames;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,102 @@
|
||||
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 () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 41,
|
||||
status: "CREATED",
|
||||
});
|
||||
it("rejects an order created by a different user", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 41,
|
||||
status: "CREATED",
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
|
||||
code: "ORDER_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
await expect(
|
||||
authorizeTopupCapture(99, "ORDER-123", findOrder),
|
||||
).rejects.toMatchObject({
|
||||
code: "ORDER_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an order that has already left the created state", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 99,
|
||||
status: "COMPLETED",
|
||||
});
|
||||
it("rejects an order that has already left the created state", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 99,
|
||||
status: "COMPLETED",
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
|
||||
code: "ORDER_ALREADY_PROCESSED",
|
||||
});
|
||||
});
|
||||
await expect(
|
||||
authorizeTopupCapture(99, "ORDER-123", findOrder),
|
||||
).rejects.toMatchObject({
|
||||
code: "ORDER_ALREADY_PROCESSED",
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes credit delivery for a captured order without capturing it again", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 99,
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
amount: 12.5,
|
||||
});
|
||||
it("resumes credit delivery for a captured order without capturing it again", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 99,
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
amount: 12.5,
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).resolves.toMatchObject({
|
||||
action: "DELIVER_CREDITS",
|
||||
amount: 12.5,
|
||||
});
|
||||
});
|
||||
await expect(
|
||||
authorizeTopupCapture(99, "ORDER-123", findOrder),
|
||||
).resolves.toMatchObject({
|
||||
action: "DELIVER_CREDITS",
|
||||
amount: 12.5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordCreatedTopup", () => {
|
||||
it("stores the PayPal order id with its owner before approval", async () => {
|
||||
let saved: Parameters<typeof record>[0] | undefined;
|
||||
const record = async (data: {
|
||||
userId: number;
|
||||
transactionId: string;
|
||||
status: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) => {
|
||||
saved = data;
|
||||
};
|
||||
it("stores the PayPal order id with its owner before approval", async () => {
|
||||
let saved: Parameters<typeof record>[0] | undefined;
|
||||
const record = async (data: {
|
||||
userId: number;
|
||||
transactionId: string;
|
||||
status: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) => {
|
||||
saved = data;
|
||||
};
|
||||
|
||||
await recordCreatedTopup(
|
||||
{ userId: 99, orderId: "ORDER-123", amount: 12.5, currency: "EUR", credits: 1250 },
|
||||
record,
|
||||
);
|
||||
await recordCreatedTopup(
|
||||
{
|
||||
userId: 99,
|
||||
orderId: "ORDER-123",
|
||||
amount: 12.5,
|
||||
currency: "EUR",
|
||||
credits: 1250,
|
||||
},
|
||||
record,
|
||||
);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
userId: 99,
|
||||
transactionId: "ORDER-123",
|
||||
status: "CREATED",
|
||||
amount: 12.5,
|
||||
currency: "EUR",
|
||||
});
|
||||
});
|
||||
expect(saved).toMatchObject({
|
||||
userId: 99,
|
||||
transactionId: "ORDER-123",
|
||||
status: "CREATED",
|
||||
amount: 12.5,
|
||||
currency: "EUR",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimTopupDelivery", () => {
|
||||
it("rejects delivery when another request already claimed the order", async () => {
|
||||
const claim = async () => ({ count: 0 });
|
||||
it("rejects delivery when another request already claimed the order", async () => {
|
||||
const claim = async () => ({ count: 0 });
|
||||
|
||||
await expect(claimTopupDelivery(claim)).rejects.toMatchObject({
|
||||
code: "DELIVERY_ALREADY_CLAIMED",
|
||||
});
|
||||
});
|
||||
await expect(claimTopupDelivery(claim)).rejects.toMatchObject({
|
||||
code: "DELIVERY_ALREADY_CLAIMED",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows delivery after atomically claiming the pending order", async () => {
|
||||
const claim = async () => ({ count: 1 });
|
||||
it("allows delivery after atomically claiming the pending order", async () => {
|
||||
const claim = async () => ({ count: 1 });
|
||||
|
||||
await expect(claimTopupDelivery(claim)).resolves.toBeUndefined();
|
||||
});
|
||||
await expect(claimTopupDelivery(claim)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,82 +1,91 @@
|
||||
export interface PendingTopup {
|
||||
userId: number;
|
||||
status: string | null;
|
||||
amount?: number;
|
||||
userId: number;
|
||||
status: string | null;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export type AuthorizedTopup = PendingTopup & { action: "CAPTURE" | "DELIVER_CREDITS" };
|
||||
export type AuthorizedTopup = PendingTopup & {
|
||||
action: "CAPTURE" | "DELIVER_CREDITS";
|
||||
};
|
||||
|
||||
export type FindTopupOrder = (orderId: string) => Promise<PendingTopup | null>;
|
||||
|
||||
export interface CreatedTopupRecord {
|
||||
userId: number;
|
||||
transactionId: string;
|
||||
status: "CREATED";
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userId: number;
|
||||
transactionId: string;
|
||||
status: "CREATED";
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type CreateTopupRecord = (data: CreatedTopupRecord) => Promise<unknown>;
|
||||
|
||||
export class TopupCaptureError extends Error {
|
||||
constructor(public readonly code: "ORDER_NOT_FOUND" | "ORDER_ALREADY_PROCESSED") {
|
||||
super(code);
|
||||
}
|
||||
constructor(
|
||||
public readonly code: "ORDER_NOT_FOUND" | "ORDER_ALREADY_PROCESSED",
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export class TopupDeliveryError extends Error {
|
||||
readonly code = "DELIVERY_ALREADY_CLAIMED";
|
||||
readonly code = "DELIVERY_ALREADY_CLAIMED";
|
||||
|
||||
constructor() {
|
||||
super("DELIVERY_ALREADY_CLAIMED");
|
||||
}
|
||||
constructor() {
|
||||
super("DELIVERY_ALREADY_CLAIMED");
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimTopupDelivery(claim: () => Promise<{ count: number }>): Promise<void> {
|
||||
const result = await claim();
|
||||
if (result.count !== 1) throw new TopupDeliveryError();
|
||||
export async function claimTopupDelivery(
|
||||
claim: () => Promise<{ count: number }>,
|
||||
): Promise<void> {
|
||||
const result = await claim();
|
||||
if (result.count !== 1) throw new TopupDeliveryError();
|
||||
}
|
||||
|
||||
export async function authorizeTopupCapture(
|
||||
userId: number,
|
||||
orderId: string,
|
||||
findOrder: FindTopupOrder,
|
||||
userId: number,
|
||||
orderId: string,
|
||||
findOrder: FindTopupOrder,
|
||||
): Promise<AuthorizedTopup> {
|
||||
const order = await findOrder(orderId);
|
||||
if (!order || order.userId !== userId) {
|
||||
throw new TopupCaptureError("ORDER_NOT_FOUND");
|
||||
}
|
||||
if (order.status === "CAPTURED_PENDING_CREDIT" && Number.isFinite(order.amount)) {
|
||||
return { ...order, action: "DELIVER_CREDITS" };
|
||||
}
|
||||
if (order.status !== "CREATED") {
|
||||
throw new TopupCaptureError("ORDER_ALREADY_PROCESSED");
|
||||
}
|
||||
return { ...order, action: "CAPTURE" };
|
||||
const order = await findOrder(orderId);
|
||||
if (!order || order.userId !== userId) {
|
||||
throw new TopupCaptureError("ORDER_NOT_FOUND");
|
||||
}
|
||||
if (
|
||||
order.status === "CAPTURED_PENDING_CREDIT" &&
|
||||
Number.isFinite(order.amount)
|
||||
) {
|
||||
return { ...order, action: "DELIVER_CREDITS" };
|
||||
}
|
||||
if (order.status !== "CREATED") {
|
||||
throw new TopupCaptureError("ORDER_ALREADY_PROCESSED");
|
||||
}
|
||||
return { ...order, action: "CAPTURE" };
|
||||
}
|
||||
|
||||
export async function recordCreatedTopup(
|
||||
topup: {
|
||||
userId: number;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
credits: number;
|
||||
},
|
||||
createRecord: CreateTopupRecord,
|
||||
topup: {
|
||||
userId: number;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
credits: number;
|
||||
},
|
||||
createRecord: CreateTopupRecord,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await createRecord({
|
||||
userId: topup.userId,
|
||||
transactionId: topup.orderId,
|
||||
status: "CREATED",
|
||||
description: `Top-up: ${topup.credits} credits`,
|
||||
amount: topup.amount,
|
||||
currency: topup.currency,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const now = new Date();
|
||||
await createRecord({
|
||||
userId: topup.userId,
|
||||
transactionId: topup.orderId,
|
||||
status: "CREATED",
|
||||
description: `Top-up: ${topup.credits} credits`,
|
||||
amount: topup.amount,
|
||||
currency: topup.currency,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
+129
-111
@@ -11,57 +11,66 @@
|
||||
// carries `credits`, the in-game wallet), so a top-up credits the buyer's
|
||||
// `credits` wallet via sendCurrency(), exactly like the voucher flow.
|
||||
|
||||
export const PAYPAL_API = process.env.PAYPAL_API?.replace(/\/+$/, "") ?? "https://api-m.sandbox.paypal.com";
|
||||
export const PAYPAL_API =
|
||||
process.env.PAYPAL_API?.replace(/\/+$/, "") ??
|
||||
"https://api-m.sandbox.paypal.com";
|
||||
|
||||
export const PAYPAL_CURRENCY = (process.env.PAYPAL_CURRENCY ?? "USD").toUpperCase();
|
||||
export const PAYPAL_CURRENCY = (
|
||||
process.env.PAYPAL_CURRENCY ?? "USD"
|
||||
).toUpperCase();
|
||||
|
||||
/** Credits granted per 1.00 of the order currency (configurable, sane default). */
|
||||
export function creditsPerUnit(): number {
|
||||
const n = Number(process.env.PAYPAL_CREDITS_PER_USD ?? "100");
|
||||
return Number.isFinite(n) && n > 0 ? n : 100;
|
||||
const n = Number(process.env.PAYPAL_CREDITS_PER_USD ?? "100");
|
||||
return Number.isFinite(n) && n > 0 ? n : 100;
|
||||
}
|
||||
|
||||
export class PayPalConfigError extends Error {}
|
||||
|
||||
function credentials(): { clientId: string; secret: string } {
|
||||
const clientId = process.env.PAYPAL_CLIENT_ID;
|
||||
const secret = process.env.PAYPAL_SECRET;
|
||||
if (!clientId || !secret) {
|
||||
throw new PayPalConfigError("PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.");
|
||||
}
|
||||
return { clientId, secret };
|
||||
const clientId = process.env.PAYPAL_CLIENT_ID;
|
||||
const secret = process.env.PAYPAL_SECRET;
|
||||
if (!clientId || !secret) {
|
||||
throw new PayPalConfigError(
|
||||
"PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.",
|
||||
);
|
||||
}
|
||||
return { clientId, secret };
|
||||
}
|
||||
|
||||
/** True when both PayPal credentials are present. */
|
||||
export function isPayPalConfigured(): boolean {
|
||||
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET);
|
||||
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET);
|
||||
}
|
||||
|
||||
/** OAuth2 client-credentials token (short-lived; we fetch one per request). */
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const { clientId, secret } = credentials();
|
||||
const basic = Buffer.from(`${clientId}:${secret}`).toString("base64");
|
||||
const res = await fetch(`${PAYPAL_API}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal auth failed (${res.status}): ${body.slice(0, 300)}`);
|
||||
}
|
||||
const json = (await res.json()) as { access_token?: string };
|
||||
if (!json.access_token) throw new Error("PayPal auth returned no access_token.");
|
||||
return json.access_token;
|
||||
const { clientId, secret } = credentials();
|
||||
const basic = Buffer.from(`${clientId}:${secret}`).toString("base64");
|
||||
const res = await fetch(`${PAYPAL_API}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`PayPal auth failed (${res.status}): ${body.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as { access_token?: string };
|
||||
if (!json.access_token)
|
||||
throw new Error("PayPal auth returned no access_token.");
|
||||
return json.access_token;
|
||||
}
|
||||
|
||||
export interface CreatedOrder {
|
||||
id: string;
|
||||
approveUrl: string | null;
|
||||
id: string;
|
||||
approveUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,99 +78,108 @@ export interface CreatedOrder {
|
||||
* order id and the payer approval URL (rel === "approve") to redirect to.
|
||||
*/
|
||||
export async function createOrder(
|
||||
amount: number,
|
||||
opts: { description?: string; returnUrl?: string; cancelUrl?: string } = {},
|
||||
amount: number,
|
||||
opts: { description?: string; returnUrl?: string; cancelUrl?: string } = {},
|
||||
): Promise<CreatedOrder> {
|
||||
const token = await getAccessToken();
|
||||
const value = amount.toFixed(2);
|
||||
const token = await getAccessToken();
|
||||
const value = amount.toFixed(2);
|
||||
|
||||
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
intent: "CAPTURE",
|
||||
purchase_units: [
|
||||
{
|
||||
amount: { currency_code: PAYPAL_CURRENCY, value },
|
||||
description: opts.description?.slice(0, 127),
|
||||
},
|
||||
],
|
||||
application_context: {
|
||||
shipping_preference: "NO_SHIPPING",
|
||||
user_action: "PAY_NOW",
|
||||
...(opts.returnUrl ? { return_url: opts.returnUrl } : {}),
|
||||
...(opts.cancelUrl ? { cancel_url: opts.cancelUrl } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
intent: "CAPTURE",
|
||||
purchase_units: [
|
||||
{
|
||||
amount: { currency_code: PAYPAL_CURRENCY, value },
|
||||
description: opts.description?.slice(0, 127),
|
||||
},
|
||||
],
|
||||
application_context: {
|
||||
shipping_preference: "NO_SHIPPING",
|
||||
user_action: "PAY_NOW",
|
||||
...(opts.returnUrl ? { return_url: opts.returnUrl } : {}),
|
||||
...(opts.cancelUrl ? { cancel_url: opts.cancelUrl } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal create order failed (${res.status}): ${body.slice(0, 300)}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`PayPal create order failed (${res.status}): ${body.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
id: string;
|
||||
links?: { rel: string; href: string }[];
|
||||
};
|
||||
const approveUrl = json.links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href ?? null;
|
||||
return { id: json.id, approveUrl };
|
||||
const json = (await res.json()) as {
|
||||
id: string;
|
||||
links?: { rel: string; href: string }[];
|
||||
};
|
||||
const approveUrl =
|
||||
json.links?.find((l) => l.rel === "approve" || l.rel === "payer-action")
|
||||
?.href ?? null;
|
||||
return { id: json.id, approveUrl };
|
||||
}
|
||||
|
||||
export interface CaptureResult {
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
captureId: string | null;
|
||||
payerEmail: string | null;
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
captureId: string | null;
|
||||
payerEmail: string | null;
|
||||
}
|
||||
|
||||
/** Capture a previously-approved order id. */
|
||||
export async function captureOrder(orderId: string): Promise<CaptureResult> {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(
|
||||
`${PAYPAL_API}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal capture failed (${res.status}): ${body.slice(0, 300)}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`PayPal capture failed (${res.status}): ${body.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
id: string;
|
||||
status: string;
|
||||
payer?: { email_address?: string };
|
||||
purchase_units?: {
|
||||
payments?: {
|
||||
captures?: {
|
||||
id: string;
|
||||
amount?: { value?: string; currency_code?: string };
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
const json = (await res.json()) as {
|
||||
id: string;
|
||||
status: string;
|
||||
payer?: { email_address?: string };
|
||||
purchase_units?: {
|
||||
payments?: {
|
||||
captures?: {
|
||||
id: string;
|
||||
amount?: { value?: string; currency_code?: string };
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
const capture = json.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
const amount = capture?.amount?.value ? Number(capture.amount.value) : 0;
|
||||
const currency = capture?.amount?.currency_code ?? PAYPAL_CURRENCY;
|
||||
const capture = json.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
const amount = capture?.amount?.value ? Number(capture.amount.value) : 0;
|
||||
const currency = capture?.amount?.currency_code ?? PAYPAL_CURRENCY;
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
status: json.status,
|
||||
amount,
|
||||
currency,
|
||||
captureId: capture?.id ?? null,
|
||||
payerEmail: json.payer?.email_address ?? null,
|
||||
};
|
||||
return {
|
||||
id: json.id,
|
||||
status: json.status,
|
||||
amount,
|
||||
currency,
|
||||
captureId: capture?.id ?? null,
|
||||
payerEmail: json.payer?.email_address ?? null,
|
||||
};
|
||||
}
|
||||
@@ -3,234 +3,249 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
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");
|
||||
}
|
||||
return `rank_${rankId}`;
|
||||
if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) {
|
||||
throw new Error("Invalid rank id");
|
||||
}
|
||||
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 (RANK_GENERAL_FIELDS.has(key)) {
|
||||
rankFields[key] = value;
|
||||
} else {
|
||||
permissionFields[key] = toPermissionValue(value);
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (key === "id" || !VALID_KEY.test(key)) continue;
|
||||
if (RANK_GENERAL_FIELDS.has(key)) {
|
||||
rankFields[key] = value;
|
||||
} else {
|
||||
permissionFields[key] = toPermissionValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return { rankFields, permissionFields };
|
||||
return { rankFields, permissionFields };
|
||||
}
|
||||
|
||||
export async function fetchEmulatorRankSummaries(db: RawDb): Promise<EmulatorRankSummary[]> {
|
||||
const rows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||
Prisma.sql`
|
||||
export async function fetchEmulatorRankSummaries(
|
||||
db: RawDb,
|
||||
): Promise<EmulatorRankSummary[]> {
|
||||
const rows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||
Prisma.sql`
|
||||
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank
|
||||
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 ?? ""),
|
||||
level: Number(row.level ?? 1),
|
||||
prefix: String(row.prefix ?? ""),
|
||||
prefix_color: String(row.prefix_color ?? ""),
|
||||
hidden_rank: String(row.hidden_rank ?? "0"),
|
||||
}));
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
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"),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchEmulatorRankForEdit(
|
||||
db: RawDb,
|
||||
rankId: number,
|
||||
db: RawDb,
|
||||
rankId: number,
|
||||
): Promise<EmulatorRankForEdit | null> {
|
||||
const rankRows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||
Prisma.sql`
|
||||
const rankRows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||
Prisma.sql`
|
||||
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
|
||||
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
|
||||
auto_gotw_amount, auto_points_amount
|
||||
FROM permission_ranks
|
||||
WHERE id = ${rankId}
|
||||
`,
|
||||
);
|
||||
if (rankRows.length === 0) return null;
|
||||
);
|
||||
if (rankRows.length === 0) return null;
|
||||
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const permissionRows = await db.$queryRaw<
|
||||
{ permission_key: string; max_value: number; value: number | string | null }[]
|
||||
>(
|
||||
Prisma.sql`
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const permissionRows = await db.$queryRaw<
|
||||
{
|
||||
permission_key: string;
|
||||
max_value: number;
|
||||
value: number | string | null;
|
||||
}[]
|
||||
>(
|
||||
Prisma.sql`
|
||||
SELECT permission_key, max_value, ${Prisma.raw(quoteIdentifier(rankColumn))} AS value
|
||||
FROM permission_definitions
|
||||
ORDER BY permission_key ASC
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
const row = rankRows[0];
|
||||
return {
|
||||
id: Number(row.id),
|
||||
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"),
|
||||
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),
|
||||
auto_gotw_amount: Number(row.auto_gotw_amount ?? 0),
|
||||
auto_points_amount: Number(row.auto_points_amount ?? 0),
|
||||
permissions,
|
||||
permissionMaxValues,
|
||||
};
|
||||
const row = rankRows[0];
|
||||
return {
|
||||
id: Number(row.id),
|
||||
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"),
|
||||
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),
|
||||
auto_gotw_amount: Number(row.auto_gotw_amount ?? 0),
|
||||
auto_points_amount: Number(row.auto_points_amount ?? 0),
|
||||
permissions,
|
||||
permissionMaxValues,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createEmulatorRank(
|
||||
db: RawDb,
|
||||
data: { rank_name: string; level: number },
|
||||
db: RawDb,
|
||||
data: { rank_name: string; level: number },
|
||||
): 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 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);
|
||||
|
||||
await db.$transaction([
|
||||
db.$executeRaw(
|
||||
Prisma.sql`
|
||||
await db.$transaction([
|
||||
db.$executeRaw(
|
||||
Prisma.sql`
|
||||
INSERT INTO permission_ranks (id, rank_name, level)
|
||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||
`,
|
||||
),
|
||||
db.$executeRaw(
|
||||
Prisma.sql`
|
||||
),
|
||||
db.$executeRaw(
|
||||
Prisma.sql`
|
||||
INSERT INTO permissions (id, rank_name, level)
|
||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||
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 db.$transaction([
|
||||
db.$executeRaw(Prisma.sql`DELETE FROM permission_ranks WHERE id = ${rankId}`),
|
||||
db.$executeRaw(Prisma.sql`DELETE FROM permissions WHERE id = ${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);
|
||||
}
|
||||
if (Object.keys(rankFields).length > 0) {
|
||||
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);
|
||||
}
|
||||
if (Object.keys(permissionFields).length > 0) {
|
||||
await updatePermissionDefinitionValues(db, rankId, permissionFields);
|
||||
await updateLegacyPermissionFields(db, rankId, permissionFields);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTableFields(
|
||||
db: RawDb,
|
||||
table: string,
|
||||
rankId: number,
|
||||
fields: Record<string, string | number>,
|
||||
db: RawDb,
|
||||
table: string,
|
||||
rankId: number,
|
||||
fields: Record<string, string | number>,
|
||||
) {
|
||||
const assignments = Object.entries(fields).map(
|
||||
([key, value]) => Prisma.sql`${Prisma.raw(quoteIdentifier(key))} = ${value}`,
|
||||
);
|
||||
return db.$executeRaw(
|
||||
Prisma.sql`
|
||||
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, ", ")}
|
||||
WHERE id = ${rankId}
|
||||
`,
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
async function updatePermissionDefinitionValues(
|
||||
db: RawDb,
|
||||
rankId: number,
|
||||
permissionFields: Record<string, number>,
|
||||
db: RawDb,
|
||||
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`
|
||||
return db.$executeRaw(
|
||||
Prisma.sql`
|
||||
UPDATE permission_definitions
|
||||
SET ${Prisma.raw(quoteIdentifier(rankColumn))} =
|
||||
CASE permission_key
|
||||
@@ -239,64 +254,66 @@ async function updatePermissionDefinitionValues(
|
||||
END
|
||||
WHERE permission_key IN (${Prisma.join(keys)})
|
||||
`,
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
async function updateLegacyPermissionFields(
|
||||
db: RawDb,
|
||||
rankId: number,
|
||||
fields: Record<string, string | number>,
|
||||
db: RawDb,
|
||||
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 exists = await db.$queryRaw<{ c: bigint | number }[]>(
|
||||
Prisma.sql`
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const exists = await db.$queryRaw<{ c: bigint | number }[]>(
|
||||
Prisma.sql`
|
||||
SELECT COUNT(*) AS c
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
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`
|
||||
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);
|
||||
await db.$executeRaw(
|
||||
Prisma.sql`
|
||||
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) {
|
||||
const rows = await db.$queryRaw<{ column_name: string }[]>(
|
||||
Prisma.sql`
|
||||
const rows = await db.$queryRaw<{ column_name: string }[]>(
|
||||
Prisma.sql`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
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}\``;
|
||||
}
|
||||
+298
-254
@@ -5,38 +5,44 @@ 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).
|
||||
@@ -47,11 +53,11 @@ const DIR_PREFERENCE = [2, 0, 4, 3, 1, 7, 6, 5];
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
for (const dir of PET_DIRS) {
|
||||
const p = path.join(/*turbopackIgnore: true*/ dir, `${lib}.nitro`);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,67 +68,84 @@ 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);
|
||||
if (!viz || !viz.layerCount) {
|
||||
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;
|
||||
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 });
|
||||
}
|
||||
// No idle animation defined anywhere → render every layer's static frame 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 }));
|
||||
const viz = json.visualizations?.find((v) => v.size === 64);
|
||||
if (!viz?.layerCount) {
|
||||
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;
|
||||
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 });
|
||||
}
|
||||
// No idle animation defined anywhere → render every layer's static frame 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 }));
|
||||
}
|
||||
|
||||
/** Resolve a layer asset to its spritesheet rect + placement offset. */
|
||||
function resolveSprite(
|
||||
json: NitroJson,
|
||||
lib: string,
|
||||
layer: string,
|
||||
dir: number,
|
||||
frameId: number,
|
||||
json: NitroJson,
|
||||
lib: string,
|
||||
layer: string,
|
||||
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;
|
||||
// `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 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 };
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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;
|
||||
if (sa === 255) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
if (sa === 255) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Real per-breed pet colour is applied at runtime (the .nitro stores white
|
||||
@@ -131,60 +154,63 @@ function blit(dst: Buffer, dstW: number, src: Buffer, w: number, h: number, dx:
|
||||
// per breed (an estimate, not the exact in-game value). Naturally-coloured
|
||||
// pixels (white dog, brown horse, green plant) are left untouched.
|
||||
const DEFAULT_PET_COLOR: Record<string, [number, number, number]> = {
|
||||
lion: [216, 166, 72],
|
||||
haloompa: [216, 166, 72],
|
||||
pig: [240, 168, 178],
|
||||
pigletbaby: [240, 168, 178],
|
||||
bunnylove: [240, 168, 190],
|
||||
dog: [196, 160, 120],
|
||||
terrier: [196, 160, 120],
|
||||
terrierbaby: [196, 160, 120],
|
||||
puppybaby: [196, 160, 120],
|
||||
bunnydepressed: [196, 160, 120],
|
||||
gnome: [196, 160, 120],
|
||||
fools: [196, 160, 120],
|
||||
cat: [156, 152, 158],
|
||||
kittenbaby: [156, 152, 158],
|
||||
monkey: [150, 108, 74],
|
||||
demonmonkey: [150, 108, 74],
|
||||
bear: [150, 112, 80],
|
||||
bearbaby: [150, 112, 80],
|
||||
horse: [162, 122, 86],
|
||||
rhino: [150, 150, 158],
|
||||
elephants: [150, 150, 158],
|
||||
spider: [96, 84, 96],
|
||||
bunnyevil: [120, 120, 128],
|
||||
pigeonevil: [120, 120, 130],
|
||||
pigeongood: [176, 176, 182],
|
||||
bunnyeaster: [220, 214, 214],
|
||||
cow: [206, 186, 162],
|
||||
chicken: [238, 226, 176],
|
||||
frog: [120, 184, 92],
|
||||
dragon: [120, 172, 110],
|
||||
croco: [128, 176, 108],
|
||||
turtle: [130, 172, 120],
|
||||
monster: [128, 176, 100],
|
||||
monsterplant: [120, 170, 96],
|
||||
pterosaur: [128, 176, 108],
|
||||
velociraptor: [128, 176, 108],
|
||||
lion: [216, 166, 72],
|
||||
haloompa: [216, 166, 72],
|
||||
pig: [240, 168, 178],
|
||||
pigletbaby: [240, 168, 178],
|
||||
bunnylove: [240, 168, 190],
|
||||
dog: [196, 160, 120],
|
||||
terrier: [196, 160, 120],
|
||||
terrierbaby: [196, 160, 120],
|
||||
puppybaby: [196, 160, 120],
|
||||
bunnydepressed: [196, 160, 120],
|
||||
gnome: [196, 160, 120],
|
||||
fools: [196, 160, 120],
|
||||
cat: [156, 152, 158],
|
||||
kittenbaby: [156, 152, 158],
|
||||
monkey: [150, 108, 74],
|
||||
demonmonkey: [150, 108, 74],
|
||||
bear: [150, 112, 80],
|
||||
bearbaby: [150, 112, 80],
|
||||
horse: [162, 122, 86],
|
||||
rhino: [150, 150, 158],
|
||||
elephants: [150, 150, 158],
|
||||
spider: [96, 84, 96],
|
||||
bunnyevil: [120, 120, 128],
|
||||
pigeonevil: [120, 120, 130],
|
||||
pigeongood: [176, 176, 182],
|
||||
bunnyeaster: [220, 214, 214],
|
||||
cow: [206, 186, 162],
|
||||
chicken: [238, 226, 176],
|
||||
frog: [120, 184, 92],
|
||||
dragon: [120, 172, 110],
|
||||
croco: [128, 176, 108],
|
||||
turtle: [130, 172, 120],
|
||||
monster: [128, 176, 100],
|
||||
monsterplant: [120, 170, 96],
|
||||
pterosaur: [128, 176, 108],
|
||||
velociraptor: [128, 176, 108],
|
||||
};
|
||||
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];
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
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];
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,122 +219,138 @@ 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 }[] = [];
|
||||
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;
|
||||
while (stack.length) {
|
||||
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;
|
||||
if (label[np] === -1 && opaque(np)) {
|
||||
label[np] = id;
|
||||
stack.push(np);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
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;
|
||||
while (stack.length) {
|
||||
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;
|
||||
if (label[np] === -1 && opaque(np)) {
|
||||
label[np] = id;
|
||||
stack.push(np);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
// Tight-crop bbox of the main blob.
|
||||
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;
|
||||
}
|
||||
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);
|
||||
if (label[sp] === main.id) {
|
||||
const o = (y * nw + x) * 4;
|
||||
const si = sp * 4;
|
||||
rgba.copy(out, o, si, si + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rgba: out, w: nw, h: nh };
|
||||
// Tight-crop bbox of the main blob.
|
||||
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;
|
||||
}
|
||||
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);
|
||||
if (label[sp] === main.id) {
|
||||
const o = (y * nw + x) * 4;
|
||||
const si = sp * 4;
|
||||
rgba.copy(out, o, si, si + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
function composite(
|
||||
json: NitroJson,
|
||||
sheet: { width: number; rgba: Buffer },
|
||||
): Buffer | null {
|
||||
const lib = (json.name ?? "").toString();
|
||||
if (!lib) return null;
|
||||
|
||||
type Sprite = { rect: Rect; ox: number; oy: number };
|
||||
const bestDir = (specs: LayerSpec[]): 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);
|
||||
if (resolved.length > best.length) {
|
||||
best = resolved;
|
||||
if (dir === DIR_PREFERENCE[0]) break; // front pose is good enough
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
type Sprite = { rect: Rect; ox: number; oy: number };
|
||||
const bestDir = (specs: LayerSpec[]): 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);
|
||||
if (resolved.length > best.length) {
|
||||
best = resolved;
|
||||
if (dir === DIR_PREFERENCE[0]) break; // front pose is good enough
|
||||
}
|
||||
}
|
||||
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));
|
||||
if (sprites.length === 0) {
|
||||
sprites = bestDir(["a", "b", "c", "d"].map((letter) => ({ letter, frameId: 0 })));
|
||||
}
|
||||
if (sprites.length === 0) return null;
|
||||
// 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));
|
||||
if (sprites.length === 0) {
|
||||
sprites = bestDir(
|
||||
["a", "b", "c", "d"].map((letter) => ({ letter, frameId: 0 })),
|
||||
);
|
||||
}
|
||||
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;
|
||||
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);
|
||||
}
|
||||
const cw = maxX - minX;
|
||||
const ch = maxY - minY;
|
||||
if (cw <= 0 || ch <= 0 || cw > 256 || ch > 256) return null;
|
||||
// Union bounding box in pet-local coords.
|
||||
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);
|
||||
}
|
||||
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
|
||||
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);
|
||||
}
|
||||
recolorMask(canvas, DEFAULT_PET_COLOR[lib] ?? FALLBACK_PET_COLOR);
|
||||
const blob = keepLargestBlob(canvas, cw, ch);
|
||||
return encodePng(blob.w, blob.h, blob.rgba);
|
||||
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);
|
||||
}
|
||||
recolorMask(canvas, DEFAULT_PET_COLOR[lib] ?? FALLBACK_PET_COLOR);
|
||||
const blob = keepLargestBlob(canvas, cw, ch);
|
||||
return encodePng(blob.w, blob.h, blob.rgba);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,23 +358,25 @@ 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);
|
||||
if (!nitroPath) {
|
||||
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;
|
||||
} catch {
|
||||
iconCache.set(lib, null);
|
||||
return null;
|
||||
}
|
||||
const nitroPath = findNitro(lib);
|
||||
if (!nitroPath) {
|
||||
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;
|
||||
} catch {
|
||||
iconCache.set(lib, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,46 +2,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/services/import/core/download", () => ({
|
||||
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
|
||||
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
|
||||
}));
|
||||
vi.mock("@/lib/services/pet-source", () => ({
|
||||
CANONICAL_PETS: ["dog", "cat"],
|
||||
resolvePetSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
|
||||
CANONICAL_PETS: ["dog", "cat"],
|
||||
resolvePetSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
|
||||
}));
|
||||
const convertSwfToNitro = vi.fn(() => ({
|
||||
bundle: Buffer.from("nitro"),
|
||||
warnings: [],
|
||||
}));
|
||||
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>)),
|
||||
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")),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
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")),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
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 () => {
|
||||
convertSwfToNitro.mockImplementationOnce(() => {
|
||||
throw new Error("bad swf");
|
||||
});
|
||||
const r = await importSinglePet({ lib: "cat" });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toContain("conversion");
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -5,98 +5,125 @@ 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",
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/nitro-assets/bundled/pet",
|
||||
);
|
||||
export const PET_SWF_DIR = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"public/swf/gordon",
|
||||
);
|
||||
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 })),
|
||||
);
|
||||
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;
|
||||
try {
|
||||
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}` };
|
||||
onProgress?.("downloading");
|
||||
const swfPath = path.join(
|
||||
/*turbopackIgnore: true*/ PET_SWF_DIR,
|
||||
`${lib}.swf`,
|
||||
);
|
||||
let swfUrl: string;
|
||||
try {
|
||||
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}`,
|
||||
};
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
lib,
|
||||
warnings,
|
||||
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
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);
|
||||
} 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);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
return { deletedFile: true };
|
||||
}
|
||||
return { deletedFile: false };
|
||||
export async function deleteImportedPet(
|
||||
lib: string,
|
||||
): Promise<{ deletedFile: boolean }> {
|
||||
const p = nitroPathFor(lib);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
return { deletedFile: true };
|
||||
}
|
||||
return { deletedFile: false };
|
||||
}
|
||||
|
||||
export function getPetList(search: string): PetListItem[] {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -6,47 +6,49 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
* 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`;
|
||||
}
|
||||
+85
-67
@@ -9,88 +9,106 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Block SSRF — only allow http/https to public IPs (no private/loopback/link-local).
|
||||
const PRIVATE_IP_RE =
|
||||
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|::1|fe80:|fc00:|fd00:|localhost)/i;
|
||||
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|::1|fe80:|fc00:|fd00:|localhost)/i;
|
||||
|
||||
function isSafeUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||
if (PRIVATE_IP_RE.test(u.hostname)) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||
if (PRIVATE_IP_RE.test(u.hostname)) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NowPlaying {
|
||||
title: string;
|
||||
artist: string | null;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, ms = 4000): Promise<unknown> {
|
||||
if (!isSafeUrl(url)) return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal, cache: "no-store" });
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text; // plain-text "Artist - Title" (Shoutcast/Icecast metadata)
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!isSafeUrl(url)) return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text; // plain-text "Artist - Title" (Shoutcast/Icecast metadata)
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNowPlaying(d: unknown): NowPlaying | null {
|
||||
if (!d) return null;
|
||||
// AzureCast: { now_playing: { song: { title, artist } } }
|
||||
const azure = (d as { now_playing?: { song?: { title?: string; artist?: string; text?: string } } })
|
||||
.now_playing?.song;
|
||||
if (azure?.title || azure?.text) {
|
||||
return { title: azure.title ?? azure.text ?? "", artist: azure.artist ?? null };
|
||||
}
|
||||
// Generic JSON: { title, artist } or { songtitle }
|
||||
const generic = d as { title?: string; artist?: string; songtitle?: string };
|
||||
if (generic.title) return { title: generic.title, artist: generic.artist ?? null };
|
||||
if (generic.songtitle) {
|
||||
const [a, t] = generic.songtitle.split(" - ");
|
||||
return t ? { title: t.trim(), artist: a.trim() } : { title: generic.songtitle, artist: null };
|
||||
}
|
||||
// Plain text "Artist - Title"
|
||||
if (typeof d === "string" && d.trim()) {
|
||||
const parts = d.split(" - ");
|
||||
return parts.length > 1
|
||||
? { title: parts.slice(1).join(" - ").trim(), artist: parts[0].trim() }
|
||||
: { title: d.trim(), artist: null };
|
||||
}
|
||||
return null;
|
||||
if (!d) return null;
|
||||
// AzureCast: { now_playing: { song: { title, artist } } }
|
||||
const azure = (
|
||||
d as {
|
||||
now_playing?: {
|
||||
song?: { title?: string; artist?: string; text?: string };
|
||||
};
|
||||
}
|
||||
).now_playing?.song;
|
||||
if (azure?.title || azure?.text) {
|
||||
return {
|
||||
title: azure.title ?? azure.text ?? "",
|
||||
artist: azure.artist ?? null,
|
||||
};
|
||||
}
|
||||
// Generic JSON: { title, artist } or { songtitle }
|
||||
const generic = d as { title?: string; artist?: string; songtitle?: string };
|
||||
if (generic.title)
|
||||
return { title: generic.title, artist: generic.artist ?? null };
|
||||
if (generic.songtitle) {
|
||||
const [a, t] = generic.songtitle.split(" - ");
|
||||
return t
|
||||
? { title: t.trim(), artist: a.trim() }
|
||||
: { title: generic.songtitle, artist: null };
|
||||
}
|
||||
// Plain text "Artist - Title"
|
||||
if (typeof d === "string" && d.trim()) {
|
||||
const parts = d.split(" - ");
|
||||
return parts.length > 1
|
||||
? { title: parts.slice(1).join(" - ").trim(), artist: parts[0].trim() }
|
||||
: { title: d.trim(), artist: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchNowPlaying(): Promise<NowPlaying | null> {
|
||||
const url = (await siteSettings.get("radio_now_playing_api_url", "")) ?? "";
|
||||
if (!url) return null;
|
||||
return parseNowPlaying(await fetchJson(url));
|
||||
const url = (await siteSettings.get("radio_now_playing_api_url", "")) ?? "";
|
||||
if (!url) return null;
|
||||
return parseNowPlaying(await fetchJson(url));
|
||||
}
|
||||
|
||||
export async function fetchListeners(): Promise<number | null> {
|
||||
const url = (await siteSettings.get("radio_listeners_api_url", "")) ?? "";
|
||||
if (!url) return null;
|
||||
const d = await fetchJson(url);
|
||||
if (d == null) return null;
|
||||
const obj = d as { listeners?: unknown; current_listeners?: unknown };
|
||||
const raw =
|
||||
typeof d === "number"
|
||||
? d
|
||||
: (obj.listeners ?? obj.current_listeners ?? (typeof d === "string" ? Number(d) : null));
|
||||
const n = Number(
|
||||
typeof raw === "object" && raw && "total" in (raw as Record<string, unknown>)
|
||||
? (raw as { total: unknown }).total
|
||||
: raw,
|
||||
);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
const url = (await siteSettings.get("radio_listeners_api_url", "")) ?? "";
|
||||
if (!url) return null;
|
||||
const d = await fetchJson(url);
|
||||
if (d == null) return null;
|
||||
const obj = d as { listeners?: unknown; current_listeners?: unknown };
|
||||
const raw =
|
||||
typeof d === "number"
|
||||
? d
|
||||
: (obj.listeners ??
|
||||
obj.current_listeners ??
|
||||
(typeof d === "string" ? Number(d) : null));
|
||||
const n = Number(
|
||||
typeof raw === "object" &&
|
||||
raw &&
|
||||
"total" in (raw as Record<string, unknown>)
|
||||
? (raw as { total: unknown }).total
|
||||
: raw,
|
||||
);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
@@ -3,65 +3,71 @@ import { CurrencyType } from "./currency";
|
||||
import { RconClient, type RconPayload } from "./rcon";
|
||||
|
||||
function spyClient() {
|
||||
const sent: RconPayload[] = [];
|
||||
const client = new RconClient(async (p) => {
|
||||
sent.push(p);
|
||||
return true;
|
||||
});
|
||||
return { client, sent };
|
||||
const sent: RconPayload[] = [];
|
||||
const client = new RconClient(async (p) => {
|
||||
sent.push(p);
|
||||
return true;
|
||||
});
|
||||
return { client, sent };
|
||||
}
|
||||
|
||||
describe("RconClient payloads (AtomCMS-faithful shapes)", () => {
|
||||
it("givecredits", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.giveCredits(7, 100);
|
||||
expect(sent[0]).toEqual({ key: "givecredits", data: { user_id: 7, credits: 100 } });
|
||||
});
|
||||
it("givecredits", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.giveCredits(7, 100);
|
||||
expect(sent[0]).toEqual({
|
||||
key: "givecredits",
|
||||
data: { user_id: 7, credits: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
it("givepoints carries the numeric currency type", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.giveDiamonds(7, 3);
|
||||
expect(sent[0]).toEqual({
|
||||
key: "givepoints",
|
||||
data: { user_id: 7, points: 3, type: CurrencyType.Diamonds },
|
||||
});
|
||||
expect(JSON.parse(JSON.stringify(sent[0])).data.type).toBe(5);
|
||||
});
|
||||
it("givepoints carries the numeric currency type", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.giveDiamonds(7, 3);
|
||||
expect(sent[0]).toEqual({
|
||||
key: "givepoints",
|
||||
data: { user_id: 7, points: 3, type: CurrencyType.Diamonds },
|
||||
});
|
||||
expect(JSON.parse(JSON.stringify(sent[0])).data.type).toBe(5);
|
||||
});
|
||||
|
||||
it("sendgift uses 'itemid' (not item_id)", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.sendGift(7, 42);
|
||||
expect(sent[0]).toEqual({
|
||||
key: "sendgift",
|
||||
data: { user_id: 7, itemid: 42, message: "Here is a gift." },
|
||||
});
|
||||
});
|
||||
it("sendgift uses 'itemid' (not item_id)", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.sendGift(7, 42);
|
||||
expect(sent[0]).toEqual({
|
||||
key: "sendgift",
|
||||
data: { user_id: 7, itemid: 42, message: "Here is a gift." },
|
||||
});
|
||||
});
|
||||
|
||||
it("disconnect carries username", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.disconnectUser(7, "alice");
|
||||
expect(sent[0]).toEqual({ key: "disconnect", data: { user_id: 7, username: "alice" } });
|
||||
});
|
||||
it("disconnect carries username", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.disconnectUser(7, "alice");
|
||||
expect(sent[0]).toEqual({
|
||||
key: "disconnect",
|
||||
data: { user_id: 7, username: "alice" },
|
||||
});
|
||||
});
|
||||
|
||||
it("no-data commands send data:null", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.updateWordFilter();
|
||||
await client.updateCatalog();
|
||||
expect(sent[0]).toEqual({ key: "updatewordfilter", data: null });
|
||||
expect(sent[1]).toEqual({ key: "updatecatalog", data: null });
|
||||
});
|
||||
it("no-data commands send data:null", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.updateWordFilter();
|
||||
await client.updateCatalog();
|
||||
expect(sent[0]).toEqual({ key: "updatewordfilter", data: null });
|
||||
expect(sent[1]).toEqual({ key: "updatecatalog", data: null });
|
||||
});
|
||||
|
||||
it("setmotto / setrank / forwarduser / alertuser shapes", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.setMotto(1, "hi");
|
||||
await client.setRank(1, 7);
|
||||
await client.forwardUser(1, 99);
|
||||
await client.alertUser(1, "yo");
|
||||
expect(sent).toEqual([
|
||||
{ key: "setmotto", data: { user_id: 1, motto: "hi" } },
|
||||
{ key: "setrank", data: { user_id: 1, rank: 7 } },
|
||||
{ key: "forwarduser", data: { user_id: 1, room_id: 99 } },
|
||||
{ key: "alertuser", data: { user_id: 1, message: "yo" } },
|
||||
]);
|
||||
});
|
||||
it("setmotto / setrank / forwarduser / alertuser shapes", async () => {
|
||||
const { client, sent } = spyClient();
|
||||
await client.setMotto(1, "hi");
|
||||
await client.setRank(1, 7);
|
||||
await client.forwardUser(1, 99);
|
||||
await client.alertUser(1, "yo");
|
||||
expect(sent).toEqual([
|
||||
{ key: "setmotto", data: { user_id: 1, motto: "hi" } },
|
||||
{ key: "setrank", data: { user_id: 1, rank: 7 } },
|
||||
{ key: "forwarduser", data: { user_id: 1, room_id: 99 } },
|
||||
{ key: "alertuser", data: { user_id: 1, message: "yo" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+123
-117
@@ -1,11 +1,11 @@
|
||||
import net from "node:net";
|
||||
import { CurrencyType } from "@/lib/services/currency";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { CurrencyType } from "@/lib/services/currency";
|
||||
|
||||
export interface RconPayload {
|
||||
key: string;
|
||||
data: Record<string, unknown> | null;
|
||||
key: string;
|
||||
data: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type RconTransport = (payload: RconPayload) => Promise<boolean>;
|
||||
@@ -17,40 +17,43 @@ export type RconTransport = (payload: RconPayload) => Promise<boolean>;
|
||||
* connect timeout + exponential-backoff retry so delivery is best-effort.
|
||||
*/
|
||||
export function tcpTransport(opts: {
|
||||
host: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
maxRetries: number;
|
||||
host: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
maxRetries: number;
|
||||
}): RconTransport {
|
||||
const sendOnce = (payload: RconPayload): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const json = JSON.stringify(payload);
|
||||
let settled = false;
|
||||
const finish = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), opts.timeoutMs);
|
||||
socket.once("error", () => finish(false));
|
||||
socket.connect(opts.port, opts.host, () => {
|
||||
socket.write(json, () => finish(true));
|
||||
});
|
||||
});
|
||||
const sendOnce = (payload: RconPayload): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const json = JSON.stringify(payload);
|
||||
let settled = false;
|
||||
const finish = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), opts.timeoutMs);
|
||||
socket.once("error", () => finish(false));
|
||||
socket.connect(opts.port, opts.host, () => {
|
||||
socket.write(json, () => finish(true));
|
||||
});
|
||||
});
|
||||
|
||||
return async (payload) => {
|
||||
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
|
||||
if (await sendOnce(payload)) return true;
|
||||
if (attempt < opts.maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
logger.error("RCON delivery failed after retries", { module: "rcon", key: payload.key });
|
||||
return false;
|
||||
};
|
||||
return async (payload) => {
|
||||
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
|
||||
if (await sendOnce(payload)) return true;
|
||||
if (attempt < opts.maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
logger.error("RCON delivery failed after retries", {
|
||||
module: "rcon",
|
||||
key: payload.key,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,94 +62,97 @@ export function tcpTransport(opts: {
|
||||
* send `data: null`, givepoints `type` is the numeric CurrencyType value).
|
||||
*/
|
||||
export class RconClient {
|
||||
constructor(private readonly transport: RconTransport) {}
|
||||
constructor(private readonly transport: RconTransport) {}
|
||||
|
||||
send(key: string, data: Record<string, unknown> | null = null): Promise<boolean> {
|
||||
return this.transport({ key, data });
|
||||
}
|
||||
send(
|
||||
key: string,
|
||||
data: Record<string, unknown> | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.transport({ key, data });
|
||||
}
|
||||
|
||||
giveCredits(userId: number, credits: number) {
|
||||
return this.send("givecredits", { user_id: userId, credits });
|
||||
}
|
||||
givePoints(userId: number, amount: number, type: CurrencyType) {
|
||||
return this.send("givepoints", { user_id: userId, points: amount, type });
|
||||
}
|
||||
giveDuckets(userId: number, amount: number) {
|
||||
return this.givePoints(userId, amount, CurrencyType.Duckets);
|
||||
}
|
||||
giveDiamonds(userId: number, amount: number) {
|
||||
return this.givePoints(userId, amount, CurrencyType.Diamonds);
|
||||
}
|
||||
givePointsGotw(userId: number, amount: number) {
|
||||
return this.givePoints(userId, amount, CurrencyType.Points);
|
||||
}
|
||||
giveBadge(userId: number, badge: string) {
|
||||
return this.send("givebadge", { user_id: userId, badge });
|
||||
}
|
||||
setMotto(userId: number, motto: string) {
|
||||
return this.send("setmotto", { user_id: userId, motto });
|
||||
}
|
||||
setRank(userId: number, rank: number) {
|
||||
return this.send("setrank", { user_id: userId, rank });
|
||||
}
|
||||
sendGift(userId: number, itemId: number, message = "Here is a gift.") {
|
||||
return this.send("sendgift", { user_id: userId, itemid: itemId, message });
|
||||
}
|
||||
disconnectUser(userId: number, username = "") {
|
||||
return this.send("disconnect", { user_id: userId, username });
|
||||
}
|
||||
alertUser(userId: number, message: string) {
|
||||
return this.send("alertuser", { user_id: userId, message });
|
||||
}
|
||||
forwardUser(userId: number, roomId: number) {
|
||||
return this.send("forwarduser", { user_id: userId, room_id: roomId });
|
||||
}
|
||||
kickAll(roomId: number) {
|
||||
return this.send("kickall", { room_id: roomId });
|
||||
}
|
||||
hotelAlert(message: string) {
|
||||
return this.send("hotelalert", { message });
|
||||
}
|
||||
staffAlert(message: string) {
|
||||
return this.send("staffalert", { message });
|
||||
}
|
||||
executeCommand(userId: number, command: string) {
|
||||
return this.send("executecommand", { user_id: userId, command });
|
||||
}
|
||||
updateWordFilter() {
|
||||
return this.send("updatewordfilter");
|
||||
}
|
||||
updateCatalog() {
|
||||
return this.send("updatecatalog");
|
||||
}
|
||||
updateItems() {
|
||||
return this.send("updateitems");
|
||||
}
|
||||
updateConfig() {
|
||||
return this.send("updateconfig");
|
||||
}
|
||||
removeBadge(userId: number, badge: string) {
|
||||
return this.send("removebadge", { user_id: userId, badge });
|
||||
}
|
||||
muteUser(userId: number, duration: number) {
|
||||
return this.send("muteuser", { user_id: userId, duration });
|
||||
}
|
||||
unmuteUser(userId: number) {
|
||||
return this.send("unmuteuser", { user_id: userId });
|
||||
}
|
||||
giveCredits(userId: number, credits: number) {
|
||||
return this.send("givecredits", { user_id: userId, credits });
|
||||
}
|
||||
givePoints(userId: number, amount: number, type: CurrencyType) {
|
||||
return this.send("givepoints", { user_id: userId, points: amount, type });
|
||||
}
|
||||
giveDuckets(userId: number, amount: number) {
|
||||
return this.givePoints(userId, amount, CurrencyType.Duckets);
|
||||
}
|
||||
giveDiamonds(userId: number, amount: number) {
|
||||
return this.givePoints(userId, amount, CurrencyType.Diamonds);
|
||||
}
|
||||
givePointsGotw(userId: number, amount: number) {
|
||||
return this.givePoints(userId, amount, CurrencyType.Points);
|
||||
}
|
||||
giveBadge(userId: number, badge: string) {
|
||||
return this.send("givebadge", { user_id: userId, badge });
|
||||
}
|
||||
setMotto(userId: number, motto: string) {
|
||||
return this.send("setmotto", { user_id: userId, motto });
|
||||
}
|
||||
setRank(userId: number, rank: number) {
|
||||
return this.send("setrank", { user_id: userId, rank });
|
||||
}
|
||||
sendGift(userId: number, itemId: number, message = "Here is a gift.") {
|
||||
return this.send("sendgift", { user_id: userId, itemid: itemId, message });
|
||||
}
|
||||
disconnectUser(userId: number, username = "") {
|
||||
return this.send("disconnect", { user_id: userId, username });
|
||||
}
|
||||
alertUser(userId: number, message: string) {
|
||||
return this.send("alertuser", { user_id: userId, message });
|
||||
}
|
||||
forwardUser(userId: number, roomId: number) {
|
||||
return this.send("forwarduser", { user_id: userId, room_id: roomId });
|
||||
}
|
||||
kickAll(roomId: number) {
|
||||
return this.send("kickall", { room_id: roomId });
|
||||
}
|
||||
hotelAlert(message: string) {
|
||||
return this.send("hotelalert", { message });
|
||||
}
|
||||
staffAlert(message: string) {
|
||||
return this.send("staffalert", { message });
|
||||
}
|
||||
executeCommand(userId: number, command: string) {
|
||||
return this.send("executecommand", { user_id: userId, command });
|
||||
}
|
||||
updateWordFilter() {
|
||||
return this.send("updatewordfilter");
|
||||
}
|
||||
updateCatalog() {
|
||||
return this.send("updatecatalog");
|
||||
}
|
||||
updateItems() {
|
||||
return this.send("updateitems");
|
||||
}
|
||||
updateConfig() {
|
||||
return this.send("updateconfig");
|
||||
}
|
||||
removeBadge(userId: number, badge: string) {
|
||||
return this.send("removebadge", { user_id: userId, badge });
|
||||
}
|
||||
muteUser(userId: number, duration: number) {
|
||||
return this.send("muteuser", { user_id: userId, duration });
|
||||
}
|
||||
unmuteUser(userId: number) {
|
||||
return this.send("unmuteuser", { user_id: userId });
|
||||
}
|
||||
}
|
||||
|
||||
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
|
||||
|
||||
export const rcon =
|
||||
globalForRcon.rcon ??
|
||||
new RconClient(
|
||||
tcpTransport({
|
||||
host: env.RCON_HOST,
|
||||
port: env.RCON_PORT,
|
||||
timeoutMs: env.RCON_TIMEOUT_MS,
|
||||
maxRetries: env.RCON_MAX_RETRIES,
|
||||
}),
|
||||
);
|
||||
globalForRcon.rcon ??
|
||||
new RconClient(
|
||||
tcpTransport({
|
||||
host: env.RCON_HOST,
|
||||
port: env.RCON_PORT,
|
||||
timeoutMs: env.RCON_TIMEOUT_MS,
|
||||
maxRetries: env.RCON_MAX_RETRIES,
|
||||
}),
|
||||
);
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForRcon.rcon = rcon;
|
||||
@@ -2,65 +2,65 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { sendCurrency } from "./send-currency";
|
||||
|
||||
function deps(delivered: boolean) {
|
||||
return {
|
||||
rcon: {
|
||||
giveCredits: vi.fn().mockResolvedValue(delivered),
|
||||
giveDuckets: vi.fn().mockResolvedValue(delivered),
|
||||
giveDiamonds: vi.fn().mockResolvedValue(delivered),
|
||||
givePointsGotw: vi.fn().mockResolvedValue(delivered),
|
||||
},
|
||||
db: {
|
||||
user: { update: vi.fn().mockResolvedValue(undefined) },
|
||||
usersCurrency: { upsert: vi.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
};
|
||||
return {
|
||||
rcon: {
|
||||
giveCredits: vi.fn().mockResolvedValue(delivered),
|
||||
giveDuckets: vi.fn().mockResolvedValue(delivered),
|
||||
giveDiamonds: vi.fn().mockResolvedValue(delivered),
|
||||
givePointsGotw: vi.fn().mockResolvedValue(delivered),
|
||||
},
|
||||
db: {
|
||||
user: { update: vi.fn().mockResolvedValue(undefined) },
|
||||
usersCurrency: { upsert: vi.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("sendCurrency", () => {
|
||||
it("rejects non-positive amounts", async () => {
|
||||
const d = deps(true);
|
||||
expect(await sendCurrency(d, 1, "credits", 0)).toBe(false);
|
||||
expect(await sendCurrency(d, 1, "credits", null)).toBe(false);
|
||||
expect(d.rcon.giveCredits).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects non-positive amounts", async () => {
|
||||
const d = deps(true);
|
||||
expect(await sendCurrency(d, 1, "credits", 0)).toBe(false);
|
||||
expect(await sendCurrency(d, 1, "credits", null)).toBe(false);
|
||||
expect(d.rcon.giveCredits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers credits via RCON when connected (no DB write)", async () => {
|
||||
const d = deps(true);
|
||||
expect(await sendCurrency(d, 7, "credits", 100)).toBe(true);
|
||||
expect(d.rcon.giveCredits).toHaveBeenCalledWith(7, 100);
|
||||
expect(d.db.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it("delivers credits via RCON when connected (no DB write)", async () => {
|
||||
const d = deps(true);
|
||||
expect(await sendCurrency(d, 7, "credits", 100)).toBe(true);
|
||||
expect(d.rcon.giveCredits).toHaveBeenCalledWith(7, 100);
|
||||
expect(d.db.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to users.credits when RCON fails", async () => {
|
||||
const d = deps(false);
|
||||
await sendCurrency(d, 7, "credits", 100);
|
||||
expect(d.db.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 7 },
|
||||
data: { credits: { increment: 100 } },
|
||||
});
|
||||
});
|
||||
it("falls back to users.credits when RCON fails", async () => {
|
||||
const d = deps(false);
|
||||
await sendCurrency(d, 7, "credits", 100);
|
||||
expect(d.db.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 7 },
|
||||
data: { credits: { increment: 100 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to users_currency by type for diamonds", async () => {
|
||||
const d = deps(false);
|
||||
await sendCurrency(d, 7, "diamonds", 3);
|
||||
expect(d.db.usersCurrency.upsert).toHaveBeenCalledWith({
|
||||
where: { userId_type: { userId: 7, type: 5 } },
|
||||
update: { amount: { increment: 3 } },
|
||||
create: { userId: 7, type: 5, amount: 3 },
|
||||
});
|
||||
});
|
||||
it("falls back to users_currency by type for diamonds", async () => {
|
||||
const d = deps(false);
|
||||
await sendCurrency(d, 7, "diamonds", 3);
|
||||
expect(d.db.usersCurrency.upsert).toHaveBeenCalledWith({
|
||||
where: { userId_type: { userId: 7, type: 5 } },
|
||||
update: { amount: { increment: 3 } },
|
||||
create: { userId: 7, type: 5, amount: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the right type values (duckets=0, points=101)", async () => {
|
||||
const d = deps(false);
|
||||
await sendCurrency(d, 1, "duckets", 10);
|
||||
await sendCurrency(d, 1, "points", 20);
|
||||
expect(d.db.usersCurrency.upsert).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ create: { userId: 1, type: 0, amount: 10 } }),
|
||||
);
|
||||
expect(d.db.usersCurrency.upsert).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ create: { userId: 1, type: 101, amount: 20 } }),
|
||||
);
|
||||
});
|
||||
it("uses the right type values (duckets=0, points=101)", async () => {
|
||||
const d = deps(false);
|
||||
await sendCurrency(d, 1, "duckets", 10);
|
||||
await sendCurrency(d, 1, "points", 20);
|
||||
expect(d.db.usersCurrency.upsert).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ create: { userId: 1, type: 0, amount: 10 } }),
|
||||
);
|
||||
expect(d.db.usersCurrency.upsert).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ create: { userId: 1, type: 101, amount: 20 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,27 +4,33 @@ export type CurrencyName = "credits" | "duckets" | "diamonds" | "points";
|
||||
|
||||
// users_currency.type values for the non-credits currencies.
|
||||
const TYPE_VALUE: Record<Exclude<CurrencyName, "credits">, number> = {
|
||||
duckets: 0,
|
||||
diamonds: 5,
|
||||
points: 101,
|
||||
duckets: 0,
|
||||
diamonds: 5,
|
||||
points: 101,
|
||||
};
|
||||
|
||||
export interface CurrencyDb {
|
||||
user: {
|
||||
update(args: { where: { id: number }; data: { credits: { increment: number } } }): Promise<unknown>;
|
||||
};
|
||||
usersCurrency: {
|
||||
upsert(args: {
|
||||
where: { userId_type: { userId: number; type: number } };
|
||||
update: { amount: { increment: number } };
|
||||
create: { userId: number; type: number; amount: number };
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
user: {
|
||||
update(args: {
|
||||
where: { id: number };
|
||||
data: { credits: { increment: number } };
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
usersCurrency: {
|
||||
upsert(args: {
|
||||
where: { userId_type: { userId: number; type: number } };
|
||||
update: { amount: { increment: number } };
|
||||
create: { userId: number; type: number; amount: number };
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendCurrencyDeps {
|
||||
rcon: Pick<RconClient, "giveCredits" | "giveDuckets" | "giveDiamonds" | "givePointsGotw">;
|
||||
db: CurrencyDb;
|
||||
rcon: Pick<
|
||||
RconClient,
|
||||
"giveCredits" | "giveDuckets" | "giveDiamonds" | "givePointsGotw"
|
||||
>;
|
||||
db: CurrencyDb;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,46 +41,46 @@ export interface SendCurrencyDeps {
|
||||
* users_currency keyed by (user_id, type).
|
||||
*/
|
||||
export async function sendCurrency(
|
||||
deps: SendCurrencyDeps,
|
||||
userId: number,
|
||||
type: CurrencyName,
|
||||
amount: number | null | undefined,
|
||||
deps: SendCurrencyDeps,
|
||||
userId: number,
|
||||
type: CurrencyName,
|
||||
amount: number | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!amount || amount <= 0) return false;
|
||||
if (!amount || amount <= 0) return false;
|
||||
|
||||
if (await rconDeliver(deps.rcon, userId, type, amount)) return true;
|
||||
if (await rconDeliver(deps.rcon, userId, type, amount)) return true;
|
||||
|
||||
if (type === "credits") {
|
||||
await deps.db.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { increment: amount } },
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyName union, not "credits"
|
||||
const t = TYPE_VALUE[type];
|
||||
await deps.db.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: t } },
|
||||
update: { amount: { increment: amount } },
|
||||
create: { userId, type: t, amount },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
if (type === "credits") {
|
||||
await deps.db.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { increment: amount } },
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyName union, not "credits"
|
||||
const t = TYPE_VALUE[type];
|
||||
await deps.db.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: t } },
|
||||
update: { amount: { increment: amount } },
|
||||
create: { userId, type: t, amount },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function rconDeliver(
|
||||
rcon: SendCurrencyDeps["rcon"],
|
||||
userId: number,
|
||||
type: CurrencyName,
|
||||
amount: number,
|
||||
rcon: SendCurrencyDeps["rcon"],
|
||||
userId: number,
|
||||
type: CurrencyName,
|
||||
amount: number,
|
||||
): Promise<boolean> {
|
||||
switch (type) {
|
||||
case "credits":
|
||||
return rcon.giveCredits(userId, amount);
|
||||
case "duckets":
|
||||
return rcon.giveDuckets(userId, amount);
|
||||
case "diamonds":
|
||||
return rcon.giveDiamonds(userId, amount);
|
||||
case "points":
|
||||
return rcon.givePointsGotw(userId, amount);
|
||||
}
|
||||
switch (type) {
|
||||
case "credits":
|
||||
return rcon.giveCredits(userId, amount);
|
||||
case "duckets":
|
||||
return rcon.giveDuckets(userId, amount);
|
||||
case "diamonds":
|
||||
return rcon.giveDiamonds(userId, amount);
|
||||
case "points":
|
||||
return rcon.givePointsGotw(userId, amount);
|
||||
}
|
||||
}
|
||||
@@ -2,45 +2,45 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: { websiteSetting: { findMany } },
|
||||
prisma: { websiteSetting: { findMany } },
|
||||
}));
|
||||
|
||||
import { siteSettings } from "./site-settings";
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
siteSettings.reload();
|
||||
findMany.mockReset();
|
||||
siteSettings.reload();
|
||||
});
|
||||
|
||||
describe("siteSettings", () => {
|
||||
it("returns a value by key", async () => {
|
||||
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
|
||||
expect(await siteSettings.get("hotel_name")).toBe("AtomHotel");
|
||||
});
|
||||
it("returns a value by key", async () => {
|
||||
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
|
||||
expect(await siteSettings.get("hotel_name")).toBe("AtomHotel");
|
||||
});
|
||||
|
||||
it("returns the fallback when key is missing", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
expect(await siteSettings.get("missing", "fallback")).toBe("fallback");
|
||||
expect(await siteSettings.get("missing")).toBeNull();
|
||||
});
|
||||
it("returns the fallback when key is missing", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
expect(await siteSettings.get("missing", "fallback")).toBe("fallback");
|
||||
expect(await siteSettings.get("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("coerces '1'/'0' string booleans", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ key: "maintenance_enabled", value: "1" },
|
||||
{ key: "radio_enabled", value: "0" },
|
||||
]);
|
||||
expect(await siteSettings.getBool("maintenance_enabled")).toBe(true);
|
||||
expect(await siteSettings.getBool("radio_enabled")).toBe(false);
|
||||
expect(await siteSettings.getBool("missing", true)).toBe(true);
|
||||
});
|
||||
it("coerces '1'/'0' string booleans", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ key: "maintenance_enabled", value: "1" },
|
||||
{ key: "radio_enabled", value: "0" },
|
||||
]);
|
||||
expect(await siteSettings.getBool("maintenance_enabled")).toBe(true);
|
||||
expect(await siteSettings.getBool("radio_enabled")).toBe(false);
|
||||
expect(await siteSettings.getBool("missing", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches once and caches until reload", async () => {
|
||||
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
|
||||
await siteSettings.get("hotel_name");
|
||||
await siteSettings.getBool("hotel_name");
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
siteSettings.reload();
|
||||
await siteSettings.get("hotel_name");
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("fetches once and caches until reload", async () => {
|
||||
findMany.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
|
||||
await siteSettings.get("hotel_name");
|
||||
await siteSettings.getBool("hotel_name");
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
siteSettings.reload();
|
||||
await siteSettings.get("hotel_name");
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -2,89 +2,96 @@ import { prisma } from "@/lib/prisma";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
hotel_name: "Atom",
|
||||
habbo_imaging_url: "https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
logo_url: "",
|
||||
nitro_client_url: "",
|
||||
hotel_name: "Atom",
|
||||
habbo_imaging_url: "https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
logo_url: "",
|
||||
nitro_client_url: "",
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 300_000;
|
||||
const REDIS_CACHE_KEY = "site_settings";
|
||||
|
||||
class SiteSettings {
|
||||
private cache: Map<string, string> | null = null;
|
||||
private cache: Map<string, string> | null = null;
|
||||
|
||||
private async loadFromDb(): Promise<Map<string, string>> {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
return new Map(rows.map((r) => [r.key, r.value]));
|
||||
} catch {
|
||||
return new Map(Object.entries(DEFAULTS));
|
||||
}
|
||||
}
|
||||
private async loadFromDb(): Promise<Map<string, string>> {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
return new Map(rows.map((r) => [r.key, r.value]));
|
||||
} catch {
|
||||
return new Map(Object.entries(DEFAULTS));
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<Map<string, string>> {
|
||||
if (redis) {
|
||||
try {
|
||||
const cached = await redis.get(REDIS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached) as Record<string, string>;
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
} catch {
|
||||
// Redis unavailable — fall through
|
||||
}
|
||||
}
|
||||
private async load(): Promise<Map<string, string>> {
|
||||
if (redis) {
|
||||
try {
|
||||
const cached = await redis.get(REDIS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached) as Record<string, string>;
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
} catch {
|
||||
// Redis unavailable — fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cache !== null) return this.cache;
|
||||
if (this.cache !== null) return this.cache;
|
||||
|
||||
const map = await this.loadFromDb();
|
||||
this.cache = map;
|
||||
const map = await this.loadFromDb();
|
||||
this.cache = map;
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const obj = Object.fromEntries(map.entries());
|
||||
await redis.setex(REDIS_CACHE_KEY, Math.ceil(CACHE_TTL_MS / 1000), JSON.stringify(obj));
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
if (redis) {
|
||||
try {
|
||||
const obj = Object.fromEntries(map.entries());
|
||||
await redis.setex(
|
||||
REDIS_CACHE_KEY,
|
||||
Math.ceil(CACHE_TTL_MS / 1000),
|
||||
JSON.stringify(obj),
|
||||
);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async get(key: string, fallback: string | null = null): Promise<string | null> {
|
||||
const map = await this.load();
|
||||
if (map.has(key)) return map.get(key) as string;
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by `key in DEFAULTS`
|
||||
if (key in DEFAULTS) return DEFAULTS[key] as string;
|
||||
return fallback;
|
||||
}
|
||||
async get(
|
||||
key: string,
|
||||
fallback: string | null = null,
|
||||
): Promise<string | null> {
|
||||
const map = await this.load();
|
||||
if (map.has(key)) return map.get(key) as string;
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by `key in DEFAULTS`
|
||||
if (key in DEFAULTS) return DEFAULTS[key] as string;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async getBool(key: string, fallback = false): Promise<boolean> {
|
||||
const v = await this.get(key, null);
|
||||
if (v === null) return fallback;
|
||||
const s = v.toLowerCase();
|
||||
return s === "1" || s === "true";
|
||||
}
|
||||
async getBool(key: string, fallback = false): Promise<boolean> {
|
||||
const v = await this.get(key, null);
|
||||
if (v === null) return fallback;
|
||||
const s = v.toLowerCase();
|
||||
return s === "1" || s === "true";
|
||||
}
|
||||
|
||||
async update(key: string, value: string): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
create: { key, value },
|
||||
update: { value },
|
||||
});
|
||||
this.reload();
|
||||
}
|
||||
async update(key: string, value: string): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
create: { key, value },
|
||||
update: { value },
|
||||
});
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.cache = null;
|
||||
if (redis) {
|
||||
redis.del(REDIS_CACHE_KEY).catch(() => {});
|
||||
}
|
||||
}
|
||||
reload(): void {
|
||||
this.cache = null;
|
||||
if (redis) {
|
||||
redis.del(REDIS_CACHE_KEY).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const siteSettings = new SiteSettings();
|
||||
@@ -21,7 +21,14 @@ import { parseBuffer } from "music-metadata";
|
||||
|
||||
/** 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. */
|
||||
@@ -34,19 +41,19 @@ export const MAX_SOUNDTRACK_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
/** 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}`);
|
||||
}
|
||||
return path.join(SOUNDTRACK_DIR, `${code}.mp3`);
|
||||
if (!isSafeSoundtrackCode(code)) {
|
||||
throw new Error(`Unsafe soundtrack code: ${code}`);
|
||||
}
|
||||
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 +69,19 @@ 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;
|
||||
// ID3v2 tag header
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,14 +89,23 @@ export function validateMp3Bytes(buf: Buffer): boolean {
|
||||
* Returns 0 if metadata cannot be parsed.
|
||||
*/
|
||||
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);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
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);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── File I/O ───────────────────────────────────────────────────────
|
||||
@@ -97,22 +114,25 @@ export async function extractMp3Duration(buf: Buffer): Promise<number> {
|
||||
* Write the MP3 payload to disk under the given code.
|
||||
* Creates the parent directory if missing.
|
||||
*/
|
||||
export async function writeSoundtrackFile(code: string, buf: Buffer): Promise<void> {
|
||||
await ensureSoundtrackDir();
|
||||
await fs.writeFile(getSoundtrackPath(code), buf);
|
||||
export async function writeSoundtrackFile(
|
||||
code: string,
|
||||
buf: Buffer,
|
||||
): Promise<void> {
|
||||
await ensureSoundtrackDir();
|
||||
await fs.writeFile(getSoundtrackPath(code), buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the MP3 file for the given code. No-op if the file is already gone.
|
||||
*/
|
||||
export async function deleteSoundtrackFile(code: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(getSoundtrackPath(code));
|
||||
} catch (err) {
|
||||
const errCode = (err as NodeJS.ErrnoException).code;
|
||||
if (errCode === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await fs.unlink(getSoundtrackPath(code));
|
||||
} catch (err) {
|
||||
const errCode = (err as NodeJS.ErrnoException).code;
|
||||
if (errCode === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type { TraxChannel, TraxEvent } from "@/lib/trax-format";
|
||||
@@ -120,8 +140,8 @@ export type { TraxChannel, TraxEvent } from "@/lib/trax-format";
|
||||
// implementation lives in `@/lib/trax-format` so it can be imported by
|
||||
// client components without pulling in Node-only deps from this file.
|
||||
export {
|
||||
detectSoundtrackKind,
|
||||
parseTrackSamples,
|
||||
parseTraxChannels,
|
||||
TRAX_TICK_SECONDS,
|
||||
detectSoundtrackKind,
|
||||
parseTrackSamples,
|
||||
parseTraxChannels,
|
||||
TRAX_TICK_SECONDS,
|
||||
} from "@/lib/trax-format";
|
||||
@@ -6,32 +6,35 @@ import { prisma } from "@/lib/prisma";
|
||||
* logging must not block the action it records.
|
||||
*/
|
||||
export async function logStaffActivity(opts: {
|
||||
staffId: number;
|
||||
action: string;
|
||||
description: string;
|
||||
targetType?: string;
|
||||
targetId?: number;
|
||||
staffId: number;
|
||||
action: string;
|
||||
description: string;
|
||||
targetType?: string;
|
||||
targetId?: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
let ip: string | null = null;
|
||||
try {
|
||||
const h = await headers();
|
||||
ip = h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
|
||||
} catch {
|
||||
ip = null;
|
||||
}
|
||||
await prisma.staffActivities.create({
|
||||
data: {
|
||||
userId: BigInt(opts.staffId),
|
||||
action: opts.action.slice(0, 50),
|
||||
description: opts.description,
|
||||
targetType: opts.targetType ?? null,
|
||||
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
|
||||
ipAddress: ip,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// swallow — audit logging is best-effort
|
||||
}
|
||||
try {
|
||||
let ip: string | null = null;
|
||||
try {
|
||||
const h = await headers();
|
||||
ip =
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
null;
|
||||
} catch {
|
||||
ip = null;
|
||||
}
|
||||
await prisma.staffActivities.create({
|
||||
data: {
|
||||
userId: BigInt(opts.staffId),
|
||||
action: opts.action.slice(0, 50),
|
||||
description: opts.description,
|
||||
targetType: opts.targetType ?? null,
|
||||
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
|
||||
ipAddress: ip,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// swallow — audit logging is best-effort
|
||||
}
|
||||
}
|
||||
@@ -2,302 +2,315 @@ 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;
|
||||
if (format === 3) {
|
||||
colorTableSize = data.readUInt8(offset) + 1;
|
||||
offset += 1;
|
||||
}
|
||||
let colorTableSize = 0;
|
||||
if (format === 3) {
|
||||
colorTableSize = data.readUInt8(offset) + 1;
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
const compressed = data.subarray(offset);
|
||||
let decompressed: Buffer;
|
||||
try {
|
||||
decompressed = inflateSync(compressed);
|
||||
} catch {
|
||||
return { id, width, height, pixels: Buffer.alloc(width * height * 4) };
|
||||
}
|
||||
const compressed = data.subarray(offset);
|
||||
let decompressed: Buffer;
|
||||
try {
|
||||
decompressed = inflateSync(compressed);
|
||||
} catch {
|
||||
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;
|
||||
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 (format === 5) {
|
||||
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;
|
||||
|
||||
// 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));
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
|
||||
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;
|
||||
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];
|
||||
// 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));
|
||||
}
|
||||
palette.push([r, g, b, a]);
|
||||
palOff += 4;
|
||||
}
|
||||
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;
|
||||
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];
|
||||
// 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));
|
||||
}
|
||||
palette.push([r, g, b, a]);
|
||||
palOff += 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];
|
||||
}
|
||||
srcOff += rowPadded;
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
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;
|
||||
if (format === 3) {
|
||||
colorTableSize = data.readUInt8(offset) + 1;
|
||||
offset += 1;
|
||||
}
|
||||
let colorTableSize = 0;
|
||||
if (format === 3) {
|
||||
colorTableSize = data.readUInt8(offset) + 1;
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
const compressed = data.subarray(offset);
|
||||
let decompressed: Buffer;
|
||||
try {
|
||||
decompressed = inflateSync(compressed);
|
||||
} catch {
|
||||
return { id, width, height, pixels: Buffer.alloc(width * height * 4) };
|
||||
}
|
||||
const compressed = data.subarray(offset);
|
||||
let decompressed: Buffer;
|
||||
try {
|
||||
decompressed = inflateSync(compressed);
|
||||
} catch {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else if (format === 3) {
|
||||
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;
|
||||
}
|
||||
if (format === 5) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else if (format === 3) {
|
||||
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 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];
|
||||
}
|
||||
srcOff += rowPadded;
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
srcOff += rowPadded;
|
||||
}
|
||||
}
|
||||
|
||||
return { id, width, height, pixels };
|
||||
return { id, width, height, pixels };
|
||||
}
|
||||
|
||||
// ── DefineBitsJpeg2 Parser (JPEG without alpha) ───────────────────
|
||||
|
||||
export function parseBitsJpeg2(
|
||||
data: Buffer,
|
||||
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 (
|
||||
jpegData.length >= 4 &&
|
||||
jpegData[0] === 0xff &&
|
||||
jpegData[1] === 0xd9 &&
|
||||
jpegData[2] === 0xff &&
|
||||
jpegData[3] === 0xd1
|
||||
) {
|
||||
jpegData = jpegData.subarray(4);
|
||||
}
|
||||
// Strip erroneous JPEG header (FF D9 FF D1) if present
|
||||
if (
|
||||
jpegData.length >= 4 &&
|
||||
jpegData[0] === 0xff &&
|
||||
jpegData[1] === 0xd9 &&
|
||||
jpegData[2] === 0xff &&
|
||||
jpegData[3] === 0xd1
|
||||
) {
|
||||
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) ──────────────
|
||||
|
||||
export function parseBitsJpeg3(
|
||||
data: Buffer,
|
||||
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 (
|
||||
jpegData.length >= 4 &&
|
||||
jpegData[0] === 0xff &&
|
||||
jpegData[1] === 0xd9 &&
|
||||
jpegData[2] === 0xff &&
|
||||
jpegData[3] === 0xd1
|
||||
) {
|
||||
jpegData = jpegData.subarray(4);
|
||||
}
|
||||
// Strip erroneous JPEG header (FF D9 FF D1) if present
|
||||
if (
|
||||
jpegData.length >= 4 &&
|
||||
jpegData[0] === 0xff &&
|
||||
jpegData[1] === 0xd9 &&
|
||||
jpegData[2] === 0xff &&
|
||||
jpegData[3] === 0xd1
|
||||
) {
|
||||
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);
|
||||
if (compressedAlpha.length > 0) {
|
||||
try {
|
||||
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];
|
||||
}
|
||||
} catch {
|
||||
// Alpha decompression failed, keep fully opaque
|
||||
}
|
||||
}
|
||||
// Apply zlib-compressed alpha channel
|
||||
const compressedAlpha = data.subarray(6 + alphaDataOffset);
|
||||
if (compressedAlpha.length > 0) {
|
||||
try {
|
||||
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];
|
||||
}
|
||||
} 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;
|
||||
while (offset < jpeg.length - 1) {
|
||||
if (jpeg[offset] !== 0xff) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
export function parseJpegDimensions(
|
||||
jpeg: Buffer,
|
||||
): { width: number; height: number } | null {
|
||||
let offset = 0;
|
||||
while (offset < jpeg.length - 1) {
|
||||
if (jpeg[offset] !== 0xff) {
|
||||
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;
|
||||
// Skip length (2 bytes) + precision (1 byte)
|
||||
const height = jpeg.readUInt16BE(offset + 3);
|
||||
const width = jpeg.readUInt16BE(offset + 5);
|
||||
return { width, height };
|
||||
}
|
||||
// SOF0 (Baseline), SOF1 (Extended), SOF2 (Progressive)
|
||||
if (marker >= 0xc0 && marker <= 0xc2) {
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
return null;
|
||||
// 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;
|
||||
}
|
||||
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 });
|
||||
// jpeg-js returns RGBA data directly
|
||||
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);
|
||||
}
|
||||
export function decodeJpegToRGBA(
|
||||
jpegData: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
): Buffer {
|
||||
try {
|
||||
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,
|
||||
);
|
||||
} catch {
|
||||
// Fallback: transparent pixels if JPEG decode fails
|
||||
return Buffer.alloc(width * height * 4);
|
||||
}
|
||||
}
|
||||
+499
-398
@@ -6,46 +6,54 @@
|
||||
* by the Nitro HTML5 client.
|
||||
*/
|
||||
import { gunzipSync, inflateSync } from "node:zlib";
|
||||
import type { BatchConversionItem, BatchConversionResult, ConversionResult } from "@/types/furni";
|
||||
import type {
|
||||
BatchConversionItem,
|
||||
BatchConversionResult,
|
||||
ConversionResult,
|
||||
} from "@/types/furni";
|
||||
import {
|
||||
parseBitsJpeg2,
|
||||
parseBitsJpeg3,
|
||||
parseBitsLossless,
|
||||
parseBitsLossless2,
|
||||
type SpriteImage,
|
||||
parseBitsJpeg2,
|
||||
parseBitsJpeg3,
|
||||
parseBitsLossless,
|
||||
parseBitsLossless2,
|
||||
type SpriteImage,
|
||||
} from "./image-decoder";
|
||||
import { buildSpritesheet, createNitroBundle, encodePng } from "./nitro-builder";
|
||||
import {
|
||||
decompressSwf,
|
||||
parseBinaryData,
|
||||
parseSwfTags,
|
||||
parseSymbolClass,
|
||||
type SymbolClassResult,
|
||||
TAG_DEFINE_BINARY_DATA,
|
||||
TAG_DEFINE_BITS_JPEG2,
|
||||
TAG_DEFINE_BITS_JPEG3,
|
||||
TAG_DEFINE_BITS_LOSSLESS,
|
||||
TAG_DEFINE_BITS_LOSSLESS2,
|
||||
TAG_SYMBOL_CLASS,
|
||||
buildSpritesheet,
|
||||
createNitroBundle,
|
||||
encodePng,
|
||||
} from "./nitro-builder";
|
||||
import {
|
||||
decompressSwf,
|
||||
parseBinaryData,
|
||||
parseSwfTags,
|
||||
parseSymbolClass,
|
||||
type SymbolClassResult,
|
||||
TAG_DEFINE_BINARY_DATA,
|
||||
TAG_DEFINE_BITS_JPEG2,
|
||||
TAG_DEFINE_BITS_JPEG3,
|
||||
TAG_DEFINE_BITS_LOSSLESS,
|
||||
TAG_DEFINE_BITS_LOSSLESS2,
|
||||
TAG_SYMBOL_CLASS,
|
||||
} from "./swf-parser";
|
||||
import {
|
||||
computeDefaultDir,
|
||||
extractPartColors,
|
||||
findChild,
|
||||
findChildren,
|
||||
parseXml,
|
||||
processAssetsXml,
|
||||
processIndex,
|
||||
processLogic,
|
||||
processManifest,
|
||||
processVisualization,
|
||||
computeDefaultDir,
|
||||
extractPartColors,
|
||||
findChild,
|
||||
findChildren,
|
||||
parseXml,
|
||||
processAssetsXml,
|
||||
processIndex,
|
||||
processLogic,
|
||||
processManifest,
|
||||
processVisualization,
|
||||
} from "./xml-processor";
|
||||
|
||||
export type {
|
||||
BatchConversionItem,
|
||||
BatchConversionResult,
|
||||
ConversionResult,
|
||||
FurniMetadata,
|
||||
BatchConversionItem,
|
||||
BatchConversionResult,
|
||||
ConversionResult,
|
||||
FurniMetadata,
|
||||
} from "@/types/furni";
|
||||
// Re-export public API from submodules
|
||||
export { createNitroBundle, parseNitroBundle } from "./nitro-builder";
|
||||
@@ -53,435 +61,528 @@ export { computeDefaultDir, extractPartColors } from "./xml-processor";
|
||||
|
||||
// ── Main Conversion ────────────────────────────────────────────────
|
||||
|
||||
export function convertSwfToNitro(swfBuffer: Buffer, classname: string): ConversionResult {
|
||||
const warnings: string[] = [];
|
||||
export function convertSwfToNitro(
|
||||
swfBuffer: Buffer,
|
||||
classname: string,
|
||||
): ConversionResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1. Decompress SWF
|
||||
let decompressed: Buffer;
|
||||
try {
|
||||
decompressed = decompressSwf(swfBuffer);
|
||||
} catch (err) {
|
||||
throw new Error(`SWF decompression failed for ${classname}: ${(err as Error).message}`);
|
||||
}
|
||||
// 1. Decompress SWF
|
||||
let decompressed: Buffer;
|
||||
try {
|
||||
decompressed = decompressSwf(swfBuffer);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`SWF decompression failed for ${classname}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Parse tags
|
||||
const tags = parseSwfTags(decompressed);
|
||||
if (tags.length === 0) {
|
||||
throw new Error(`No tags found in SWF for ${classname}`);
|
||||
}
|
||||
// 2. Parse tags
|
||||
const tags = parseSwfTags(decompressed);
|
||||
if (tags.length === 0) {
|
||||
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() };
|
||||
for (const tag of tags) {
|
||||
if (tag.type === TAG_SYMBOL_CLASS) {
|
||||
symbolResult = parseSymbolClass(tag.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const { classNames: symbolMap, symbols: allSymbols } = symbolResult;
|
||||
// 3. Parse SymbolClass to map IDs to names
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 }>();
|
||||
// 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 }
|
||||
>();
|
||||
|
||||
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;
|
||||
}
|
||||
case TAG_DEFINE_BITS_LOSSLESS2: {
|
||||
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;
|
||||
}
|
||||
case TAG_DEFINE_BITS_JPEG2: {
|
||||
const img = parseBitsJpeg2(tag.data);
|
||||
if (img && !rawImages.has(img.id)) {
|
||||
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TAG_DEFINE_BITS_JPEG3: {
|
||||
const img = parseBitsJpeg3(tag.data);
|
||||
if (img && !rawImages.has(img.id)) {
|
||||
rawImages.set(img.id, { width: img.width, height: img.height, pixels: img.pixels });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
case TAG_DEFINE_BITS_LOSSLESS2: {
|
||||
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;
|
||||
}
|
||||
case TAG_DEFINE_BITS_JPEG2: {
|
||||
const img = parseBitsJpeg2(tag.data);
|
||||
if (img && !rawImages.has(img.id)) {
|
||||
rawImages.set(img.id, {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
pixels: img.pixels,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TAG_DEFINE_BITS_JPEG3: {
|
||||
const img = parseBitsJpeg3(tag.data);
|
||||
if (img && !rawImages.has(img.id)) {
|
||||
rawImages.set(img.id, {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
pixels: img.pixels,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Strip the SymbolClass package prefix
|
||||
const prefix = `${classname}_`;
|
||||
// 5. Strip the SymbolClass package prefix
|
||||
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;
|
||||
}
|
||||
function stripPrefix(name: string): string {
|
||||
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 = "";
|
||||
// 6. Parse XML data from binary tags
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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>();
|
||||
if (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;
|
||||
if (!asset.attrs.source) {
|
||||
neededSprites.add(assetName);
|
||||
} else {
|
||||
neededSprites.add(asset.attrs.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build set of needed sprites from assets.xml
|
||||
const neededSprites = new Set<string>();
|
||||
if (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;
|
||||
if (!asset.attrs.source) {
|
||||
neededSprites.add(assetName);
|
||||
} else {
|
||||
neededSprites.add(asset.attrs.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map images to named sprites
|
||||
const namedImages: SpriteImage[] = [];
|
||||
for (const [id, imgData] of rawImages) {
|
||||
const rawName = symbolMap.get(id);
|
||||
if (!rawName) continue;
|
||||
const name = stripPrefix(rawName);
|
||||
// Map images to named sprites
|
||||
const namedImages: SpriteImage[] = [];
|
||||
for (const [id, imgData] of rawImages) {
|
||||
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,
|
||||
name,
|
||||
width: imgData.width,
|
||||
height: imgData.height,
|
||||
pixels: imgData.pixels,
|
||||
});
|
||||
}
|
||||
namedImages.push({
|
||||
id,
|
||||
name,
|
||||
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);
|
||||
// 7. Build spritesheet using bin-packing
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// Build IMAGE_SOURCES map
|
||||
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 (symbolName !== canonicalName) {
|
||||
imageSources.set(stripPrefix(symbolName), stripPrefix(canonicalName));
|
||||
}
|
||||
}
|
||||
// Build IMAGE_SOURCES map
|
||||
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 (symbolName !== canonicalName) {
|
||||
imageSources.set(stripPrefix(symbolName), stripPrefix(canonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
// Build assets map
|
||||
const assets: Record<
|
||||
string,
|
||||
{
|
||||
x: number;
|
||||
y: number;
|
||||
source?: string;
|
||||
flipH?: boolean;
|
||||
flipV?: boolean;
|
||||
usesPalette?: boolean;
|
||||
}
|
||||
> = {};
|
||||
// Build assets map
|
||||
const assets: Record<
|
||||
string,
|
||||
{
|
||||
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 };
|
||||
}
|
||||
for (const ma of manifest.assets) {
|
||||
assets[ma.name] = { x: ma.x, y: ma.y };
|
||||
}
|
||||
|
||||
if (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;
|
||||
if (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 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 || "";
|
||||
if (source && imageSources.has(source)) {
|
||||
source = imageSources.get(source)!;
|
||||
}
|
||||
if (imageSources.has(assetName)) {
|
||||
source = imageSources.get(assetName)!;
|
||||
}
|
||||
let source = asset.attrs.source || "";
|
||||
if (source && imageSources.has(source)) {
|
||||
source = imageSources.get(source)!;
|
||||
}
|
||||
if (imageSources.has(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;
|
||||
}
|
||||
if (asset.attrs.flipV === "1") {
|
||||
entry.flipV = true;
|
||||
}
|
||||
if (asset.attrs.usesPalette === "1") {
|
||||
entry.usesPalette = true;
|
||||
}
|
||||
const entry: (typeof assets)[string] = source
|
||||
? ({ source, x, y } as (typeof assets)[string])
|
||||
: { x, y };
|
||||
if (asset.attrs.flipH === "1") {
|
||||
entry.flipH = true;
|
||||
}
|
||||
if (asset.attrs.flipV === "1") {
|
||||
entry.flipV = 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 };
|
||||
}
|
||||
}
|
||||
if (Object.keys(assets).length === 0) {
|
||||
for (const img of namedImages) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(assets)) {
|
||||
if (!validSpriteNames.has(key) && !assets[key].source) {
|
||||
delete assets[key];
|
||||
}
|
||||
}
|
||||
|
||||
const sortedAssets: typeof assets = {};
|
||||
for (const key of Object.keys(assets).sort()) {
|
||||
sortedAssets[key] = assets[key];
|
||||
}
|
||||
const sortedAssets: typeof assets = {};
|
||||
for (const key of Object.keys(assets).sort()) {
|
||||
sortedAssets[key] = assets[key];
|
||||
}
|
||||
|
||||
// Build spritesheet frames
|
||||
const spritesheetFrames: Record<string, unknown> = {};
|
||||
for (const img of namedImages) {
|
||||
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,
|
||||
trimmed: false,
|
||||
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 },
|
||||
};
|
||||
}
|
||||
// Build spritesheet frames
|
||||
const spritesheetFrames: Record<string, unknown> = {};
|
||||
for (const img of namedImages) {
|
||||
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,
|
||||
trimmed: false,
|
||||
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";
|
||||
// Determine visualization/logic types
|
||||
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";
|
||||
}
|
||||
}
|
||||
if (!indexData) {
|
||||
if (
|
||||
visualizations.some(
|
||||
(v) => v.animations && Object.keys(v.animations).length > 0,
|
||||
)
|
||||
) {
|
||||
visualizationType = "furniture_animated";
|
||||
logicType = "furniture_multistate";
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Build final JSON
|
||||
const logicObj: Record<string, unknown> = {
|
||||
model: {
|
||||
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;
|
||||
// 9. Build final JSON
|
||||
const logicObj: Record<string, unknown> = {
|
||||
model: {
|
||||
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;
|
||||
|
||||
const nitroJson: Record<string, unknown> = {
|
||||
name: classname,
|
||||
visualizationType,
|
||||
logicType,
|
||||
spritesheet: {
|
||||
meta: {
|
||||
image: `${classname}.png`,
|
||||
format: "RGBA8888",
|
||||
size: { w: sheetWidth, h: sheetHeight },
|
||||
scale: 1,
|
||||
},
|
||||
frames: spritesheetFrames,
|
||||
},
|
||||
assets: sortedAssets,
|
||||
};
|
||||
const nitroJson: Record<string, unknown> = {
|
||||
name: classname,
|
||||
visualizationType,
|
||||
logicType,
|
||||
spritesheet: {
|
||||
meta: {
|
||||
image: `${classname}.png`,
|
||||
format: "RGBA8888",
|
||||
size: { w: sheetWidth, h: sheetHeight },
|
||||
scale: 1,
|
||||
},
|
||||
frames: spritesheetFrames,
|
||||
},
|
||||
assets: sortedAssets,
|
||||
};
|
||||
|
||||
if (Object.keys(palettes).length > 0) {
|
||||
nitroJson.palettes = palettes;
|
||||
}
|
||||
if (Object.keys(palettes).length > 0) {
|
||||
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);
|
||||
// 10. Create .nitro bundle
|
||||
const bundle = createNitroBundle(nitroJson, png, classname);
|
||||
|
||||
return {
|
||||
bundle,
|
||||
classname,
|
||||
dimensions: logic.dimensions,
|
||||
directions: logic.directions,
|
||||
spriteCount: namedImages.length,
|
||||
warnings,
|
||||
metadata: {
|
||||
visualizationType,
|
||||
logicType,
|
||||
canstandon: logic.canstandon,
|
||||
cansiton: logic.cansiton,
|
||||
canlayon: logic.canlayon,
|
||||
specialtype: indexData?.specialtype ?? 0,
|
||||
defaultdir: computeDefaultDir(logic.directions),
|
||||
partcolors: extractPartColors(visualizations),
|
||||
},
|
||||
};
|
||||
return {
|
||||
bundle,
|
||||
classname,
|
||||
dimensions: logic.dimensions,
|
||||
directions: logic.directions,
|
||||
spriteCount: namedImages.length,
|
||||
warnings,
|
||||
metadata: {
|
||||
visualizationType,
|
||||
logicType,
|
||||
canstandon: logic.canstandon,
|
||||
cansiton: logic.cansiton,
|
||||
canlayon: logic.canlayon,
|
||||
specialtype: indexData?.specialtype ?? 0,
|
||||
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);
|
||||
export function extractIconFromSwf(
|
||||
swfBuffer: Buffer,
|
||||
classname: string,
|
||||
): Buffer | null {
|
||||
try {
|
||||
const decompressed = decompressSwf(swfBuffer);
|
||||
const tags = parseSwfTags(decompressed);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 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;
|
||||
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;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
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);
|
||||
} catch {
|
||||
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);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backward-compatible wrapper ────────────────────────────────────
|
||||
|
||||
export function convertSwfToNitroBuffer(swfBuffer: Buffer, classname: string): Buffer {
|
||||
return convertSwfToNitro(swfBuffer, classname).bundle;
|
||||
export function convertSwfToNitroBuffer(
|
||||
swfBuffer: Buffer,
|
||||
classname: string,
|
||||
): Buffer {
|
||||
return convertSwfToNitro(swfBuffer, classname).bundle;
|
||||
}
|
||||
|
||||
// ── Batch Conversion ───────────────────────────────────────────────
|
||||
|
||||
export function convertSwfToNitroBatch(items: BatchConversionItem[]): BatchConversionResult[] {
|
||||
return items.map((item) => {
|
||||
try {
|
||||
const result = convertSwfToNitro(item.swfBuffer, item.classname);
|
||||
return {
|
||||
classname: item.classname,
|
||||
success: true,
|
||||
bundle: result.bundle,
|
||||
dimensions: result.dimensions,
|
||||
directions: result.directions,
|
||||
spriteCount: result.spriteCount,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
classname: item.classname,
|
||||
success: false,
|
||||
warnings: [],
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
});
|
||||
export function convertSwfToNitroBatch(
|
||||
items: BatchConversionItem[],
|
||||
): BatchConversionResult[] {
|
||||
return items.map((item) => {
|
||||
try {
|
||||
const result = convertSwfToNitro(item.swfBuffer, item.classname);
|
||||
return {
|
||||
classname: item.classname,
|
||||
success: true,
|
||||
bundle: result.bundle,
|
||||
dimensions: result.dimensions,
|
||||
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);
|
||||
} catch {
|
||||
try {
|
||||
return gunzipSync(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return inflateSync(data);
|
||||
} catch {
|
||||
try {
|
||||
return gunzipSync(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,88 +4,88 @@ import type { SpriteImage } from "./image-decoder";
|
||||
// ── Minimal PNG Encoder ────────────────────────────────────────────
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
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));
|
||||
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 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 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]));
|
||||
// Signature
|
||||
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));
|
||||
// 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));
|
||||
// IDAT
|
||||
chunks.push(pngChunk("IDAT", compressed));
|
||||
|
||||
// IEND
|
||||
chunks.push(pngChunk("IEND", Buffer.alloc(0)));
|
||||
// IEND
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,168 +93,193 @@ function nextPow2(v: number): number {
|
||||
* Sorts sprites by height descending, then packs left-to-right in rows.
|
||||
* Produces much tighter spritesheets than simple vertical stacking.
|
||||
*/
|
||||
function binPack(images: SpriteImage[]): { rects: PackedRect[]; width: number; height: number } {
|
||||
if (images.length === 0) {
|
||||
return { rects: [], width: 1, height: 1 };
|
||||
}
|
||||
function binPack(images: SpriteImage[]): {
|
||||
rects: PackedRect[];
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
if (images.length === 0) {
|
||||
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);
|
||||
// Sort by height descending for better shelf packing
|
||||
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)));
|
||||
// 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)));
|
||||
|
||||
// Round up to power of 2 for GPU friendliness (max 2048)
|
||||
atlasWidth = Math.min(2048, nextPow2(atlasWidth));
|
||||
// Round up to power of 2 for GPU friendliness (max 2048)
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
if (images.length === 0) {
|
||||
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]));
|
||||
// Build lookup by name for pixel data
|
||||
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;
|
||||
for (const rect of rects) {
|
||||
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);
|
||||
}
|
||||
frames.set(rect.name, { x: rect.x, y: rect.y, w: rect.w, h: rect.h });
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
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 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);
|
||||
const totalSize =
|
||||
2 +
|
||||
(2 + jsonFileName.length + 4 + jsonCompressed.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;
|
||||
if (fileCount < 2) {
|
||||
throw new Error(`Expected at least 2 files in .nitro bundle, got ${fileCount}`);
|
||||
}
|
||||
const fileCount = buffer.readInt16BE(offset);
|
||||
offset += 2;
|
||||
if (fileCount < 2) {
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
return { json, jsonFileName, png, pngFileName };
|
||||
return { json, jsonFileName, png, pngFileName };
|
||||
}
|
||||
@@ -17,136 +17,136 @@ 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>;
|
||||
/** symbol name → charID (all symbols including aliases) */
|
||||
symbols: Map<string, number>;
|
||||
/** charID → first symbol name (canonical name) */
|
||||
classNames: Map<number, string>;
|
||||
/** symbol name → charID (all symbols including aliases) */
|
||||
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;
|
||||
while (offset < swfBuffer.length) {
|
||||
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 (tagLength === 0x3f) {
|
||||
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;
|
||||
for (let i = 0; i < numSymbols; i++) {
|
||||
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);
|
||||
if (!classNames.has(charId)) {
|
||||
classNames.set(charId, name);
|
||||
}
|
||||
}
|
||||
symbols.set(name, charId);
|
||||
if (!classNames.has(charId)) {
|
||||
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 };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,45 +2,45 @@ import { describe, expect, it } from "vitest";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
|
||||
describe("createOwnedTicketReply", () => {
|
||||
it("does not create a reply for a ticket owned by another user", async () => {
|
||||
let created = false;
|
||||
const db = {
|
||||
findTicket: async () => ({ id: 5n, userId: 7 }),
|
||||
createReply: async () => {
|
||||
created = true;
|
||||
throw new Error("must not run");
|
||||
},
|
||||
touchTicket: async () => undefined,
|
||||
};
|
||||
it("does not create a reply for a ticket owned by another user", async () => {
|
||||
let created = false;
|
||||
const db = {
|
||||
findTicket: async () => ({ id: 5n, userId: 7 }),
|
||||
createReply: async () => {
|
||||
created = true;
|
||||
throw new Error("must not run");
|
||||
},
|
||||
touchTicket: async () => undefined,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createOwnedTicketReply(db, { ticketId: 5n, userId: 99, content: "Help" }),
|
||||
).resolves.toBeNull();
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
await expect(
|
||||
createOwnedTicketReply(db, { ticketId: 5n, userId: 99, content: "Help" }),
|
||||
).resolves.toBeNull();
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
|
||||
it("creates the reply and updates ticket activity as one operation", async () => {
|
||||
let touchedAt: Date | undefined;
|
||||
const db = {
|
||||
findTicket: async () => ({ id: 5n, userId: 99 }),
|
||||
createReply: async (data: { createdAt: Date }) => ({
|
||||
id: 8n,
|
||||
userId: 99,
|
||||
content: "Help",
|
||||
createdAt: data.createdAt,
|
||||
}),
|
||||
touchTicket: async (_ticketId: bigint, updatedAt: Date) => {
|
||||
touchedAt = updatedAt;
|
||||
},
|
||||
};
|
||||
it("creates the reply and updates ticket activity as one operation", async () => {
|
||||
let touchedAt: Date | undefined;
|
||||
const db = {
|
||||
findTicket: async () => ({ id: 5n, userId: 99 }),
|
||||
createReply: async (data: { createdAt: Date }) => ({
|
||||
id: 8n,
|
||||
userId: 99,
|
||||
content: "Help",
|
||||
createdAt: data.createdAt,
|
||||
}),
|
||||
touchTicket: async (_ticketId: bigint, updatedAt: Date) => {
|
||||
touchedAt = updatedAt;
|
||||
},
|
||||
};
|
||||
|
||||
const reply = await createOwnedTicketReply(db, {
|
||||
ticketId: 5n,
|
||||
userId: 99,
|
||||
content: "Help",
|
||||
});
|
||||
const reply = await createOwnedTicketReply(db, {
|
||||
ticketId: 5n,
|
||||
userId: 99,
|
||||
content: "Help",
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({ id: 8n, userId: 99, content: "Help" });
|
||||
expect(touchedAt).toBe(reply?.createdAt);
|
||||
});
|
||||
expect(reply).toMatchObject({ id: 8n, userId: 99, content: "Help" });
|
||||
expect(touchedAt).toBe(reply?.createdAt);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,37 @@
|
||||
export interface TicketReplyRecord {
|
||||
id: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date | null;
|
||||
id: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
export interface TicketReplyDb {
|
||||
findTicket(ticketId: bigint): Promise<{ id: bigint; userId: number | null } | null>;
|
||||
createReply(data: {
|
||||
ticketId: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Promise<TicketReplyRecord>;
|
||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||
findTicket(
|
||||
ticketId: bigint,
|
||||
): Promise<{ id: bigint; userId: number | null } | null>;
|
||||
createReply(data: {
|
||||
ticketId: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Promise<TicketReplyRecord>;
|
||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function createOwnedTicketReply(
|
||||
db: TicketReplyDb,
|
||||
input: { ticketId: bigint; userId: number; content: string },
|
||||
db: TicketReplyDb,
|
||||
input: { ticketId: bigint; userId: number; content: string },
|
||||
): Promise<TicketReplyRecord | null> {
|
||||
const ticket = await db.findTicket(input.ticketId);
|
||||
if (!ticket || ticket.userId !== input.userId) return null;
|
||||
const ticket = await db.findTicket(input.ticketId);
|
||||
if (!ticket || ticket.userId !== input.userId) return null;
|
||||
|
||||
const now = new Date();
|
||||
const reply = await db.createReply({ ...input, createdAt: now, updatedAt: now });
|
||||
await db.touchTicket(input.ticketId, now);
|
||||
return reply;
|
||||
const now = new Date();
|
||||
const reply = await db.createReply({
|
||||
...input,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.touchTicket(input.ticketId, now);
|
||||
return reply;
|
||||
}
|
||||
+11
-11
@@ -6,18 +6,18 @@ import { prisma } from "@/lib/prisma";
|
||||
* "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(
|
||||
async (staffId: number): Promise<number[]> => {
|
||||
const rows = await prisma.userWatch.findMany({
|
||||
where: { staffId },
|
||||
select: { targetUserId: true },
|
||||
});
|
||||
return rows.map((r) => r.targetUserId);
|
||||
},
|
||||
["user-watch-list"],
|
||||
{ revalidate: 60, tags: ["user-watch"] },
|
||||
async (staffId: number): Promise<number[]> => {
|
||||
const rows = await prisma.userWatch.findMany({
|
||||
where: { staffId },
|
||||
select: { targetUserId: true },
|
||||
});
|
||||
return rows.map((r) => r.targetUserId);
|
||||
},
|
||||
["user-watch-list"],
|
||||
{ revalidate: 60, tags: ["user-watch"] },
|
||||
);
|
||||
+98
-83
@@ -6,107 +6,122 @@ export 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> = {
|
||||
ban: 0xf87272,
|
||||
unban: 0x36d399,
|
||||
kick: 0xfbbd23,
|
||||
disconnect: 0xfbbd23,
|
||||
room_delete: 0xf87272,
|
||||
user_edit: 0x3abff8,
|
||||
user_create: 0x36d399,
|
||||
news_publish: 0x36d399,
|
||||
news_delete: 0xf87272,
|
||||
hotel_alert: 0xa78bfa,
|
||||
staff_alert: 0xa78bfa,
|
||||
ticket_create: 0x3abff8,
|
||||
event_create: 0x36d399,
|
||||
event_update: 0x3abff8,
|
||||
event_cancel: 0xf87272,
|
||||
poll_create: 0x36d399,
|
||||
poll_close: 0xa78bfa,
|
||||
ban: 0xf87272,
|
||||
unban: 0x36d399,
|
||||
kick: 0xfbbd23,
|
||||
disconnect: 0xfbbd23,
|
||||
room_delete: 0xf87272,
|
||||
user_edit: 0x3abff8,
|
||||
user_create: 0x36d399,
|
||||
news_publish: 0x36d399,
|
||||
news_delete: 0xf87272,
|
||||
hotel_alert: 0xa78bfa,
|
||||
staff_alert: 0xa78bfa,
|
||||
ticket_create: 0x3abff8,
|
||||
event_create: 0x36d399,
|
||||
event_update: 0x3abff8,
|
||||
event_cancel: 0xf87272,
|
||||
poll_create: 0x36d399,
|
||||
poll_close: 0xa78bfa,
|
||||
};
|
||||
|
||||
async function getSetting(cmsKey: string, envValue?: string): Promise<string | null> {
|
||||
if (envValue) return envValue;
|
||||
try {
|
||||
const setting = await prisma.websiteSetting.findUnique({ where: { key: cmsKey } });
|
||||
return setting?.value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
async function getSetting(
|
||||
cmsKey: string,
|
||||
envValue?: string,
|
||||
): Promise<string | null> {
|
||||
if (envValue) return envValue;
|
||||
try {
|
||||
const setting = await prisma.websiteSetting.findUnique({
|
||||
where: { key: cmsKey },
|
||||
});
|
||||
return setting?.value || null;
|
||||
} catch {
|
||||
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("_", " ")}]`,
|
||||
color: ACTION_COLORS[payload.action] ?? 0x666666,
|
||||
fields: [
|
||||
{ name: "Staff", value: payload.actor, inline: true },
|
||||
{ name: "Target", value: payload.target, inline: true },
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const embed = {
|
||||
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 },
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (payload.details) {
|
||||
embed.fields.push({ name: "Details", value: payload.details, inline: false });
|
||||
}
|
||||
if (payload.details) {
|
||||
embed.fields.push({
|
||||
name: "Details",
|
||||
value: payload.details,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ embeds: [embed] }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Webhook] Discord send failed:", {
|
||||
action: payload.action,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ embeds: [embed] }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (err) {
|
||||
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>Staff:</b> ${payload.actor}`,
|
||||
`<b>Target:</b> ${payload.target}`,
|
||||
payload.details ? `<b>Details:</b> ${payload.details}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const text = [
|
||||
`<b>[${payload.action.toUpperCase().replace("_", " ")}]</b>`,
|
||||
`<b>Staff:</b> ${payload.actor}`,
|
||||
`<b>Target:</b> ${payload.target}`,
|
||||
payload.details ? `<b>Details:</b> ${payload.details}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.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" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Webhook] Telegram send failed:", {
|
||||
action: payload.action,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
}
|
||||
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" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (err) {
|
||||
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(() => {});
|
||||
}
|
||||
Reference in new issue
Block a user