style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+68 -61
View File
@@ -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
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+267 -243
View File
@@ -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
View File
@@ -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;
}
+43 -42
View File
@@ -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;
}
}
+141 -111
View File
@@ -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
View File
@@ -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);
}
+31 -29
View File
@@ -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;
}
+70 -45
View File
@@ -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);
});
});
+167 -145
View File
@@ -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 };
}
+10 -10
View File
@@ -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");
});
});
+10 -10
View File
@@ -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];
}
+54 -34
View File
@@ -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();
});
});
+136 -99
View File
@@ -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,
};
}
+36 -26
View File
@@ -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`;
}
+37 -30
View File
@@ -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
View File
@@ -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;
}
+57 -45
View File
@@ -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");
});
});
+112 -86
View File
@@ -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
View File
@@ -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;
}
+6 -4
View File
@@ -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`;
}
+62 -50
View File
@@ -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);
}
});
}
+32 -26
View File
@@ -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 });
});
}
+103 -76
View File
@@ -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
View File
@@ -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>);
});
}
+33 -23
View File
@@ -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,
]);
});
});
File diff suppressed because it is too large. Load diff
+363 -322
View File
@@ -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 };
}
+122 -118
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+39 -37
View File
@@ -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",
);
});
});
+126 -105
View File
@@ -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,
);
});
});
+11 -7
View File
@@ -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;
}
+17 -13
View File
@@ -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);
});
});
+64 -55
View File
@@ -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
});
});
+56 -45
View File
@@ -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);
}
+29 -20
View File
@@ -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();
});
});
+20 -12
View File
@@ -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;
}
+35 -20
View File
@@ -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,
});
});
});
+100 -87
View File
@@ -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",
},
});
}
+49 -33
View File
@@ -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 };
}
}
+74 -68
View File
@@ -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");
}
}
+36 -36
View File
@@ -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();
}
}
+82 -66
View File
@@ -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();
});
});
+64 -55
View File
@@ -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
View File
@@ -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,
};
}
+194 -177
View File
@@ -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
View File
@@ -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;
}
}
+34 -30
View File
@@ -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");
});
});
+95 -68
View File
@@ -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,
};
}
+42 -40
View File
@@ -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
View File
@@ -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;
}
+59 -53
View File
@@ -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
View File
@@ -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;
+54 -54
View File
@@ -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 } }),
);
});
});
+56 -50
View File
@@ -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);
}
}
+30 -30
View File
@@ -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);
});
});
+74 -67
View File
@@ -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();
+58 -38
View File
@@ -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";
+30 -27
View File
@@ -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
}
}
+252 -239
View File
@@ -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
View File
@@ -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;
}
}
}
+204 -179
View File
@@ -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 };
}
+85 -85
View File
@@ -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
+37 -37
View File
@@ -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);
});
});
+27 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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(() => {});
}