297 lines
9.4 KiB
TypeScript
297 lines
9.4 KiB
TypeScript
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,
|
|
}
|
|
}
|