/** * Galaxy Strike Online — Server Item System * All item/shop data comes from ContentLoader (data/gso/items/). * No hardcoded item data lives here. */ class ItemSystem { constructor(contentLoader = null) { this.serverUrl = process.env.SERVER_URL || 'http://localhost:3002'; this._loader = contentLoader; if (contentLoader) { console.log(`[ITEM SYSTEM] ${contentLoader.items.size} items available from ContentLoader`); } else { console.warn('[ITEM SYSTEM] No ContentLoader provided — item catalog is empty'); } } setServerUrl(url) { this.serverUrl = url; } // ── Catalog access ───────────────────────────────────────────────────── getAllItems() { return this._loader ? this._loader.getAllItems() : []; } getItem(id) { return this._loader ? this._loader.getItem(id) : null; } getItemsByType(type) { return this._loader ? this._loader.getItemsByType(type) : []; } /** * Returns all items that include 'shop' in their categories array. * Grouped by item type: { ship: [...], weapon: [...], ... } */ getShopItemsByCategory() { const out = {}; for (const item of this.getAllItems()) { if (!Array.isArray(item.categories) || !item.categories.includes('shop')) continue; const t = item.type || 'misc'; if (!out[t]) out[t] = []; out[t].push(this._normaliseForClient(item)); } return out; } /** * Flat array of all shop items. * Called as getRandomShopItems() by legacy server.js code — returns the same thing, * just grouped (matching the expected { category: [items] } shape). */ getRandomShopItems() { return this.getShopItemsByCategory(); } /** * All shop items for a single type/category. * Called as getRandomItemsByCategory(category) by legacy server.js code. */ getRandomItemsByCategory(category) { return this.getAllItems() .filter(i => Array.isArray(i.categories) && i.categories.includes('shop') && i.type === category ) .map(i => this._normaliseForClient(i)); } // ── Shop helpers ─────────────────────────────────────────────────────── /** * Find an item that is available in the shop. * Returns null if the item doesn't exist or isn't in the shop. */ findShopItem(itemId) { const item = this.getItem(itemId); if (!item) return null; if (!Array.isArray(item.categories) || !item.categories.includes('shop')) return null; return item; } /** * Validate a purchase against player wallet. * Returns { valid: true } or { valid: false, error: '...' } */ validatePurchase(item, playerStats, quantity = 1) { const currency = item.currency || 'credits'; const totalCost = (item.price || 0) * quantity; if (currency === 'credits' && (playerStats.credits || 0) < totalCost) { return { valid: false, error: 'Not enough credits' }; } if (currency === 'gems' && (playerStats.gems || 0) < totalCost) { return { valid: false, error: 'Not enough gems' }; } return { valid: true, totalCost, currency }; } /** * Apply a purchase to playerData (mutates directly, like the original server did). * Returns the purchase summary for the response payload. */ applyPurchase(item, playerData, quantity = 1) { const currency = item.currency || 'credits'; const totalCost = (item.price || 0) * quantity; // Deduct currency if (currency === 'credits') playerData.stats.credits = (playerData.stats.credits || 0) - totalCost; if (currency === 'gems') playerData.stats.gems = (playerData.stats.gems || 0) - totalCost; // Grant item switch (item.type) { case 'decoration': if (!playerData.starbase) playerData.starbase = { wallpaper: null, ownedWallpapers: [], unlockedRooms: [] }; if (item.subtype === 'wallpaper') { if (!playerData.starbase.ownedWallpapers) playerData.starbase.ownedWallpapers = []; if (!playerData.starbase.ownedWallpapers.includes(item.id)) { playerData.starbase.ownedWallpapers.push(item.id); } } else if (item.subtype === 'room_unlock') { if (!playerData.starbase.unlockedRooms) playerData.starbase.unlockedRooms = []; if (item.roomId && !playerData.starbase.unlockedRooms.includes(item.roomId)) { playerData.starbase.unlockedRooms.push(item.roomId); } } break; case 'ship': if (!playerData.ownedShips) playerData.ownedShips = []; if (!playerData.ownedShips.includes(item.id)) playerData.ownedShips.push(item.id); break; case 'cosmetic': if (!playerData.ownedCosmetics) playerData.ownedCosmetics = []; if (!playerData.ownedCosmetics.includes(item.id)) playerData.ownedCosmetics.push(item.id); break; case 'weapon': case 'armour': case 'consumable': case 'material': case 'hullPlating': default: if (!playerData.inventory) playerData.inventory = { items: [] }; if (!playerData.inventory.items) playerData.inventory.items = []; // Stack consumables/materials; unique otherwise const existing = playerData.inventory.items.find(i => i.id === item.id && item.stackable); if (existing) { existing.quantity = (existing.quantity || 1) + quantity; } else { playerData.inventory.items.push({ id: item.id, name: item.name, type: item.type, quantity: quantity, acquired: new Date().toISOString() }); } break; } return { totalCost, currency, newBalance: currency === 'credits' ? playerData.stats.credits : playerData.stats.gems }; } // ── Packet builder helpers ───────────────────────────────────────────── /** Builds the full shopItemsReceived payload */ buildShopResponse() { return { success: true, shopItems: this.getShopItemsByCategory(), timestamp: new Date().toISOString() }; } /** Builds the itemDetailsReceived payload */ buildItemDetailResponse(itemId) { const item = this.getItem(itemId); if (!item) return { success: false, error: `Item '${itemId}' not found` }; return { success: true, item: this._normaliseForClient(item) }; } // ── Internal normalisation ───────────────────────────────────────────── /** * Normalise an item object into the flat shape the client expects. * Handles both bare-item format (what ContentLoader returns after unwrapping templates) * and the raw wrapped format { templates: { : {...} } }. */ _normaliseForClient(item) { return { id: item.id, name: item.name || item.id, type: item.type || 'misc', rarity: item.rarity || 'common', price: item.price || 0, currency: item.currency || 'credits', description: item.description || '', texture: item.texture || '', texturePath: item.texture || '', stats: item.stats || {}, categories: item.categories || [], requirements:item.requirements || {}, stackable: item.stackable ?? false, maxStack: item.maxStack || 1, meta: item.meta || {} }; } } module.exports = ItemSystem;