fix: restore complete admin feature dependencies
This commit is contained in:
1 parent
5b4228261a
commit
0cd753c735
115 files changed
+18565
-4930
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import type { ListParams } from '@/types'
|
||||
|
||||
export const PER_PAGE_OPTIONS = [10, 20, 50] as const
|
||||
export const DEFAULT_PER_PAGE = 20
|
||||
|
||||
export function parseListParams(searchParams: URLSearchParams): ListParams {
|
||||
const search = searchParams.get('search') || ''
|
||||
const perPage = Math.min(
|
||||
Math.max(parseInt(searchParams.get('perPage') || String(DEFAULT_PER_PAGE), 10), 1),
|
||||
100,
|
||||
)
|
||||
const page = Math.max(parseInt(searchParams.get('page') || '1', 10), 1)
|
||||
const sort = searchParams.get('sort') || undefined
|
||||
const order = (searchParams.get('order') === 'asc' ? 'asc' : 'desc') as 'asc' | 'desc'
|
||||
|
||||
return { search, perPage, page, sort, order }
|
||||
}
|
||||
|
||||
export function calcPagination(total: number, page: number, perPage: number) {
|
||||
const lastPage = Math.max(Math.ceil(total / perPage), 1)
|
||||
return {
|
||||
total,
|
||||
page: Math.min(page, lastPage),
|
||||
perPage,
|
||||
lastPage,
|
||||
offset: (Math.min(page, lastPage) - 1) * perPage,
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate CSV content from rows */
|
||||
export function generateCsv(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: { key: string; label: string }[],
|
||||
): string {
|
||||
const BOM = '\uFEFF'
|
||||
const header = columns.map((c) => escapeCsv(c.label)).join(',')
|
||||
const body = rows
|
||||
.map((row) => columns.map((c) => escapeCsv(String(row[c.key] ?? ''))).join(','))
|
||||
.join('\n')
|
||||
|
||||
return `${BOM + header}\n${body}`
|
||||
}
|
||||
|
||||
function escapeCsv(value: string): string {
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts: number): string {
|
||||
if (!ts) return 'N/A'
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { calcPagination, parseListParams } from './admin-helpers'
|
||||
import { canAccess, getAdminContext, type PermissionSet } from './permissions'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
type PrismaModel = any
|
||||
|
||||
interface AdminListConfig<TRow> {
|
||||
/** Permission slug required to view this page */
|
||||
permission: string
|
||||
/** Prisma model name (e.g. 'user', 'ban', 'room') */
|
||||
model: string
|
||||
/** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */
|
||||
searchFields: string[]
|
||||
/** Default sort when no sort param provided. Default: { id: 'desc' } */
|
||||
defaultSort?: Record<string, 'asc' | 'desc'>
|
||||
/** Default items per page. Default: 20 */
|
||||
defaultPerPage?: number
|
||||
/** Prisma include clause for relations */
|
||||
include?: Record<string, unknown>
|
||||
/** Prisma select clause (mutually exclusive with include) */
|
||||
select?: Record<string, unknown>
|
||||
/** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */
|
||||
baseWhere?: Record<string, unknown>
|
||||
/**
|
||||
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
|
||||
* the raw string value from the URL and returns the WHERE fragment to merge.
|
||||
* Returning null/undefined drops the filter.
|
||||
*
|
||||
* @example
|
||||
* filterMap: {
|
||||
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
|
||||
* filter_online: (v) => ({ online: v }),
|
||||
* }
|
||||
*/
|
||||
filterMap?: Record<string, (value: string) => Record<string, unknown> | null | undefined>
|
||||
/** Transform raw Prisma row to the shape needed by the table component */
|
||||
mapRow?: (row: PrismaModel) => TRow
|
||||
}
|
||||
|
||||
interface AdminListResult<TRow> {
|
||||
rows: TRow[]
|
||||
total: number
|
||||
page: number
|
||||
perPage: number
|
||||
lastPage: number
|
||||
locale: string
|
||||
permissions: PermissionSet
|
||||
rank: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested Prisma WHERE condition from a dot-notation field path.
|
||||
* e.g. 'owner.username' => { owner: { username: { contains: search } } }
|
||||
* e.g. 'username' => { username: { contains: search } }
|
||||
*/
|
||||
function buildFieldCondition(fieldPath: string, search: string): Record<string, unknown> {
|
||||
const parts = fieldPath.split('.')
|
||||
if (parts.length === 1) {
|
||||
return { [parts[0]]: { contains: search } }
|
||||
}
|
||||
// Build nested object from right to left
|
||||
let result: Record<string, unknown> = { [parts[parts.length - 1]]: { contains: search } }
|
||||
for (let i = parts.length - 2; i >= 0; i--) {
|
||||
result = { [parts[i]]: result }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic admin list data fetcher.
|
||||
* Centralizes the common pattern: auth check, param parsing, WHERE/orderBy construction,
|
||||
* Prisma query with pagination, and row mapping.
|
||||
*/
|
||||
export async function fetchAdminList<TRow = PrismaModel>(
|
||||
config: AdminListConfig<TRow>,
|
||||
paramsPromise: Promise<{ locale: string }>,
|
||||
searchParamsPromise: Promise<Record<string, string>>,
|
||||
): Promise<AdminListResult<TRow>> {
|
||||
const { locale } = await paramsPromise
|
||||
const { session, permissions } = await getAdminContext()
|
||||
|
||||
if (!canAccess(permissions, config.permission, session.user.rank)) {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const rawParams = await searchParamsPromise
|
||||
const sp = new URLSearchParams(rawParams)
|
||||
const parsed = parseListParams(sp)
|
||||
const perPage = config.defaultPerPage
|
||||
? Number(rawParams.perPage) || config.defaultPerPage
|
||||
: parsed.perPage
|
||||
const { search, page, sort, order } = parsed
|
||||
|
||||
// Build WHERE clause
|
||||
const searchWhere =
|
||||
search && config.searchFields.length > 0
|
||||
? { OR: config.searchFields.map((field) => buildFieldCondition(field, search)) }
|
||||
: {}
|
||||
|
||||
// Apply URL filter_* params via filterMap
|
||||
const filterWhere: Record<string, unknown> = {}
|
||||
if (config.filterMap) {
|
||||
for (const [paramKey, build] of Object.entries(config.filterMap)) {
|
||||
const value = rawParams[paramKey]
|
||||
if (!value) continue
|
||||
const fragment = build(value)
|
||||
if (fragment) Object.assign(filterWhere, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
const where = {
|
||||
...(config.baseWhere ?? {}),
|
||||
...filterWhere,
|
||||
...searchWhere,
|
||||
}
|
||||
|
||||
// Build orderBy
|
||||
const orderBy = sort ? { [sort]: order } : (config.defaultSort ?? { id: 'desc' as const })
|
||||
|
||||
// Access Prisma model dynamically
|
||||
const delegate = (prisma as PrismaModel)[config.model]
|
||||
|
||||
const queryArgs: PrismaModel = {
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}
|
||||
|
||||
if (config.include) queryArgs.include = config.include
|
||||
if (config.select) queryArgs.select = config.select
|
||||
|
||||
const [rawRows, total] = await Promise.all([
|
||||
delegate.findMany(queryArgs),
|
||||
delegate.count({ where }),
|
||||
])
|
||||
|
||||
const pagination = calcPagination(total, page, perPage)
|
||||
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows
|
||||
|
||||
return {
|
||||
rows,
|
||||
...pagination,
|
||||
locale,
|
||||
permissions,
|
||||
rank: session.user.rank,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** All known catalog page layouts for Habbo/Arcturus. */
|
||||
export const CATALOG_LAYOUTS = [
|
||||
'default_3x3',
|
||||
'frontpage',
|
||||
'spaces_new',
|
||||
'recycler',
|
||||
'trophies',
|
||||
'pets',
|
||||
'pets2',
|
||||
'pets3',
|
||||
'soundmachine',
|
||||
'guilds',
|
||||
'info_loyalty',
|
||||
'info_duckets',
|
||||
'loyalty_vip_buy',
|
||||
'single_bundle',
|
||||
'club_buy',
|
||||
'marketplace',
|
||||
'badge_display',
|
||||
'room_bundle',
|
||||
'sold_ltd_items',
|
||||
] as const
|
||||
|
||||
export type CatalogLayout = (typeof CATALOG_LAYOUTS)[number]
|
||||
|
||||
/** Short human-readable description for each layout. Shown in the layout selector. */
|
||||
export const LAYOUT_DESCRIPTIONS: Record<CatalogLayout, string> = {
|
||||
default_3x3: 'Standard 3-column grid of items',
|
||||
frontpage: 'Featured landing page with large teasers',
|
||||
spaces_new: 'Wide 4-column grid used for themed spaces',
|
||||
recycler: 'Ecotron recycler exchange page',
|
||||
trophies: 'Trophy crafting form',
|
||||
pets: 'Legacy pet purchase page',
|
||||
pets2: 'Pet purchase page (v2)',
|
||||
pets3: 'Pet purchase page (v3)',
|
||||
soundmachine: 'Soundmachine track picker',
|
||||
guilds: 'Guild/group creation page',
|
||||
info_loyalty: 'Informational loyalty page (no items)',
|
||||
info_duckets: 'Informational duckets page (no items)',
|
||||
loyalty_vip_buy: 'HC/VIP loyalty purchase',
|
||||
single_bundle: 'Single prominent bundle tile',
|
||||
club_buy: 'HC subscription purchase page',
|
||||
marketplace: 'Marketplace entry page',
|
||||
badge_display: 'Badge showcase page',
|
||||
room_bundle: 'Room bundle purchase page',
|
||||
sold_ltd_items: 'Sold limited editions archive',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Italian translations for common Habbo catalog page captions.
|
||||
* Keys are lowercase versions of the English caption.
|
||||
* Add new entries as needed.
|
||||
*/
|
||||
export const CATALOG_IT: Record<string, string> = {
|
||||
// ── Main categories ───────────────────────────────────
|
||||
frontpage: 'Pagina Principale',
|
||||
'front page': 'Pagina Principale',
|
||||
furniture: 'Mobili',
|
||||
furni: 'Mobili',
|
||||
rare: 'Rari',
|
||||
rares: 'Rari',
|
||||
'super rares': 'Super Rari',
|
||||
'ultra rares': 'Ultra Rari',
|
||||
limited: 'Limitati',
|
||||
'limited edition': 'Edizione Limitata',
|
||||
ltd: 'Limitati',
|
||||
'new': 'Novità',
|
||||
'new furni': 'Nuovi Mobili',
|
||||
newest: 'Novità',
|
||||
|
||||
// ── Rooms & Spaces ────────────────────────────────────
|
||||
rooms: 'Stanze',
|
||||
'room bundles': 'Pacchetti Stanza',
|
||||
'room bundle': 'Pacchetto Stanza',
|
||||
spaces: 'Spazi',
|
||||
walls: 'Pareti',
|
||||
wall: 'Parete',
|
||||
floors: 'Pavimenti',
|
||||
floor: 'Pavimento',
|
||||
landscapes: 'Paesaggi',
|
||||
landscape: 'Paesaggio',
|
||||
wallpaper: 'Carta da Parati',
|
||||
wallpapers: 'Carte da Parati',
|
||||
|
||||
// ── Pets ──────────────────────────────────────────────
|
||||
pets: 'Animali',
|
||||
'pet accessories': 'Accessori Animali',
|
||||
'pet food': 'Cibo Animali',
|
||||
horses: 'Cavalli',
|
||||
dogs: 'Cani',
|
||||
cats: 'Gatti',
|
||||
crocodiles: 'Coccodrilli',
|
||||
terriers: 'Terrier',
|
||||
bears: 'Orsi',
|
||||
pigs: 'Maiali',
|
||||
lions: 'Leoni',
|
||||
rhinos: 'Rinoceronti',
|
||||
spiders: 'Ragni',
|
||||
turtles: 'Tartarughe',
|
||||
chickens: 'Galline',
|
||||
frogs: 'Rane',
|
||||
dragons: 'Draghi',
|
||||
monkeys: 'Scimmie',
|
||||
gnomes: 'Gnomi',
|
||||
monsters: 'Mostri',
|
||||
'monster plants': 'Piante Mostro',
|
||||
butterflies: 'Farfalle',
|
||||
bunnies: 'Coniglietti',
|
||||
pigeons: 'Piccioni',
|
||||
|
||||
// ── Economy ───────────────────────────────────────────
|
||||
credits: 'Crediti',
|
||||
diamonds: 'Diamanti',
|
||||
duckets: 'Duckets',
|
||||
pixels: 'Pixels',
|
||||
marketplace: 'Mercatino',
|
||||
recycler: 'Riciclatore',
|
||||
ecotron: 'Ecotron',
|
||||
|
||||
// ── Habbo Club ────────────────────────────────────────
|
||||
'habbo club': 'Club Habbo',
|
||||
hc: 'Club Habbo',
|
||||
vip: 'VIP',
|
||||
'club furni': 'Mobili Club',
|
||||
'club furniture': 'Mobili Club',
|
||||
'club offers': 'Offerte Club',
|
||||
membership: 'Abbonamento',
|
||||
|
||||
// ── Building ──────────────────────────────────────────
|
||||
building: 'Costruzione',
|
||||
'builders club': 'Club Costruttori',
|
||||
wired: 'Wired',
|
||||
'wired furni': 'Mobili Wired',
|
||||
teleports: 'Teletrasporti',
|
||||
rollers: 'Rulli',
|
||||
gates: 'Cancelli',
|
||||
switches: 'Interruttori',
|
||||
|
||||
// ── Decorations / Themes ──────────────────────────────
|
||||
decorations: 'Decorazioni',
|
||||
decoration: 'Decorazione',
|
||||
plants: 'Piante',
|
||||
lighting: 'Illuminazione',
|
||||
lights: 'Luci',
|
||||
candles: 'Candele',
|
||||
kitchen: 'Cucina',
|
||||
bathroom: 'Bagno',
|
||||
bedroom: 'Camera da Letto',
|
||||
'living room': 'Soggiorno',
|
||||
garden: 'Giardino',
|
||||
outdoor: 'Esterno',
|
||||
office: 'Ufficio',
|
||||
study: 'Studio',
|
||||
music: 'Musica',
|
||||
sound: 'Suoni',
|
||||
'sound machine': 'Jukebox',
|
||||
|
||||
// ── Collections / Series ──────────────────────────────
|
||||
gothic: 'Gotico',
|
||||
executive: 'Executive',
|
||||
mode: 'Moda',
|
||||
iced: 'Ghiacciato',
|
||||
pura: 'Pura',
|
||||
lodge: 'Baita',
|
||||
plastic: 'Plastica',
|
||||
area: 'Area',
|
||||
asian: 'Asiatico',
|
||||
candy: 'Caramelle',
|
||||
chrome: 'Cromo',
|
||||
country: 'Country',
|
||||
diner: 'Tavola Calda',
|
||||
fairy: 'Fatato',
|
||||
greek: 'Greco',
|
||||
haunted: 'Stregato',
|
||||
hollywood: 'Hollywood',
|
||||
japanese: 'Giapponese',
|
||||
jungle: 'Giungla',
|
||||
love: 'Amore',
|
||||
marble: 'Marmo',
|
||||
medieval: 'Medievale',
|
||||
neon: 'Neon',
|
||||
prairie: 'Prateria',
|
||||
romantic: 'Romantico',
|
||||
'sci-fi': 'Fantascienza',
|
||||
sport: 'Sport',
|
||||
sports: 'Sport',
|
||||
street: 'Strada',
|
||||
tropical: 'Tropicale',
|
||||
victorian: 'Vittoriano',
|
||||
vintage: 'Vintage',
|
||||
winter: 'Inverno',
|
||||
summer: 'Estate',
|
||||
spring: 'Primavera',
|
||||
autumn: 'Autunno',
|
||||
fall: 'Autunno',
|
||||
christmas: 'Natale',
|
||||
xmas: 'Natale',
|
||||
easter: 'Pasqua',
|
||||
halloween: 'Halloween',
|
||||
valentine: 'San Valentino',
|
||||
valentines: 'San Valentino',
|
||||
'st patricks': 'San Patrizio',
|
||||
|
||||
// ── Games & Activities ────────────────────────────────
|
||||
games: 'Giochi',
|
||||
game: 'Gioco',
|
||||
'battle banzai': 'Battaglia Banzai',
|
||||
freeze: 'Freeze',
|
||||
football: 'Calcio',
|
||||
snowstorm: 'Tempesta di Neve',
|
||||
'roller skating': 'Pattinaggio',
|
||||
trophies: 'Trofei',
|
||||
trophy: 'Trofeo',
|
||||
prizes: 'Premi',
|
||||
rewards: 'Ricompense',
|
||||
|
||||
// ── Badges & Clothing ────────────────────────────────
|
||||
badges: 'Distintivi',
|
||||
badge: 'Distintivo',
|
||||
clothing: 'Abbigliamento',
|
||||
clothes: 'Vestiti',
|
||||
effects: 'Effetti',
|
||||
dances: 'Balli',
|
||||
|
||||
// ── Bots & Features ──────────────────────────────────
|
||||
bots: 'Bot',
|
||||
bot: 'Bot',
|
||||
groups: 'Gruppi',
|
||||
guilds: 'Gilde',
|
||||
guild: 'Gilda',
|
||||
'group furni': 'Mobili Gruppo',
|
||||
|
||||
// ── Misc ──────────────────────────────────────────────
|
||||
offers: 'Offerte',
|
||||
specials: 'Speciali',
|
||||
special: 'Speciale',
|
||||
deals: 'Occasioni',
|
||||
sale: 'Saldi',
|
||||
bundles: 'Pacchetti',
|
||||
bundle: 'Pacchetto',
|
||||
promotions: 'Promozioni',
|
||||
promo: 'Promozione',
|
||||
info: 'Informazioni',
|
||||
help: 'Aiuto',
|
||||
loyalty: 'Fedeltà',
|
||||
'loyalty rewards': 'Premi Fedeltà',
|
||||
gift: 'Regalo',
|
||||
gifts: 'Regali',
|
||||
'gift shop': 'Negozio Regali',
|
||||
'credit furni': 'Mobili Crediti',
|
||||
exchange: 'Cambio',
|
||||
'sold out': 'Esaurito',
|
||||
'coming soon': 'In Arrivo',
|
||||
all: 'Tutto',
|
||||
search: 'Cerca',
|
||||
featured: 'In Evidenza',
|
||||
popular: 'Popolari',
|
||||
'top picks': 'Le Migliori',
|
||||
trending: 'Tendenze',
|
||||
collections: 'Collezioni',
|
||||
collection: 'Collezione',
|
||||
'imported furni': 'Mobili Importati',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Italian translation for a catalog caption.
|
||||
* Returns the translated name if found, otherwise null.
|
||||
*/
|
||||
export function translateCaption(caption: string): string | null {
|
||||
const key = caption.toLowerCase().trim()
|
||||
return CATALOG_IT[key] ?? null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Module-level cache for the song picker options, shared between the
|
||||
* catalog-items table (where the picker lives) and the Sounds admin page
|
||||
* (which invalidates after an upload/edit/delete).
|
||||
*
|
||||
* Living in `src/lib/` (outside any `[dynamic]` route folder) avoids
|
||||
* cross-route client imports that confused Turbopack's HMR boundaries
|
||||
* and triggered `Router action dispatched before initialization` on
|
||||
* the Sounds page in dev.
|
||||
*/
|
||||
|
||||
export interface SoundtrackOption {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
author: string
|
||||
length: number
|
||||
}
|
||||
|
||||
let cachedOptions: SoundtrackOption[] | null = null
|
||||
let inflight: Promise<SoundtrackOption[]> | null = null
|
||||
|
||||
export async function loadSongPickerOptions(): Promise<SoundtrackOption[]> {
|
||||
if (cachedOptions) return cachedOptions
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
const res = await fetch('/api/admin/sounds', { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error('Failed to load soundtracks')
|
||||
const json = (await res.json()) as { ok: boolean; items?: SoundtrackOption[] }
|
||||
cachedOptions = json.items ?? []
|
||||
inflight = null
|
||||
return cachedOptions
|
||||
})()
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function getCachedSongPickerOptions(): SoundtrackOption[] | null {
|
||||
return cachedOptions
|
||||
}
|
||||
|
||||
/** Clear the cache so new uploads show up on next open. */
|
||||
export function invalidateSongPickerCache(): void {
|
||||
cachedOptions = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Whitelist of Nitro client localization files exposed in
|
||||
// /admin/translations/client. The registry doubles as a path-traversal guard
|
||||
// for the saveClientTranslations server action: only `id`s defined here can be
|
||||
// written to.
|
||||
|
||||
export type ClientTranslationFormat = 'json5' | 'json'
|
||||
|
||||
export interface ClientTranslationFile {
|
||||
id: string
|
||||
/** Path relative to the project root. */
|
||||
relPath: string
|
||||
format: ClientTranslationFormat
|
||||
language: 'it' | 'en' | 'shared'
|
||||
readOnly: boolean
|
||||
/**
|
||||
* True when the on-disk file commonly contains comments that the json5
|
||||
* serializer cannot preserve. Used to surface a warning in the UI.
|
||||
*/
|
||||
hasComments: boolean
|
||||
/** Free-form note shown in the UI (e.g. why a file is read-only). */
|
||||
note?: string
|
||||
}
|
||||
|
||||
export const CLIENT_TRANSLATION_FILES: ClientTranslationFile[] = [
|
||||
{
|
||||
id: 'ui-texts-it',
|
||||
relPath: 'public/nitro-assets/config/UITexts.json5',
|
||||
format: 'json5',
|
||||
language: 'it',
|
||||
readOnly: false,
|
||||
hasComments: true,
|
||||
note: 'Override del client (sovrascrive ExternalTexts).',
|
||||
},
|
||||
{
|
||||
id: 'ui-texts-en',
|
||||
relPath: 'public/nitro-assets/config/UITexts_en.json5',
|
||||
format: 'json5',
|
||||
language: 'en',
|
||||
readOnly: true,
|
||||
hasComments: true,
|
||||
note: 'Riferimento EN — non viene caricato dal client.',
|
||||
},
|
||||
{
|
||||
id: 'external-texts',
|
||||
relPath: 'public/nitro-assets/config/ExternalTexts.json5',
|
||||
format: 'json5',
|
||||
language: 'shared',
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: 'external-texts-badges',
|
||||
relPath: 'public/nitro-assets/config/ExternalTexts_Badges.json5',
|
||||
format: 'json5',
|
||||
language: 'shared',
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
note: 'File grande (~30k voci): usa la ricerca.',
|
||||
},
|
||||
{
|
||||
id: 'catalog-texts',
|
||||
relPath: 'public/nitro-assets/config/CatalogTexts.json5',
|
||||
format: 'json5',
|
||||
language: 'shared',
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: 'gamedata-external-texts',
|
||||
relPath: 'public/nitro-assets/gamedata/ExternalTexts.json',
|
||||
format: 'json',
|
||||
language: 'shared',
|
||||
readOnly: true,
|
||||
hasComments: false,
|
||||
note: 'Copia "live" servita al client. Generata dal build, modifiche manuali sovrascritte.',
|
||||
},
|
||||
{
|
||||
id: 'badge-texts-en',
|
||||
relPath: 'public/nitro3/localization/badge-texts-en.json',
|
||||
format: 'json',
|
||||
language: 'en',
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
{
|
||||
id: 'badge-texts-it',
|
||||
relPath: 'public/nitro3/localization/badge-texts-it.json',
|
||||
format: 'json',
|
||||
language: 'it',
|
||||
readOnly: false,
|
||||
hasComments: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function getClientTranslationFile(id: string): ClientTranslationFile | undefined {
|
||||
return CLIENT_TRANSLATION_FILES.find((f) => f.id === id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const CLASSNAME_CHAR_RE = /^[a-z0-9_*\-.]+$/
|
||||
|
||||
export function getBaseClassname(classname: string): string {
|
||||
const star = classname.indexOf('*')
|
||||
return star >= 0 ? classname.substring(0, star) : classname
|
||||
}
|
||||
|
||||
export function getSafeClassnameStem(classname: string): string {
|
||||
return classname.replace(/\*/g, '_')
|
||||
}
|
||||
|
||||
export function getIconFileName(classname: string): string {
|
||||
return `${getSafeClassnameStem(classname)}_icon.png`
|
||||
}
|
||||
|
||||
export function getSwfFileName(classname: string): string {
|
||||
return `${getBaseClassname(classname)}.swf`
|
||||
}
|
||||
|
||||
export function getNitroFileName(classname: string): string {
|
||||
return `${getBaseClassname(classname)}.nitro`
|
||||
}
|
||||
|
||||
export function normalizeClassname(raw: string): string {
|
||||
return raw.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isValidClassname(classname: string): boolean {
|
||||
if (!classname) return false
|
||||
return CLASSNAME_CHAR_RE.test(classname)
|
||||
}
|
||||
|
||||
export const LOCAL_ICON_URL_PREFIX = '/swf/dcr/hof_furni/icons/'
|
||||
|
||||
export function getLocalIconUrl(classname: string): string {
|
||||
return `${LOCAL_ICON_URL_PREFIX}${encodeURIComponent(getIconFileName(classname))}`
|
||||
}
|
||||
|
||||
export function getHabboCdnIconUrl(classname: string, revision: number): string {
|
||||
const base = getBaseClassname(classname)
|
||||
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Centralized Habbo avatar imager utility.
|
||||
* Points to the self-hosted imager API (`/api/imaging/avatar`) which handles
|
||||
* disk + memory caching and (Phase 2) local rendering from .nitro bundles.
|
||||
* Override with NEXT_PUBLIC_IMAGER_URL when needed (e.g. CDN in front).
|
||||
*/
|
||||
|
||||
const IMAGER_URL = process.env.NEXT_PUBLIC_IMAGER_URL || '/api/imaging/avatar'
|
||||
|
||||
export type { AvatarOptions } from '@/types/admin'
|
||||
|
||||
import type { AvatarOptions } from '@/types/admin'
|
||||
|
||||
/**
|
||||
* Build an avatar image URL served by the self-hosted imager.
|
||||
*
|
||||
* @example
|
||||
* getAvatarUrl('hr-893-45.hd-180-1.ch-210-66.lg-270-82')
|
||||
* getAvatarUrl(look, { size: 'l', headOnly: true })
|
||||
* getAvatarUrl(look, { size: 'm', gesture: 'sml', direction: 2 })
|
||||
*/
|
||||
export function getAvatarUrl(figure: string, options: AvatarOptions = {}): string {
|
||||
const {
|
||||
size = 'm',
|
||||
direction = 2,
|
||||
headDirection = 3,
|
||||
headOnly = false,
|
||||
gesture,
|
||||
action,
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams({
|
||||
figure,
|
||||
direction: String(direction),
|
||||
head_direction: String(headDirection),
|
||||
size,
|
||||
})
|
||||
|
||||
if (headOnly) params.set('headonly', '1')
|
||||
if (gesture) params.set('gesture', gesture)
|
||||
if (action) params.set('action', action)
|
||||
|
||||
return `${IMAGER_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Surgical line-level update for JSON5 files that preserves comments and
|
||||
// formatting. Used by saveClientTranslations so that the Nitro localization
|
||||
// files keep their headers and section markers when an admin edits a value.
|
||||
//
|
||||
// Assumptions (true for every Nitro `.json5` file in the repo):
|
||||
// * one entry per line, formatted as `<key>: <value>,`
|
||||
// * keys are simple strings (single- or double-quoted, or bare)
|
||||
// * values are strings (single- or double-quoted)
|
||||
// If a changed key cannot be located via this contract, we report it back so
|
||||
// the caller can decide whether to fall back to a full re-serialization.
|
||||
|
||||
export interface PatchResult {
|
||||
/** The new file content, with surgical edits applied. */
|
||||
content: string
|
||||
/** Keys that we could not patch surgically (missing or unusual format). */
|
||||
unpatchedKeys: string[]
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function escapeForQuote(value: string, quote: '"' | "'"): string {
|
||||
// Escape backslashes first, then the chosen quote. Newlines/tabs are
|
||||
// preserved as escape sequences so we don't break the JSON5 parser.
|
||||
let out = value.replace(/\\/g, '\\\\')
|
||||
out = out.replace(new RegExp(quote, 'g'), `\\${quote}`)
|
||||
out = out.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply per-key value updates to a JSON5 source string while preserving
|
||||
* comments and formatting. Only keys present in `original` (and with a
|
||||
* different value in `updated`) are touched.
|
||||
*/
|
||||
export function patchJson5(
|
||||
raw: string,
|
||||
original: Record<string, string>,
|
||||
updated: Record<string, string>,
|
||||
): PatchResult {
|
||||
let result = raw
|
||||
const unpatchedKeys: string[] = []
|
||||
|
||||
for (const [key, newVal] of Object.entries(updated)) {
|
||||
if (!(key in original)) {
|
||||
// Key did not exist on disk — append/merge logic is the caller's job.
|
||||
unpatchedKeys.push(key)
|
||||
continue
|
||||
}
|
||||
if (original[key] === newVal) continue
|
||||
|
||||
const keyPattern = `(['"]?)${escapeRegExp(key)}\\1\\s*:\\s*(["'])((?:\\\\.|(?!\\2).)*)\\2`
|
||||
const re = new RegExp(keyPattern)
|
||||
const idx = result.search(re)
|
||||
if (idx < 0) {
|
||||
unpatchedKeys.push(key)
|
||||
continue
|
||||
}
|
||||
const match = re.exec(result)
|
||||
if (!match) {
|
||||
unpatchedKeys.push(key)
|
||||
continue
|
||||
}
|
||||
|
||||
const valueQuote = match[2] as '"' | "'"
|
||||
const oldValueLen = match[3].length
|
||||
const fullLen = match[0].length
|
||||
// prefix keeps everything up to and including the opening value quote
|
||||
const prefix = match[0].slice(0, fullLen - oldValueLen - 1)
|
||||
const escaped = escapeForQuote(newVal, valueQuote)
|
||||
const replacement = prefix + escaped + valueQuote
|
||||
|
||||
result = result.slice(0, idx) + replacement + result.slice(idx + fullLen)
|
||||
}
|
||||
|
||||
return { content: result, unpatchedKeys }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface MissingLocalImport {
|
||||
}
|
||||
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
||||
const resolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
|
||||
const resolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
|
||||
const importPattern = /(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|require\(\s*["']([^"']+)["']\s*\)/g;
|
||||
|
||||
async function collectSourceFiles(directory: string): Promise<string[]> {
|
||||
@@ -16,6 +16,7 @@ async function collectSourceFiles(directory: string): Promise<string[]> {
|
||||
entries.map(async (entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return collectSourceFiles(path);
|
||||
if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) return [];
|
||||
return sourceExtensions.has(extname(entry.name)) ? [path] : [];
|
||||
}),
|
||||
);
|
||||
@@ -32,7 +33,7 @@ async function exists(path: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function resolvesToLocalFile(base: string): Promise<boolean> {
|
||||
if (sourceExtensions.has(extname(base)) && (await exists(base))) return true;
|
||||
if (extname(base) && (await exists(base))) return true;
|
||||
for (const suffix of resolutionSuffixes) {
|
||||
if (await exists(`${base}${suffix}`)) return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Learns where items get moved and suggests target pages for future moves.
|
||||
* Stores patterns in localStorage:
|
||||
* - By interaction type (e.g. "clothing" → page #42)
|
||||
* - By item name prefix (e.g. "rare_" → page #15)
|
||||
* - Recent moves (last 10)
|
||||
*
|
||||
* All suggestions are non-forced — just ranked higher in the combobox.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'catalog_move_suggestions'
|
||||
|
||||
interface MoveRecord {
|
||||
targetPageId: number
|
||||
count: number
|
||||
}
|
||||
|
||||
interface SuggestionStore {
|
||||
/** interaction type → target pages with count */
|
||||
byType: Record<string, MoveRecord[]>
|
||||
/** item name prefix (first segment before _) → target pages with count */
|
||||
byPrefix: Record<string, MoveRecord[]>
|
||||
/** last N recent targets */
|
||||
recent: number[]
|
||||
}
|
||||
|
||||
function load(): SuggestionStore {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch {}
|
||||
return { byType: {}, byPrefix: {}, recent: [] }
|
||||
}
|
||||
|
||||
function save(store: SuggestionStore) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function extractPrefix(itemName: string): string {
|
||||
if (!itemName) return ''
|
||||
const idx = itemName.indexOf('_')
|
||||
return idx > 0 ? itemName.substring(0, idx).toLowerCase() : itemName.toLowerCase()
|
||||
}
|
||||
|
||||
function incrementRecord(records: MoveRecord[], targetPageId: number): MoveRecord[] {
|
||||
const existing = records.find((r) => r.targetPageId === targetPageId)
|
||||
if (existing) {
|
||||
return records
|
||||
.map((r) => (r.targetPageId === targetPageId ? { ...r, count: r.count + 1 } : r))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
return [...records, { targetPageId, count: 1 }].sort((a, b) => b.count - a.count).slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a move for learning. Call this after successfully moving an item.
|
||||
*/
|
||||
export function recordMove(opts: {
|
||||
interactionType?: string
|
||||
itemName?: string
|
||||
targetPageId: number
|
||||
}) {
|
||||
const store = load()
|
||||
const { interactionType, itemName, targetPageId } = opts
|
||||
|
||||
if (interactionType) {
|
||||
const key = interactionType.toLowerCase()
|
||||
store.byType[key] = incrementRecord(store.byType[key] || [], targetPageId)
|
||||
}
|
||||
|
||||
const prefix = extractPrefix(itemName ?? '')
|
||||
if (prefix && prefix.length > 1) {
|
||||
store.byPrefix[prefix] = incrementRecord(store.byPrefix[prefix] || [], targetPageId)
|
||||
}
|
||||
|
||||
// Recent (deduplicate, keep last 10)
|
||||
store.recent = [targetPageId, ...store.recent.filter((id) => id !== targetPageId)].slice(0, 10)
|
||||
|
||||
save(store)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested page IDs for moving an item, ranked by relevance.
|
||||
* Returns unique page IDs ordered by best match.
|
||||
*/
|
||||
export function getSuggestions(opts: { interactionType?: string; itemName?: string }): number[] {
|
||||
const store = load()
|
||||
const { interactionType, itemName } = opts
|
||||
const scores = new Map<number, number>()
|
||||
|
||||
// By interaction type (highest weight)
|
||||
if (interactionType) {
|
||||
const records = store.byType[interactionType.toLowerCase()] || []
|
||||
for (const r of records) {
|
||||
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 3)
|
||||
}
|
||||
}
|
||||
|
||||
// By name prefix
|
||||
const prefix = extractPrefix(itemName ?? '')
|
||||
if (prefix && prefix.length > 1) {
|
||||
const records = store.byPrefix[prefix] || []
|
||||
for (const r of records) {
|
||||
scores.set(r.targetPageId, (scores.get(r.targetPageId) ?? 0) + r.count * 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Recent (lowest weight)
|
||||
for (let i = 0; i < store.recent.length; i++) {
|
||||
const id = store.recent[i]
|
||||
scores.set(id, (scores.get(id) ?? 0) + (10 - i))
|
||||
}
|
||||
|
||||
return Array.from(scores.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([id]) => id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// ── Permission Slug Constants ───────────────────────────────────────
|
||||
// Extracted to its own file so it can be imported by client components
|
||||
// (modules.ts → sidebar.tsx) without pulling in prisma/server-only deps.
|
||||
|
||||
export const PERMS = {
|
||||
ADMIN_DASHBOARD: 'admin.dashboard',
|
||||
USERS_VIEW: 'admin.users.view',
|
||||
USERS_EDIT: 'admin.users.edit',
|
||||
USERS_BAN: 'admin.users.ban',
|
||||
USERS_RESET_PASSWORD: 'admin.users.reset_password',
|
||||
ROOMS_VIEW: 'admin.room.view',
|
||||
ROOMS_EDIT: 'admin.room.edit',
|
||||
ROOMS_DELETE: 'admin.room.delete',
|
||||
BANS_VIEW: 'admin.bans.view',
|
||||
NEWS_VIEW: 'admin.news.view',
|
||||
NEWS_EDIT: 'admin.news.edit',
|
||||
LOGS_VIEW: 'admin.logs.view',
|
||||
SETTINGS_VIEW: 'admin.settings.view',
|
||||
SETTINGS_EDIT: 'admin.settings.edit',
|
||||
PERMISSIONS_MANAGE: 'admin.permissions.manage',
|
||||
SHOP_VIEW: 'admin.shop.view',
|
||||
SHOP_EDIT: 'admin.shop.edit',
|
||||
WORDFILTER_VIEW: 'admin.wordfilter.view',
|
||||
WORDFILTER_EDIT: 'admin.wordfilter.edit',
|
||||
CATALOG_VIEW: 'admin.catalog.view',
|
||||
CATALOG_EDIT: 'admin.catalog.edit',
|
||||
RCON_EXECUTE: 'admin.rcon.execute',
|
||||
EXPORT: 'admin.export',
|
||||
PREFIXES_VIEW: 'admin.prefixes.view',
|
||||
PREFIXES_EDIT: 'admin.prefixes.edit',
|
||||
TICKETS_VIEW: 'admin.tickets.view',
|
||||
TICKETS_EDIT: 'admin.tickets.edit',
|
||||
MODERATION_VIEW: 'admin.moderation.view',
|
||||
MODERATION_EDIT: 'admin.moderation.edit',
|
||||
// ── Events Module ──
|
||||
EVENTS_VIEW: 'admin.events.view',
|
||||
EVENTS_EDIT: 'admin.events.edit',
|
||||
// ── Analytics Module ──
|
||||
ANALYTICS_VIEW: 'admin.analytics.view',
|
||||
ANALYTICS_EXPORT: 'admin.analytics.export',
|
||||
// ── DevOps Module ──
|
||||
DEVOPS_VIEW: 'admin.devops.view',
|
||||
DEVOPS_EDIT: 'admin.devops.edit',
|
||||
// ── Polls Module ──
|
||||
POLLS_VIEW: 'admin.polls.view',
|
||||
POLLS_EDIT: 'admin.polls.edit',
|
||||
// ── Notifications Module ──
|
||||
NOTIFICATIONS_VIEW: 'admin.notifications.view',
|
||||
NOTIFICATIONS_EDIT: 'admin.notifications.edit',
|
||||
// ── CMS Pages Module ──
|
||||
PAGES_VIEW: 'admin.pages.view',
|
||||
PAGES_EDIT: 'admin.pages.edit',
|
||||
// ── Banners Module ──
|
||||
BANNERS_VIEW: 'admin.banners.view',
|
||||
BANNERS_EDIT: 'admin.banners.edit',
|
||||
// ── Assets Module ──
|
||||
ASSETS_IMPORT: 'admin.assets.import',
|
||||
// ── Mod Panel Permissions ──
|
||||
MOD_DASHBOARD: 'mod.dashboard',
|
||||
MOD_CFH_VIEW: 'mod.cfh.view',
|
||||
MOD_CFH_EDIT: 'mod.cfh.edit',
|
||||
MOD_ACTIONS: 'mod.actions',
|
||||
MOD_TEAM_VIEW: 'mod.team.view',
|
||||
MOD_TICKETS_VIEW: 'mod.tickets.view',
|
||||
MOD_TICKETS_EDIT: 'mod.tickets.edit',
|
||||
MOD_USERS_VIEW: 'mod.users.view',
|
||||
MOD_BANS_VIEW: 'mod.bans.view',
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { cache } from 'react'
|
||||
import { auth } from './auth'
|
||||
import { sessionUserId } from './auth/session-user'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
// Re-export PERMS from the standalone file (safe for client components)
|
||||
export { PERMS } from './permission-slugs'
|
||||
|
||||
import { PERMS } from './permission-slugs'
|
||||
|
||||
// ── Permission Set ──────────────────────────────────────────────────
|
||||
|
||||
export type { PermissionSet } from '@/types/admin'
|
||||
|
||||
import type { PermissionSet } from '@/types/admin'
|
||||
|
||||
function createEmptySet(): PermissionSet {
|
||||
return {
|
||||
has: () => false,
|
||||
hasAny: () => false,
|
||||
hasAll: () => false,
|
||||
isSuperAdmin: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all permission slugs for a user in a single SQL query.
|
||||
* Cached via unstable_cache with 60s TTL — invalidated via revalidateTag('permissions').
|
||||
*/
|
||||
const getCachedPermissionSlugs = unstable_cache(
|
||||
async (userId: number, rank: number): Promise<string[]> => {
|
||||
const rows = await prisma.$queryRaw<{ slug: string }[]>`
|
||||
SELECT DISTINCT p.slug
|
||||
FROM acl_model_permissions mp
|
||||
JOIN acl_permissions p ON p.id = mp.permission_id
|
||||
WHERE mp.model_type = 'Role'
|
||||
AND mp.model_id IN (
|
||||
SELECT amr.role_id
|
||||
FROM acl_model_roles amr
|
||||
WHERE amr.model_type = 'User' AND amr.model_id = ${userId}
|
||||
UNION
|
||||
SELECT ar.id
|
||||
FROM acl_roles ar
|
||||
WHERE ar.slug = ${`rank_${rank}`}
|
||||
)
|
||||
`
|
||||
return rows.map((r) => r.slug)
|
||||
},
|
||||
['user-permissions'],
|
||||
{ revalidate: 60, tags: ['permissions'] },
|
||||
)
|
||||
|
||||
/**
|
||||
* Load permission slugs for a user. Rank is taken from the JWT session
|
||||
* to avoid an extra DB query. Super admin (rank >= 7) bypasses all checks.
|
||||
* Wrapped with React cache() to de-duplicate within the same request.
|
||||
*/
|
||||
export const loadUserPermissions = cache(async function loadUserPermissions(
|
||||
userId: number,
|
||||
rank: number,
|
||||
): Promise<PermissionSet> {
|
||||
try {
|
||||
// Super admin bypasses all permission checks — zero DB queries
|
||||
if (rank >= 7) {
|
||||
return {
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
isSuperAdmin: true,
|
||||
}
|
||||
}
|
||||
|
||||
const slugArray = await getCachedPermissionSlugs(userId, rank)
|
||||
if (slugArray.length === 0) return createEmptySet()
|
||||
|
||||
const slugs = new Set(slugArray)
|
||||
return {
|
||||
has: (perm: string) => slugs.has(perm),
|
||||
hasAny: (...perms: string[]) => perms.some((p) => slugs.has(p)),
|
||||
hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)),
|
||||
isSuperAdmin: false,
|
||||
}
|
||||
} catch {
|
||||
// Fail-closed: return empty set on any error
|
||||
return createEmptySet()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Context Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* For server components: get session + load permissions.
|
||||
* Redirects to login if not authenticated.
|
||||
*/
|
||||
export async function getAdminContext() {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const userId = sessionUserId(session.user.id)
|
||||
if (!userId) redirect('/login')
|
||||
const permissions = await loadUserPermissions(userId, session.user.rank)
|
||||
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
|
||||
}
|
||||
|
||||
/**
|
||||
* For API routes: get session + load permissions.
|
||||
* Returns null if not authenticated (caller handles 401).
|
||||
*/
|
||||
export async function getApiAdminContext() {
|
||||
const session = await auth()
|
||||
if (!session?.user) return null
|
||||
|
||||
const userId = sessionUserId(session.user.id)
|
||||
if (!userId) return null
|
||||
const permissions = await loadUserPermissions(userId, session.user.rank)
|
||||
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission.
|
||||
* Super admins (rank >= 7) bypass all checks via PermissionSet.isSuperAdmin.
|
||||
* Rank >= 6 gets fallback access to admin dashboard and admin .view permissions.
|
||||
* Rank >= 3 gets fallback access to mod dashboard and mod .view permissions.
|
||||
*/
|
||||
export function canAccess(permissions: PermissionSet, slug: string, rank: number): boolean {
|
||||
if (permissions.has(slug)) return true
|
||||
// Rank 6+ fallback: admin dashboard and admin view-only permissions
|
||||
if (
|
||||
rank >= 6 &&
|
||||
(slug === PERMS.ADMIN_DASHBOARD || (slug.startsWith('admin.') && slug.endsWith('.view')))
|
||||
)
|
||||
return true
|
||||
// Rank 3+ fallback: mod dashboard and mod view-only permissions + mod actions
|
||||
if (
|
||||
rank >= 3 &&
|
||||
(slug === PERMS.MOD_DASHBOARD ||
|
||||
(slug.startsWith('mod.') && slug.endsWith('.view')) ||
|
||||
slug === PERMS.MOD_ACTIONS)
|
||||
)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* For mod panel server components: get session + load permissions.
|
||||
* Redirects to login if not authenticated, to / if rank < 3.
|
||||
*/
|
||||
export async function getModContext() {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (session.user.rank < 3) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const userId = sessionUserId(session.user.id)
|
||||
if (!userId) redirect('/login')
|
||||
const permissions = await loadUserPermissions(userId, session.user.rank)
|
||||
return { session: { ...session, user: { ...session.user, id: userId } }, permissions }
|
||||
}
|
||||
|
||||
// ── Legacy single-check functions (kept for backward compatibility) ──
|
||||
|
||||
/** Check if a user has a CMS permission. Rank >= 7 bypasses all checks. */
|
||||
export async function checkPermission(
|
||||
userId: number,
|
||||
rank: number,
|
||||
permission: string,
|
||||
): Promise<boolean> {
|
||||
const perms = await loadUserPermissions(userId, rank)
|
||||
return perms.has(permission)
|
||||
}
|
||||
|
||||
/** Check multiple permissions (user needs ALL of them) */
|
||||
export async function checkAllPermissions(
|
||||
userId: number,
|
||||
rank: number,
|
||||
permissions: string[],
|
||||
): Promise<boolean> {
|
||||
const perms = await loadUserPermissions(userId, rank)
|
||||
return perms.hasAll(...permissions)
|
||||
}
|
||||
|
||||
/** Check if user has admin access */
|
||||
export async function hasAdminAccess(userId: number, rank: number): Promise<boolean> {
|
||||
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
// ── Action Result Type ───────────────────────────────────────────────
|
||||
|
||||
export type ActionResult<T = Record<string, unknown>> =
|
||||
| { ok: true; data?: T }
|
||||
| { ok: false; error: string; fieldErrors?: Record<string, string[]> }
|
||||
|
||||
// ── Helper to build results ──────────────────────────────────────────
|
||||
|
||||
export function actionOk<T = Record<string, unknown>>(data?: T): ActionResult<T> {
|
||||
return { ok: true, data: data ?? ({} as T) }
|
||||
}
|
||||
|
||||
export function actionError(message: string): ActionResult<never> {
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
|
||||
// ── Throwable error ──────────────────────────────────────────────────
|
||||
|
||||
export class ActionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ActionError'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error handler ────────────────────────────────────────────────────
|
||||
|
||||
export function handleActionError(error: unknown): ActionResult<never> {
|
||||
if (error instanceof ZodError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Validation failed',
|
||||
fieldErrors: error.flatten().fieldErrors as Record<string, string[]>,
|
||||
}
|
||||
}
|
||||
if (error instanceof Error && error.name === 'ActionError') {
|
||||
return { ok: false, error: error.message }
|
||||
}
|
||||
// Prisma P2025
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.constructor.name === 'PrismaClientKnownRequestError' &&
|
||||
(error as Error & { code?: string }).code === 'P2025'
|
||||
) {
|
||||
return { ok: false, error: 'Not found' }
|
||||
}
|
||||
console.error('[Action error]', error)
|
||||
return { ok: false, error: 'Internal server error' }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { canAccess, getApiAdminContext } from '@/lib/permissions'
|
||||
import { type ActionResult, actionError, handleActionError } from '@/lib/safe-action-shared'
|
||||
|
||||
export type { ActionResult }
|
||||
|
||||
// ── Admin action wrapper ─────────────────────────────────────────────
|
||||
|
||||
interface AdminActionOptions<TSchema extends z.ZodType | undefined = undefined> {
|
||||
permission?: string
|
||||
schema?: TSchema
|
||||
}
|
||||
|
||||
type AdminActionContext<TSchema extends z.ZodType | undefined> = {
|
||||
session: { user: { id: number; username: string; rank: number; look: string; mail: string } }
|
||||
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object)
|
||||
|
||||
/**
|
||||
* Create a server action with admin auth + optional permission + optional Zod validation.
|
||||
*
|
||||
* @example
|
||||
* export const updateNews = adminAction(
|
||||
* { permission: PERMS.NEWS_EDIT, schema: updateNewsSchema },
|
||||
* async (ctx) => {
|
||||
* await prisma.websiteArticle.update({ ... })
|
||||
* return actionOk()
|
||||
* }
|
||||
* )
|
||||
*/
|
||||
export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
options: AdminActionOptions<TSchema>,
|
||||
handler: (ctx: AdminActionContext<TSchema>) => Promise<ActionResult>,
|
||||
) {
|
||||
return async (
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
||||
): Promise<ActionResult> => {
|
||||
try {
|
||||
const apiCtx = await getApiAdminContext()
|
||||
if (!apiCtx) return actionError('Unauthorized')
|
||||
|
||||
if (options.permission) {
|
||||
if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) {
|
||||
return actionError('Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown
|
||||
if (options.schema) {
|
||||
const parsed = options.schema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Validation failed',
|
||||
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
||||
}
|
||||
}
|
||||
data = parsed.data
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
session: apiCtx.session,
|
||||
...(options.schema ? { data } : {}),
|
||||
} as AdminActionContext<TSchema>
|
||||
|
||||
return await handler(ctx)
|
||||
} catch (error) {
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth action wrapper (no permissions) ─────────────────────────────
|
||||
|
||||
interface AuthActionOptions<TSchema extends z.ZodType | undefined = undefined> {
|
||||
schema?: TSchema
|
||||
}
|
||||
|
||||
type AuthActionContext<TSchema extends z.ZodType | undefined> = {
|
||||
session: { user: { id: number; username: string; rank: number; look: string; mail: string } }
|
||||
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object)
|
||||
|
||||
/**
|
||||
* Create a server action with auth only (no permission check).
|
||||
*/
|
||||
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
options: AuthActionOptions<TSchema>,
|
||||
handler: (ctx: AuthActionContext<TSchema>) => Promise<ActionResult>,
|
||||
) {
|
||||
return async (
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
||||
): Promise<ActionResult> => {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) return actionError('Unauthorized')
|
||||
|
||||
let data: unknown
|
||||
if (options.schema) {
|
||||
const parsed = options.schema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Validation failed',
|
||||
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
||||
}
|
||||
}
|
||||
data = parsed.data
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
session,
|
||||
...(options.schema ? { data } : {}),
|
||||
} as AuthActionContext<TSchema>
|
||||
|
||||
return await handler(ctx)
|
||||
} catch (error) {
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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>)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}\``
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Re-export barrel for backward compatibility.
|
||||
* All implementation has been moved to the ./swf/ module directory.
|
||||
*/
|
||||
export * from './swf'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'] },
|
||||
)
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Shared helpers used by every translations editor (CMS messages, Nitro JSON5
|
||||
// configs, JSON badge files). Editors operate on a flat `key -> string` map
|
||||
// for ease of search/filter/render; the on-disk format is the nested object
|
||||
// produced by `unflattenObject`.
|
||||
|
||||
export function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenObject(value as Record<string, unknown>, fullKey))
|
||||
} else {
|
||||
result[fullKey] = String(value ?? '')
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function unflattenObject(flat: Record<string, string>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(flat)) {
|
||||
const parts = key.split('.')
|
||||
let current: Record<string, unknown> = result
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]] || typeof current[parts[i]] !== 'object') {
|
||||
current[parts[i]] = {}
|
||||
}
|
||||
current = current[parts[i]] as Record<string, unknown>
|
||||
}
|
||||
current[parts[parts.length - 1]] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Arcturus Trax format parser — client-safe (no Node-only imports).
|
||||
*
|
||||
* Format (verified against doorman_frank, ballad_of_bonnie, bossa_nova on a
|
||||
* live DB):
|
||||
*
|
||||
* channelN:event;event;…:channelM:event;event;…:
|
||||
*
|
||||
* Each `event` is `sample_id,duration`:
|
||||
* - sample_id = 0 means silence.
|
||||
* - duration = integer "ticks". One tick = 2 seconds of playback.
|
||||
* (Verified: doorman_frank has 24 ticks × 2s = 48s = soundtracks.length.)
|
||||
*
|
||||
* All channels play in parallel starting at t=0, so the total soundtrack
|
||||
* length equals the max per-channel tick-sum × 2 seconds.
|
||||
*/
|
||||
|
||||
export const TRAX_TICK_SECONDS = 2
|
||||
|
||||
export interface TraxEvent {
|
||||
sampleId: number
|
||||
ticks: number
|
||||
}
|
||||
|
||||
export interface TraxChannel {
|
||||
channel: number
|
||||
events: TraxEvent[]
|
||||
}
|
||||
|
||||
/** Split a raw track string into per-channel event lists. */
|
||||
export function parseTraxChannels(track: string | null | undefined): TraxChannel[] {
|
||||
if (!track) return []
|
||||
const channels: TraxChannel[] = []
|
||||
for (const m of track.matchAll(/(\d+):([^:]+)/g)) {
|
||||
const channel = Number(m[1])
|
||||
const events: TraxEvent[] = []
|
||||
for (const raw of m[2].split(';')) {
|
||||
const pair = raw.match(/^(\d+),(\d+)$/)
|
||||
if (pair) events.push({ sampleId: Number(pair[1]), ticks: Number(pair[2]) })
|
||||
}
|
||||
if (events.length > 0) channels.push({ channel, events })
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `track` string into the unique non-zero sample IDs it uses, sorted.
|
||||
* Returns [] for empty or unparseable tracks (i.e. single-sample song disks).
|
||||
*/
|
||||
export function parseTrackSamples(track: string | null | undefined): number[] {
|
||||
const set = new Set<number>()
|
||||
for (const ch of parseTraxChannels(track)) {
|
||||
for (const ev of ch.events) {
|
||||
if (ev.sampleId > 0) set.add(ev.sampleId)
|
||||
}
|
||||
}
|
||||
return [...set].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export function detectSoundtrackKind(track: string | null | undefined): 'single' | 'composition' {
|
||||
return parseTrackSamples(track).length > 0 ? 'composition' : 'single'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const updateRoomSchema = z.object({
|
||||
name: z.string().min(1).max(60).optional(),
|
||||
description: z.string().max(512).optional(),
|
||||
state: z.enum(['open', 'locked', 'password', 'invisible']).optional(),
|
||||
usersMax: z.number().int().min(1).max(100).optional(),
|
||||
})
|
||||
|
||||
export type UpdateRoomInput = z.infer<typeof updateRoomSchema>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const TICKET_CATEGORIES = [
|
||||
'general',
|
||||
'account',
|
||||
'technical',
|
||||
'report',
|
||||
'billing',
|
||||
'other',
|
||||
] as const
|
||||
export const TICKET_PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const
|
||||
export const TICKET_STATUSES = ['open', 'in_progress', 'waiting', 'closed'] as const
|
||||
|
||||
export const createTicketSchema = z.object({
|
||||
subject: z.string().min(5, 'Subject must be at least 5 characters').max(255),
|
||||
message: z.string().min(10, 'Message must be at least 10 characters').max(5000),
|
||||
category: z.enum(TICKET_CATEGORIES),
|
||||
})
|
||||
|
||||
export const replyTicketSchema = z.object({
|
||||
ticketId: z.coerce.number().int().positive(),
|
||||
message: z.string().min(1, 'Message is required').max(5000),
|
||||
})
|
||||
|
||||
export const updateTicketStatusSchema = z.object({
|
||||
ticketId: z.coerce.number().int().positive(),
|
||||
status: z.enum(TICKET_STATUSES),
|
||||
})
|
||||
|
||||
export const assignTicketSchema = z.object({
|
||||
ticketId: z.coerce.number().int().positive(),
|
||||
assigneeId: z.coerce.number().int().positive().nullable(),
|
||||
})
|
||||
|
||||
export const updateTicketPrioritySchema = z.object({
|
||||
ticketId: z.coerce.number().int().positive(),
|
||||
priority: z.enum(TICKET_PRIORITIES),
|
||||
})
|
||||
|
||||
export type CreateTicketInput = z.infer<typeof createTicketSchema>
|
||||
export type ReplyTicketInput = z.infer<typeof replyTicketSchema>
|
||||
export type UpdateTicketStatusInput = z.infer<typeof updateTicketStatusSchema>
|
||||
export type AssignTicketInput = z.infer<typeof assignTicketSchema>
|
||||
export type UpdateTicketPriorityInput = z.infer<typeof updateTicketPrioritySchema>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must be at most 20 characters')
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, _ and -'),
|
||||
mail: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
rank: z.coerce.number().int().min(1).max(7).default(1),
|
||||
motto: z.string().max(127).default(''),
|
||||
})
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
username: z.string().min(3).max(20).optional(),
|
||||
mail: z.string().email().optional(),
|
||||
rank: z.number().int().min(1).max(7).optional(),
|
||||
motto: z.string().max(127).optional(),
|
||||
credits: z.number().int().min(0).max(2147483647).optional(),
|
||||
pixels: z.number().int().min(0).max(2147483647).optional(),
|
||||
diamonds: z.number().int().min(0).max(2147483647).optional(),
|
||||
duckets: z.number().int().min(0).max(2147483647).optional(),
|
||||
})
|
||||
|
||||
export const banUserSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
reason: z.string().min(1, 'Ban reason is required').max(500),
|
||||
duration: z.coerce.number().int().min(0, 'Duration must be 0 or more hours'),
|
||||
type: z.enum(['account', 'ip', 'machine']).default('account'),
|
||||
ip: z.string().optional(),
|
||||
})
|
||||
|
||||
export const giveBadgeSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
badgeCode: z.string().min(1, 'Badge code is required').max(20),
|
||||
})
|
||||
|
||||
export type CreateUserInput = z.infer<typeof createUserSchema>
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>
|
||||
export type BanUserInput = z.infer<typeof banUserSchema>
|
||||
export type GiveBadgeInput = z.infer<typeof giveBadgeSchema>
|
||||
|
||||
Reference in new issue
Block a user