fix: restore complete admin feature dependencies

This commit is contained in:
Simo committed 2026-07-11 21:15:54 +02:00
1 parent 5b4228261a
commit 0cd753c735
115 files changed
+18565 -4930

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
import { prisma } from '../prisma'
interface AuditEntry {
userId: number
action: string
target: string
targetId?: number
before?: Record<string, unknown>
after?: Record<string, unknown>
}
const SENSITIVE_KEY_RE =
/password|secret|token|otp|recovery|authTicket|two_factor|two_factor_secret|api_key/i
const REDACTED = '[Redacted]'
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
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>,
): Record<string, { from: unknown; to: unknown }> | null {
if (!before || !after) return null
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] }
}
}
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)
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
}
export async function getAuditLogs(options: GetLogsOptions = {}) {
const { search, page = 1, perPage = 20 } = options
const skip = (page - 1) * perPage
const where = search
? {
OR: [{ action: { contains: search } }, { target: { contains: search } }],
}
: {}
const [rows, total] = await Promise.all([
prisma.adminAuditLog.findMany({
where,
orderBy: { id: 'desc' },
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 enrichedRows = rows.map((r) => ({
...r,
username: userMap.get(r.userId) ?? `User #${r.userId}`,
}))
return {
rows: enrichedRows,
total,
page,
perPage,
lastPage: Math.ceil(total / perPage),
}
}
+297
View File
@@ -0,0 +1,297 @@
import { promises as fs } from 'node:fs'
import { Prisma } from '@/generated/prisma/client'
import { prisma } from '@/lib/prisma'
import { getFurnitureDataPath } from '@/lib/services/furni-data'
// Cache FurnitureData.json in memory with TTL to avoid repeated disk I/O
let furniDataCache: {
roomitemtypes?: {
furnitype?: Array<{ id: number; description?: string; classname?: string; revision?: number }>
}
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
}
}
/** Invalidate the FurnitureData cache after writes */
export function invalidateFurniDataCache() {
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
}
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
}
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
}
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 }[]
}
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 [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))
// 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>>>`
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,
allow_inventory_stack, interaction_type, interaction_modes_count,
vending_ids, customparams, effect_id_male, effect_id_female,
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 ?? ''),
}))
: []
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
}
}
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)
}
// 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,
}
}
+260
View File
@@ -0,0 +1,260 @@
import { prisma } from '@/lib/prisma'
export type { NestedTreeNode, TreeNode } from '@/types/catalog'
import type { TreeNode } from '@/types/catalog'
/**
* Get the full catalog tree recursively using iterative Prisma queries.
* Returns a flat list with depth info for client-side tree building.
* 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 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)
}
// 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
}
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,
}))
}
/**
* Build a nested tree structure from flat nodes.
*/
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>()
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) ?? []
}
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
}): Promise<{ id: number }> {
// 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
}
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
}
/**
* 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
}
}
await prisma.catalogPages.update({
where: { id: pageId },
data: { parentId: newParentId },
})
}
/**
* Delete a page with cascade or reparent mode.
* - cascade: deletes all children and their items
* - reparent: moves children to the deleted page's parent
*/
export async function deletePage(
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 }
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 } })
return { deletedPages: 1, movedChildren: result.count }
}
// 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]
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] } })
}
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
// 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
}
+117
View File
@@ -0,0 +1,117 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { siteSettings } from '@/lib/services/site-settings'
export const DEFAULT_NITRO_FILES_ROOT = path.join(
/*turbopackIgnore: true*/ path.dirname(process.cwd()),
'DEV',
'Nitro-Files',
)
export const DEFAULT_FURNI_ICON_DIR = path.join(
/*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',
)
export const DEFAULT_FURNI_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/furniture',
)
export interface FurniAssetDirs {
swfDir: string
iconDir: string
nitroDir: string
}
export interface FurniAssetWriteTargets extends 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
}
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
}
export async function getNitroFilesRoot(): Promise<string> {
const configured = (await siteSettings.get('nitro_files_root', '')) ?? ''
if (configured.trim()) return normalizeDir(configured, configured)
return existsSync(/*turbopackIgnore: true*/ DEFAULT_NITRO_FILES_ROOT)
? normalizeDir(DEFAULT_NITRO_FILES_ROOT, DEFAULT_NITRO_FILES_ROOT)
: ''
}
function dirsFromNitroFilesRoot(root: string): FurniAssetDirs {
return {
swfDir: path.join(/*turbopackIgnore: true*/ root, 'swf/dcr/hof_furni'),
iconDir: path.join(/*turbopackIgnore: true*/ root, 'swf/dcr/hof_furni/icons'),
nitroDir: path.join(/*turbopackIgnore: true*/ root, 'nitro-assets/bundled/furniture'),
}
}
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),
])
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 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
)
})
return {
...primary,
mirrorDirs: mirrorDirs.map((dirs) => ({
swfDir: uniqueDirs([dirs.swfDir])[0],
iconDir: uniqueDirs([dirs.iconDir])[0],
nitroDir: uniqueDirs([dirs.nitroDir])[0],
})),
}
}
+263
View File
@@ -0,0 +1,263 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { getNitroFilesRoot } from '@/lib/services/furni-asset-dirs'
import type { HabboItFurniEntry } from '@/lib/services/habbo-furnidata-cache'
import { siteSettings } from '@/lib/services/site-settings'
import type { FurniMetadata } from '@/lib/services/swf-to-nitro'
const LOCK_STALE_MS = 60_000
/**
* Resolve the FurnitureData file path from the admin setting
* `furni_data_url` (e.g. `/nitro-assets/config/FurnitureData.json5`) mapped
* under the public/ directory. Falls back to the json5 path if the setting
* 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)
}
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 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]
}
// ── FurnitureData.json lock + helpers ────────────────────────────────
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))
}
}
}
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!()
}
}
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)
}
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
}): Record<string, unknown> {
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: [] })
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>,
) {
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 removeFurniEntry(classname: string): Promise<boolean> {
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
}
}
if (removed) {
await writeFurniData(furniData as Record<string, unknown>)
}
return removed
})
}
export async function appendFurniEntriesBatch(
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
): Promise<void> {
await withFurniDataLock(async () => {
const furniData = (await readFurniData()) as Record<string, { furnitype: unknown[] }>
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>)
})
}
+163
View File
@@ -0,0 +1,163 @@
/**
* habbo.it furnidata cache — downloads once, keeps in memory.
* Provides instant lookup by classname for enriching imports.
*/
export type { HabboItFurniEntry } from '@/types/furni'
import type { HabboItFurniEntry } from '@/types/furni'
const FURNIDATA_URL = 'https://www.habbo.it/gamedata/furnidata_json/1'
const CACHE_TTL = 30 * 60 * 1000 // 30 minutes
let cache: Map<string, HabboItFurniEntry> | null = null
let cacheTimestamp = 0
let loading: Promise<Map<string, HabboItFurniEntry>> | null = null
/**
* Fetches and parses the full habbo.it furnidata JSON.
* Results are cached in memory for 30 minutes.
*/
export async function getHabboItFurnidata(): Promise<Map<string, HabboItFurniEntry>> {
const now = Date.now()
// Return cache if still valid
if (cache && now - cacheTimestamp < CACHE_TTL) {
return cache
}
// 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' },
})
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>()
// 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',
})
}
}
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
}
/**
* 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()
// 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
}
// 3. If no variant in classname, try *0
if (!classname.includes('*')) {
const withVariant = map.get(`${classname}*0`)
if (withVariant) return withVariant
}
return null
}
/** Clear the cache (useful after manual edits) */
export function clearHabboItCache(): void {
cache = null
cacheTimestamp = 0
}
+311
View File
@@ -0,0 +1,311 @@
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',
])
export interface EmulatorRankSummary {
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>
}
type RawDb = {
$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}`
}
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)
}
export function splitRankUpdateFields(fields: Record<string, 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)
}
}
return { rankFields, permissionFields }
}
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'),
}))
}
export async function fetchEmulatorRankForEdit(
db: RawDb,
rankId: number,
): Promise<EmulatorRankForEdit | null> {
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
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 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 },
): 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)
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`
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
}
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}`),
])
}
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(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>,
) {
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>,
) {
const rankColumn = getRankPermissionColumn(rankId)
const entries = Object.entries(permissionFields)
const cases = entries.map(
([key, value]) => Prisma.sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
)
const keys = entries.map(([key]) => key)
return db.$executeRaw(
Prisma.sql`
UPDATE permission_definitions
SET ${Prisma.raw(quoteIdentifier(rankColumn))} =
CASE permission_key
${Prisma.join(cases, ' ')}
ELSE ${Prisma.raw(quoteIdentifier(rankColumn))}
END
WHERE permission_key IN (${Prisma.join(keys)})
`,
)
}
async function updateLegacyPermissionFields(
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)
}
async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
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
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`
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`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ${table}
`,
)
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}\``
}
+13 -1
View File
@@ -92,7 +92,7 @@ export class RconClient {
sendGift(userId: number, itemId: number, message = "Here is a gift.") {
return this.send("sendgift", { user_id: userId, itemid: itemId, message });
}
disconnectUser(userId: number, username: string) {
disconnectUser(userId: number, username = "") {
return this.send("disconnect", { user_id: userId, username });
}
alertUser(userId: number, message: string) {
@@ -110,6 +110,18 @@ export class RconClient {
updateCatalog() {
return this.send("updatecatalog");
}
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 };
+128
View File
@@ -0,0 +1,128 @@
/**
* Soundtrack (Song Disks) service helpers.
*
* Storage layout:
* MP3 files live at public/swf/dcr/hof_furni/mp3/{code}.mp3
* Public URL: /swf/dcr/hof_furni/mp3/{code}.mp3
*
* This is the same directory used by Arcturus for the trax sample set
* (`sound_machine_sample_<N>.mp3`). Keeping uploads alongside existing
* samples means they resolve via the same URL base and play without
* extra middleware config.
*
* The public URL prefix is configurable via the CMS setting
* `soundtrack_base_url` — see site-settings.ts defaults.
*/
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { parseBuffer } from 'music-metadata'
// ── Constants ──────────────────────────────────────────────────────
/** On-disk directory where MP3s live. Fixed (not configurable from UI to
* avoid any path traversal risk). */
export const SOUNDTRACK_DIR = path.join(process.cwd(), 'public', 'swf', 'dcr', 'hof_furni', 'mp3')
/** Default public URL prefix (with trailing slash). Can be overridden via
* the `soundtrack_base_url` CMS setting. */
export const DEFAULT_SOUNDTRACK_BASE_URL = '/swf/dcr/hof_furni/mp3/'
/** 10 MB hard limit per upload. */
export const MAX_SOUNDTRACK_SIZE = 10 * 1024 * 1024
// ── File paths ─────────────────────────────────────────────────────
/** Reject codes that would escape the sounds directory or use unsafe chars. */
export function isSafeSoundtrackCode(code: string): boolean {
return /^[a-zA-Z0-9_-]{1,32}$/.test(code)
}
export function getSoundtrackPath(code: string): string {
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 })
}
// ── Validation ─────────────────────────────────────────────────────
/**
* Verify the buffer starts with an MP3 header.
* Accepts:
* - ID3v2 tag header ("ID3")
* - Raw MPEG sync byte 0xFF followed by 0xFA / 0xFB / 0xFE / 0xF3 / 0xF2
* (covers MPEG-1/2 Layer III common variants)
*
* This is a best-effort check — not a full parse — but it rejects arbitrary
* files masquerading as .mp3 (e.g. renamed .exe).
*/
export function validateMp3Bytes(buf: Buffer): boolean {
if (buf.length < 4) return false
// 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
}
return false
}
/**
* Extract MP3 duration in seconds (integer, rounded).
* 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
}
}
// ── File I/O ───────────────────────────────────────────────────────
/**
* 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)
}
/**
* 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
}
}
export type { TraxChannel, TraxEvent } from '@/lib/trax-format'
// Re-export the Trax format parser for server-side use. The actual
// implementation lives in `@/lib/trax-format` so it can be imported by
// client components without pulling in Node-only deps from this file.
export {
detectSoundtrackKind,
parseTrackSamples,
parseTraxChannels,
TRAX_TICK_SECONDS,
} from '@/lib/trax-format'
+6
View File
@@ -0,0 +1,6 @@
/**
* Re-export barrel for backward compatibility.
* All implementation has been moved to the ./swf/ module directory.
*/
export * from './swf'
+304
View File
@@ -0,0 +1,304 @@
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
}
// ── DefineBitsLossless2 Parser (with alpha) ──────────────────────
export function parseBitsLossless2(data: 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 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 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
// 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 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 }
}
// ── DefineBitsLossless Parser (without alpha) ──────────────────────
export function parseBitsLossless(data: 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 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 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
}
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 }
}
// ── DefineBitsJpeg2 Parser (JPEG without alpha) ───────────────────
export function parseBitsJpeg2(
data: Buffer,
): { id: number; width: number; height: number; pixels: Buffer } | null {
if (data.length < 6) return null
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)
}
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 }
}
// ── DefineBitsJpeg3 Parser (JPEG with alpha channel) ──────────────
export function parseBitsJpeg3(
data: Buffer,
): { id: number; width: number; height: number; pixels: Buffer } | null {
if (data.length < 6) return null
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)
}
const dims = parseJpegDimensions(jpegData)
if (!dims) return null
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
}
}
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
}
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 }
}
// 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)
}
}
+488
View File
@@ -0,0 +1,488 @@
/**
* SWF to Nitro (.nitro) converter for Habbo furniture assets.
*
* Parses a SWF file, extracts sprites and metadata XML,
* then bundles everything into the .nitro format expected
* by the Nitro HTML5 client.
*/
import { gunzipSync, inflateSync } from 'node:zlib'
import type { BatchConversionItem, BatchConversionResult, ConversionResult } from '@/types/furni'
import {
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,
} from './swf-parser'
import {
computeDefaultDir,
extractPartColors,
findChild,
findChildren,
parseXml,
processAssetsXml,
processIndex,
processLogic,
processManifest,
processVisualization,
} from './xml-processor'
export type {
BatchConversionItem,
BatchConversionResult,
ConversionResult,
FurniMetadata,
} from '@/types/furni'
// Re-export public API from submodules
export { createNitroBundle, parseNitroBundle } from './nitro-builder'
export { computeDefaultDir, extractPartColors } from './xml-processor'
// ── Main Conversion ────────────────────────────────────────────────
export function convertSwfToNitro(swfBuffer: Buffer, classname: string): ConversionResult {
const warnings: string[] = []
// 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}`)
}
// 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 }>()
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}_`
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 = ''
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')
// 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)
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.sort((a, b) => a.name.localeCompare(b.name))
// 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)
// 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
}
> = {}
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
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)!
}
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
}
}
}
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))
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]
}
// 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'
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
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
}
nitroJson.visualizations = visualizations
nitroJson.logic = logicObj
// 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),
},
}
}
// ── Icon Extraction from SWF ────────────────────────────────────────
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
}
}
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
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 (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
candidates.push({ name, width: img.width, height: img.height, pixels: img.pixels })
}
}
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
}
}
// ── Backward-compatible wrapper ────────────────────────────────────
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,
}
}
})
}
// ── 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
}
}
}
+261
View File
@@ -0,0 +1,261 @@
import { deflateSync, inflateSync } from 'node:zlib'
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
})()
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
}
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
}
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 compressed = deflateSync(rawData)
const chunks: Buffer[] = []
// 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))
// IDAT
chunks.push(pngChunk('IDAT', compressed))
// IEND
chunks.push(pngChunk('IEND', Buffer.alloc(0)))
return Buffer.concat(chunks)
}
// ── Bin-Packing Spritesheet Builder ────────────────────────────────
interface PackedRect {
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
}
/**
* Simple shelf-based bin packing algorithm.
* 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 }
}
// 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)))
// 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
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)
}
const finalHeight = shelfY + shelfHeight
return { rects, width: atlasWidth, height: finalHeight || 1 }
}
// ── Spritesheet Builder ────────────────────────────────────────────
interface FrameInfo {
x: number
y: number
w: number
h: number
}
export function buildSpritesheet(images: SpriteImage[]): {
png: Buffer
frames: Map<string, FrameInfo>
width: number
height: number
} {
if (images.length === 0) {
const emptyPng = encodePng(1, 1, Buffer.alloc(4))
return { png: emptyPng, frames: new Map(), width: 1, height: 1 }
}
const { rects, width: sheetWidth, height: sheetHeight } = binPack(images)
// Build lookup by name for pixel data
const imageByName = new Map(images.map((img) => [img.name, img]))
const 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
// 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 }
}
// ── .nitro Bundle Creator ──────────────────────────────────────────
export function createNitroBundle(jsonData: object, pngData: Buffer, name: string): Buffer {
const jsonStr = JSON.stringify(jsonData)
const jsonCompressed = deflateSync(Buffer.from(jsonStr, 'utf-8'))
const pngCompressed = deflateSync(pngData)
const jsonFileName = `${name}.json`
const pngFileName = `${name}.png`
const totalSize =
2 +
(2 + jsonFileName.length + 4 + jsonCompressed.length) +
(2 + pngFileName.length + 4 + pngCompressed.length)
const bundle = Buffer.alloc(totalSize)
let offset = 0
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(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
}
// ── .nitro Bundle Parser ──────────────────────────────────────────
export function parseNitroBundle(buffer: Buffer): {
json: Record<string, unknown>
jsonFileName: string
png: Buffer
pngFileName: string
} {
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}`)
}
// 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)
return { json, jsonFileName, png, pngFileName }
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Low-level SWF file parsing: decompression, tag reading, symbol class.
*/
import { inflateSync } from 'node:zlib'
import LZMA from 'lzma'
// ── SWF Tag Types ──────────────────────────────────────────────────
export const TAG_END = 0
export const TAG_DEFINE_BITS_LOSSLESS = 20
export const TAG_DEFINE_BITS_JPEG2 = 21
export const TAG_DEFINE_BITS_JPEG3 = 35
export const TAG_DEFINE_BITS_LOSSLESS2 = 36
export const TAG_SYMBOL_CLASS = 76
export const TAG_DEFINE_BINARY_DATA = 87
// ── Interfaces ─────────────────────────────────────────────────────
export interface SwfTag {
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>
}
// ── SWF Header Parsing ─────────────────────────────────────────────
export function decompressSwf(buffer: Buffer): Buffer {
const sig = buffer.toString('ascii', 0, 3)
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 === '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 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 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}`)
}
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
}
// ── SWF Tag Reader ─────────────────────────────────────────────────
export function parseSwfTags(swfBuffer: Buffer): SwfTag[] {
const tags: SwfTag[] = []
let offset = 8
offset = readRect(swfBuffer, offset)
offset += 4
while (offset < swfBuffer.length) {
if (offset + 2 > swfBuffer.length) break
const tagCodeAndLength = swfBuffer.readUInt16LE(offset)
offset += 2
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 (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
}
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 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
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)
}
}
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 }
}
+582
View File
@@ -0,0 +1,582 @@
/**
* XML parser and XML data processor functions for SWF-to-Nitro conversion.
*
* Pure string/data processing — no external imports needed.
*/
// ── Interfaces ─────────────────────────────────────────────────────
export interface AssetOffset {
x: number
y: number
}
export interface NitroVisualization {
layerCount: number
angle: number
size: number
layers?: Record<string, Record<string, unknown>>
directions?: Record<string, Record<string, unknown>>
colors?: Record<string, unknown>
animations?: Record<string, unknown>
postures?: { defaultPosture?: string; postures?: { id: string; animationId: number }[] }
gestures?: { id: string; animationId: number }[]
}
// ── Minimal XML Parser ─────────────────────────────────────────────
export interface XmlNode {
tag: string
attrs: Record<string, string>
children: XmlNode[]
text: string
}
export function parseXml(xml: string): XmlNode | null {
const stack: XmlNode[] = []
const nodes: XmlNode[] = []
// Remove XML declaration and comments
const clean = xml
.replace(/<\?[^?]*\?>/g, '')
.replace(/<!--[\s\S]*?-->/g, '')
.trim()
const tagRe = /<\/?([a-zA-Z_][\w.-]*)((?:\s+[a-zA-Z_][\w.-]*\s*=\s*"[^"]*")*)\s*\/?>/g
let lastIndex = 0
for (const match of clean.matchAll(tagRe)) {
const fullMatch = match[0]
const tagName = match[1]
const attrsStr = match[2] || ''
const matchIndex = match.index ?? 0
const text = clean.substring(lastIndex, matchIndex).trim()
if (text && stack.length > 0) {
stack[stack.length - 1].text += text
}
lastIndex = matchIndex + fullMatch.length
if (fullMatch.startsWith('</')) {
if (stack.length > 0) {
const node = stack.pop()!
if (stack.length > 0) {
stack[stack.length - 1].children.push(node)
} else {
nodes.push(node)
}
}
} else {
const attrs: Record<string, string> = {}
const attrRe = /([a-zA-Z_][\w.-]*)\s*=\s*"([^"]*)"/g
for (const am of attrsStr.matchAll(attrRe)) {
attrs[am[1]] = am[2]
}
const node: XmlNode = { tag: tagName, attrs, children: [], text: '' }
if (fullMatch.endsWith('/>')) {
if (stack.length > 0) {
stack[stack.length - 1].children.push(node)
} else {
nodes.push(node)
}
} else {
stack.push(node)
}
}
}
while (stack.length > 1) {
const node = stack.pop()!
stack[stack.length - 1].children.push(node)
}
if (stack.length === 1) nodes.push(stack[0])
return nodes[0] || null
}
export function findChild(node: XmlNode, tag: string): XmlNode | undefined {
return node.children.find((c) => c.tag === tag)
}
export function findChildren(node: XmlNode, tag: string): XmlNode[] {
return node.children.filter((c) => c.tag === tag)
}
// ── XML Data Processors ────────────────────────────────────────────
export interface ManifestAsset {
name: string
x: number
y: number
}
export function processManifest(xml: string): { assets: ManifestAsset[] } {
const assets: ManifestAsset[] = []
const root = parseXml(xml)
if (!root) return { assets }
const libNode = root.tag === 'manifest' ? findChild(root, 'library') : root
if (!libNode) return { assets }
const assetsNode = findChild(libNode, 'assets')
if (!assetsNode) return { assets }
for (const asset of findChildren(assetsNode, 'asset')) {
const name = asset.attrs.name || ''
if (!name) continue
// Skip shadow sprites
if (name.startsWith('sh_')) continue
let x = 0,
y = 0
const paramNode = findChild(asset, 'param')
if (paramNode && paramNode.attrs.key === 'offset') {
const parts = (paramNode.attrs.value || '').split(',')
x = parseInt(parts[0] || '0', 10)
y = parseInt(parts[1] || '0', 10)
}
assets.push({ name, x, y })
}
return { assets }
}
export interface PaletteEntry {
id: number
source?: string
master?: boolean
tags?: string[]
breed?: number
colorTag?: number
color1?: string
color2?: string
}
export function processAssetsXml(xml: string): {
offsets: Map<string, AssetOffset>
palettes: Record<string, PaletteEntry>
} {
const offsets = new Map<string, AssetOffset>()
const palettes: Record<string, PaletteEntry> = {}
const root = parseXml(xml)
if (!root) return { offsets, palettes }
const assetsNode = root.tag === 'assets' ? root : findChild(root, 'assets') || root
for (const asset of findChildren(assetsNode, 'asset')) {
const name = asset.attrs.name || ''
if (!name) continue
const x = parseInt(asset.attrs.x || '0', 10)
const y = parseInt(asset.attrs.y || '0', 10)
offsets.set(name, { x, y })
}
// Parse palettes
for (const pal of findChildren(assetsNode, 'palette')) {
const id = parseInt(pal.attrs.id || '0', 10)
const entry: PaletteEntry = { id }
if (pal.attrs.source) entry.source = pal.attrs.source
if (pal.attrs.master === '1' || pal.attrs.master === 'true') entry.master = true
if (pal.attrs.tags) entry.tags = pal.attrs.tags.split(',')
if (pal.attrs.breed) entry.breed = parseInt(pal.attrs.breed, 10)
if (pal.attrs.colorTag) entry.colorTag = parseInt(pal.attrs.colorTag, 10)
if (pal.attrs.color1) entry.color1 = pal.attrs.color1
if (pal.attrs.color2) entry.color2 = pal.attrs.color2
palettes[String(id)] = entry
}
return { offsets, palettes }
}
export function processIndex(
xml: string,
): { visualizationType: string; logicType: string; specialtype: number } | null {
const root = parseXml(xml)
if (!root) return null
const obj = root.tag === 'object' ? root : findChild(root, 'object')
if (!obj) return null
return {
visualizationType: obj.attrs.visualization || '',
logicType: obj.attrs.logic || '',
specialtype: parseInt(obj.attrs.specialtype || '0', 10),
}
}
export interface LogicResult {
dimensions: { x: number; y: number; z: number; centerZ?: number }
directions: number[]
maskType: string
canstandon: boolean
cansiton: boolean
canlayon: boolean
action?: { link?: string; startState?: number }
credits?: string
soundSample?: { id: number; noPitch?: boolean }
customVars?: { variables: string[] }
particleSystems?: Record<string, unknown>[]
planetSystems?: Record<string, unknown>[]
}
export function processLogic(xml: string): LogicResult {
const defaults: LogicResult = {
dimensions: { x: 1, y: 1, z: 0 },
directions: [0],
maskType: '',
canstandon: false,
cansiton: false,
canlayon: false,
}
const root = parseXml(xml)
if (!root) return defaults
const model = findChild(root, 'model') || root
const dims = findChild(model, 'dimensions')
const dirsNode = findChild(model, 'directions')
const dimensions: LogicResult['dimensions'] = {
x: parseInt(dims?.attrs.x || '1', 10),
y: parseInt(dims?.attrs.y || '1', 10),
z: parseFloat(dims?.attrs.z || '0'),
}
if (dims?.attrs.centerZ) {
dimensions.centerZ = parseFloat(dims.attrs.centerZ)
}
const directions: number[] = []
if (dirsNode) {
for (const d of findChildren(dirsNode, 'direction')) {
directions.push(parseInt(d.attrs.id || '0', 10))
}
}
// Default directions [0, 90] when missing (matches Retrosprite behavior)
if (directions.length === 0) directions.push(0, 90)
// Parse mask type
const maskNode = findChild(root, 'mask')
const maskType = maskNode?.attrs.type || ''
// Infer canstandon/cansiton/canlayon from action nodes
const actions = findChildren(root, 'action')
const cansiton = actions.some((a) => a.attrs.kind === 'sit')
const canlayon = actions.some((a) => a.attrs.kind === 'lay')
const canstandon = !cansiton && !canlayon && dimensions.z > 0
const result: LogicResult = { dimensions, directions, maskType, canstandon, cansiton, canlayon }
// Parse action link/startState
const actionNode = findChild(root, 'action')
if (actionNode && (actionNode.attrs.link || actionNode.attrs.startState)) {
const action: LogicResult['action'] = {}
if (actionNode.attrs.link) action.link = actionNode.attrs.link
if (actionNode.attrs.startState) action.startState = parseInt(actionNode.attrs.startState, 10)
result.action = action
}
// Parse credits
const creditsNode = findChild(root, 'credits')
if (creditsNode?.attrs.value) {
result.credits = creditsNode.attrs.value
}
// Parse soundSample
const soundNode = findChild(root, 'sound')
if (soundNode) {
const sampleNode = findChild(soundNode, 'sample')
if (sampleNode) {
const sample: LogicResult['soundSample'] = { id: parseInt(sampleNode.attrs.id || '0', 10) }
if (sampleNode.attrs.noPitch === '1') sample.noPitch = true
result.soundSample = sample
}
}
// Parse customVars
const customVarsNode = findChild(root, 'customVars')
if (customVarsNode) {
const variables: string[] = []
for (const v of findChildren(customVarsNode, 'variable')) {
if (v.attrs.name) variables.push(v.attrs.name)
}
if (variables.length > 0) result.customVars = { variables }
}
// Parse particleSystems
const particleNode = findChild(root, 'particleSystems')
if (particleNode) {
const systems: Record<string, unknown>[] = []
for (const ps of findChildren(particleNode, 'particleSystem')) {
const system: Record<string, unknown> = {}
if (ps.attrs.size) system.size = parseInt(ps.attrs.size, 10)
if (ps.attrs.canvasId) system.canvasId = parseInt(ps.attrs.canvasId, 10)
if (ps.attrs.offsetY) system.offsetY = parseInt(ps.attrs.offsetY, 10)
if (ps.attrs.blend) system.blend = parseInt(ps.attrs.blend, 10)
if (ps.attrs.bgColor) system.bgColor = ps.attrs.bgColor
const emitters: Record<string, unknown>[] = []
for (const em of findChildren(ps, 'emitter')) {
const emitter: Record<string, unknown> = {}
if (em.attrs.id) emitter.id = parseInt(em.attrs.id, 10)
if (em.attrs.name) emitter.name = em.attrs.name
if (em.attrs.spriteId) emitter.spriteId = parseInt(em.attrs.spriteId, 10)
if (em.attrs.maxNumParticles)
emitter.maxNumParticles = parseInt(em.attrs.maxNumParticles, 10)
if (em.attrs.particlesPerFrame)
emitter.particlesPerFrame = parseInt(em.attrs.particlesPerFrame, 10)
if (em.attrs.burstPulse) emitter.burstPulse = parseInt(em.attrs.burstPulse, 10)
if (em.attrs.fuseTime) emitter.fuseTime = parseInt(em.attrs.fuseTime, 10)
const simNode = findChild(em, 'simulation')
if (simNode) {
const simulation: Record<string, unknown> = {}
if (simNode.attrs.force) simulation.force = parseFloat(simNode.attrs.force)
if (simNode.attrs.direction) simulation.direction = parseFloat(simNode.attrs.direction)
if (simNode.attrs.gravity) simulation.gravity = parseFloat(simNode.attrs.gravity)
if (simNode.attrs.airFriction)
simulation.airFriction = parseFloat(simNode.attrs.airFriction)
if (simNode.attrs.shape) simulation.shape = simNode.attrs.shape
if (simNode.attrs.energy) simulation.energy = parseFloat(simNode.attrs.energy)
emitter.simulation = simulation
}
const particles: Record<string, unknown>[] = []
for (const p of findChildren(em, 'particle')) {
const particle: Record<string, unknown> = {}
if (p.attrs.isEmitter === '1') particle.isEmitter = true
if (p.attrs.lifeTime) particle.lifeTime = parseInt(p.attrs.lifeTime, 10)
if (p.attrs.fade === '1') particle.fade = true
const pFrames: string[] = []
for (const pf of findChildren(p, 'frame')) {
if (pf.attrs.name) pFrames.push(pf.attrs.name)
}
if (pFrames.length > 0) particle.frames = pFrames
particles.push(particle)
}
if (particles.length > 0) emitter.particles = particles
emitters.push(emitter)
}
if (emitters.length > 0) system.emitters = emitters
systems.push(system)
}
if (systems.length > 0) result.particleSystems = systems
}
// Parse planetSystems
const planetNode = findChild(root, 'planetSystems')
if (planetNode) {
const systems: Record<string, unknown>[] = []
for (const ps of planetNode.children) {
const system: Record<string, unknown> = { ...ps.attrs }
// Convert numeric attrs
for (const key of Object.keys(system)) {
const val = system[key] as string
if (/^-?\d+(\.\d+)?$/.test(val)) system[key] = parseFloat(val)
}
systems.push(system)
}
if (systems.length > 0) result.planetSystems = systems
}
return result
}
export function processVisualization(xml: string): NitroVisualization[] {
const result: NitroVisualization[] = []
const root = parseXml(xml)
if (!root) return result
const graphics = findChild(root, 'graphics') || root
for (const viz of findChildren(graphics, 'visualization')) {
const size = parseInt(viz.attrs.size || '64', 10)
// Skip size 32 (small catalog icons) – only keep size 1 (special) and 64 (standard)
if (size === 32) continue
const layerCount = parseInt(viz.attrs.layerCount || '1', 10)
const angle = parseInt(viz.attrs.angle || '45', 10)
const entry: NitroVisualization = { layerCount, angle, size }
// Layers (before directions to match reference key order)
const layersNode = findChild(viz, 'layers')
if (layersNode) {
const layers: Record<string, Record<string, unknown>> = {}
for (const l of findChildren(layersNode, 'layer')) {
const id = l.attrs.id || '0'
const layerObj: Record<string, unknown> = {}
if (l.attrs.x) layerObj.x = parseInt(l.attrs.x, 10)
if (l.attrs.y) layerObj.y = parseInt(l.attrs.y, 10)
if (l.attrs.z) layerObj.z = parseInt(l.attrs.z, 10)
if (l.attrs.alpha) layerObj.alpha = parseInt(l.attrs.alpha, 10)
if (l.attrs.ink) layerObj.ink = l.attrs.ink
if (l.attrs.tag) layerObj.tag = l.attrs.tag
if (l.attrs.ignoreMouse) layerObj.ignoreMouse = l.attrs.ignoreMouse === '1'
layers[id] = layerObj
}
if (Object.keys(layers).length > 0) entry.layers = layers
}
// Directions
const dirsNode = findChild(viz, 'directions')
if (dirsNode) {
const dirs: Record<string, Record<string, unknown>> = {}
for (const d of findChildren(dirsNode, 'direction')) {
const id = d.attrs.id || '0'
const layersInDir = findChildren(d, 'layer')
if (layersInDir.length > 0) {
const layerData: Record<string, Record<string, unknown>> = {}
for (const l of layersInDir) {
const lid = l.attrs.id || '0'
const layerObj: Record<string, unknown> = {}
if (l.attrs.x) layerObj.x = parseInt(l.attrs.x, 10)
if (l.attrs.y) layerObj.y = parseInt(l.attrs.y, 10)
if (l.attrs.z) layerObj.z = parseFloat(l.attrs.z)
if (l.attrs.alpha) layerObj.alpha = parseInt(l.attrs.alpha, 10)
if (l.attrs.ink) layerObj.ink = l.attrs.ink
if (l.attrs.tag) layerObj.tag = l.attrs.tag
if (l.attrs.ignoreMouse) layerObj.ignoreMouse = l.attrs.ignoreMouse === '1'
layerData[lid] = layerObj
}
dirs[id] = { layers: layerData }
} else {
dirs[id] = {}
}
}
entry.directions = dirs
}
// Colors
const colorsNode = findChild(viz, 'colors')
if (colorsNode) {
const colors: Record<string, { layers: Record<string, { color: number }> }> = {}
for (const color of findChildren(colorsNode, 'color')) {
const cid = color.attrs.id || '0'
const colorLayers: Record<string, { color: number }> = {}
for (const cl of findChildren(color, 'colorLayer')) {
colorLayers[cl.attrs.id || '0'] = { color: parseInt(cl.attrs.color || '0', 16) }
}
colors[cid] = { layers: colorLayers }
}
if (Object.keys(colors).length > 0) entry.colors = colors
}
// Animations
const animsNode = findChild(viz, 'animations')
if (animsNode) {
const animations: Record<string, unknown> = {}
for (const anim of findChildren(animsNode, 'animation')) {
const aid = anim.attrs.id || '0'
const animLayers: Record<string, unknown> = {}
for (const al of findChildren(anim, 'animationLayer')) {
const alid = al.attrs.id || '0'
const frameSeqs: Record<string, unknown> = {}
for (const fs of findChildren(al, 'frameSequence')) {
const fsid = fs.attrs.id || Object.keys(frameSeqs).length.toString()
const frames: Record<string, Record<string, unknown>> = {}
let fi = 0
for (const f of findChildren(fs, 'frame')) {
const rawId = f.attrs.id || '0'
const frameObj: Record<string, unknown> = {
id: (rawId === 'NaN' ? 0 : parseInt(rawId, 10)) || 0,
}
if (f.attrs.x) frameObj.x = parseInt(f.attrs.x, 10)
if (f.attrs.y) frameObj.y = parseInt(f.attrs.y, 10)
if (f.attrs.randomX) frameObj.randomX = parseInt(f.attrs.randomX, 10)
if (f.attrs.randomY) frameObj.randomY = parseInt(f.attrs.randomY, 10)
// Per-direction offsets (keyed by sequential index, matching Retrosprite format)
const offsetNodes = findChildren(f, 'offset')
if (offsetNodes.length > 0) {
const offsets: Record<string, { direction: number; x: number; y: number }> = {}
for (let oi = 0; oi < offsetNodes.length; oi++) {
const o = offsetNodes[oi]
offsets[String(oi)] = {
direction: parseInt(o.attrs.direction || '0', 10),
x: parseInt(o.attrs.x || '0', 10),
y: parseInt(o.attrs.y || '0', 10),
}
}
frameObj.offsets = offsets
}
frames[String(fi)] = frameObj
fi++
}
const seqObj: Record<string, unknown> = { frames }
if (fs.attrs.loopCount) seqObj.loopCount = parseInt(fs.attrs.loopCount, 10)
if (fs.attrs.random) seqObj.random = parseInt(fs.attrs.random, 10)
frameSeqs[fsid] = seqObj
}
const layerObj: Record<string, unknown> = {
loopCount: parseInt(al.attrs.loopCount || '0', 10),
frameSequences: frameSeqs,
}
if (al.attrs.frameRepeat) layerObj.frameRepeat = parseInt(al.attrs.frameRepeat, 10)
if (al.attrs.random) layerObj.random = parseInt(al.attrs.random, 10)
animLayers[alid] = layerObj
}
const animObj: Record<string, unknown> = { layers: animLayers }
if (anim.attrs.transitionTo) animObj.transitionTo = parseInt(anim.attrs.transitionTo, 10)
if (anim.attrs.transitionFrom)
animObj.transitionFrom = parseInt(anim.attrs.transitionFrom, 10)
if (anim.attrs.immediateChangeFrom)
animObj.immediateChangeFrom = anim.attrs.immediateChangeFrom
if (anim.attrs.randomStart === '1') animObj.randomStart = true
animations[aid] = animObj
}
if (Object.keys(animations).length > 0) entry.animations = animations
}
// Postures
const posturesNode = findChild(viz, 'postures')
if (posturesNode) {
const posturesList: { id: string; animationId: number }[] = []
for (const p of findChildren(posturesNode, 'posture')) {
posturesList.push({
id: p.attrs.id || '',
animationId: parseInt(p.attrs.animationId || '0', 10),
})
}
const posturesObj: NitroVisualization['postures'] = {}
if (posturesNode.attrs.defaultPosture)
posturesObj.defaultPosture = posturesNode.attrs.defaultPosture
if (posturesList.length > 0) posturesObj.postures = posturesList
entry.postures = posturesObj
}
// Gestures
const gesturesNode = findChild(viz, 'gestures')
if (gesturesNode) {
const gesturesList: { id: string; animationId: number }[] = []
for (const g of findChildren(gesturesNode, 'gesture')) {
gesturesList.push({
id: g.attrs.id || '',
animationId: parseInt(g.attrs.animationId || '0', 10),
})
}
if (gesturesList.length > 0) entry.gestures = gesturesList
}
result.push(entry)
}
return result
}
// ── Metadata Helpers ────────────────────────────────────────────────
export function extractPartColors(visualizations: NitroVisualization[]): { color: string[] } {
const viz64 = visualizations.find((v) => v.size === 64)
if (!viz64?.colors) return { color: [] }
return { color: Object.keys(viz64.colors) }
}
export function computeDefaultDir(directions: number[]): number {
if (directions.includes(2)) return 2
if (directions.includes(0)) return 0
return directions[0] || 0
}
+24
View File
@@ -0,0 +1,24 @@
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'
/**
* Returns the set of user IDs that the given staff member has marked as
* "watch". Cached 60s + tag-invalidated by toggleUserWatch.
*/
export async function getWatchedUserIds(staffId: number): Promise<Set<number>> {
const ids = await getCachedWatchedIds(staffId)
return new Set(ids)
}
const 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'] },
)
+113
View File
@@ -0,0 +1,113 @@
import { env } from '@/env'
import { prisma } from '../prisma'
export type { WebhookAction } from '@/types/admin'
import type { WebhookAction } from '@/types/admin'
interface WebhookPayload {
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,
}
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 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 })
}
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 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,
})
}
}
/** Fire-and-forget notification to Discord + Telegram */
export function notify(payload: WebhookPayload): void {
sendDiscord(payload).catch(() => {})
sendTelegram(payload).catch(() => {})
}