/** * Galaxy Strike Online โ€” Player Market (GDD ยง14) * Types: Auction, Direct Sale * 2% listing fee; 24/48/72-hour durations * Tradeable: resources, modules, blueprints, consumables, cosmetics */ const mongoose = require('mongoose'); const listingSchema = new mongoose.Schema({ listingId: { type: String, required: true, unique: true }, sellerId: { type: String, required: true }, sellerName: { type: String, default: '' }, itemType: { type: String, required: true }, // 'resource'|'item' itemId: { type: String, required: true }, // resource key or item.id itemName: { type: String, default: '' }, itemIcon: { type: String, default: '๐Ÿ“ฆ' }, quantity: { type: Number, default: 1 }, pricePerUnit: { type: Number, required: true }, totalPrice: { type: Number, required: true }, currency: { type: String, default: 'credits' }, listedAt: { type: Date, default: Date.now }, expiresAt: { type: Date, required: true }, isActive: { type: Boolean, default: true }, buyerId: { type: String, default: null }, soldAt: { type: Date, default: null }, durationHours: { type: Number, default: 24 }, }, { timestamps: true }); listingSchema.index({ isActive: 1, expiresAt: 1 }); listingSchema.index({ sellerId: 1 }); listingSchema.index({ itemId: 1, isActive: 1 }); const Listing = mongoose.model('MarketListing', listingSchema); const LISTING_FEE_PCT = 0.02; // 2% GDD ยง14.3 const MAX_LISTINGS = 10; // per player (base) const VALID_DURATIONS = [24, 48, 72]; const TRADEABLE_RESOURCES = { metal: { name:'Metal', icon:'โš™', minPrice:1, maxPrice:100 }, gas: { name:'Gas', icon:'โ˜', minPrice:1, maxPrice:100 }, crystal: { name:'Crystal', icon:'๐Ÿ’Ž', minPrice:2, maxPrice:200 }, energyCells: { name:'Energy Cells',icon:'โšก', minPrice:1, maxPrice:80 }, darkMatter: { name:'Dark Matter', icon:'โœฆ', minPrice:10, maxPrice:2000 }, }; class MarketSystem { constructor() { this.Listing = Listing; } /** List a resource on the market */ async listResource(playerData, { resource, quantity, pricePerUnit, durationHours = 24 }) { if (!TRADEABLE_RESOURCES[resource]) throw new Error('Resource not tradeable'); quantity = Math.floor(quantity); pricePerUnit = Math.floor(pricePerUnit); durationHours = VALID_DURATIONS.includes(durationHours) ? durationHours : 24; if (quantity < 1) throw new Error('Quantity must be โ‰ฅ 1'); if (pricePerUnit < 1) throw new Error('Price must be โ‰ฅ 1 credit'); // Check median manipulation (stub โ€” skip if no data) const res = playerData.resources || {}; if ((res[resource] || 0) < quantity) throw new Error(`Insufficient ${resource}: have ${res[resource]||0}, need ${quantity}`); // Count existing listings const myListings = await Listing.countDocuments({ sellerId: playerData.userId, isActive: true }); if (myListings >= MAX_LISTINGS) throw new Error(`Max ${MAX_LISTINGS} active listings`); // Deduct resources res[resource] -= quantity; // Listing fee in credits (2%) const totalPrice = pricePerUnit * quantity; const listingFee = Math.ceil(totalPrice * LISTING_FEE_PCT); if ((playerData.stats?.credits || 0) < listingFee) throw new Error(`Listing fee: ${listingFee} credits`); playerData.stats.credits -= listingFee; const cfg = TRADEABLE_RESOURCES[resource]; const listing = new Listing({ listingId: 'listing_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), sellerId: playerData.userId, sellerName: playerData.username, itemType: 'resource', itemId: resource, itemName: cfg.name, itemIcon: cfg.icon, quantity, pricePerUnit, totalPrice, currency: 'credits', expiresAt: new Date(Date.now() + durationHours * 3600 * 1000), durationHours, }); await listing.save(); return { listing, listingFee }; } /** List an inventory item */ async listItem(playerData, { itemId, pricePerUnit, durationHours = 24 }) { const inventory = playerData.inventory || []; const idx = inventory.findIndex(i => i.id === itemId); if (idx < 0) throw new Error('Item not found in inventory'); const item = inventory[idx]; if (item.type === 'ship' && item.id === playerData.stats?.activeShipId) throw new Error('Cannot list your active ship'); pricePerUnit = Math.floor(pricePerUnit); durationHours = VALID_DURATIONS.includes(durationHours) ? durationHours : 24; const myListings = await Listing.countDocuments({ sellerId: playerData.userId, isActive: true }); if (myListings >= MAX_LISTINGS) throw new Error(`Max ${MAX_LISTINGS} active listings`); const listingFee = Math.ceil(pricePerUnit * LISTING_FEE_PCT); if ((playerData.stats?.credits || 0) < listingFee) throw new Error(`Listing fee: ${listingFee} credits`); playerData.stats.credits -= listingFee; inventory.splice(idx, 1); const listing = new Listing({ listingId: 'listing_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), sellerId: playerData.userId, sellerName: playerData.username, itemType: 'item', itemId: item.id, itemName: item.name || item.id, itemIcon: item.icon || '๐Ÿ“ฆ', quantity: 1, pricePerUnit, totalPrice: pricePerUnit, currency: 'credits', expiresAt: new Date(Date.now() + durationHours * 3600 * 1000), durationHours, _itemData: JSON.stringify(item), }); await listing.save(); return { listing, listingFee }; } /** Buy a listing */ async buyListing(buyerData, listingId) { const listing = await Listing.findOne({ listingId, isActive: true }); if (!listing) throw new Error('Listing not found or already sold'); if (listing.expiresAt < new Date()) throw new Error('Listing expired'); if (listing.sellerId === buyerData.userId) throw new Error('Cannot buy your own listing'); if ((buyerData.stats?.credits || 0) < listing.totalPrice) throw new Error(`Need ${listing.totalPrice} credits, have ${buyerData.stats?.credits||0}`); buyerData.stats.credits -= listing.totalPrice; // Transfer goods to buyer if (listing.itemType === 'resource') { if (!buyerData.resources) buyerData.resources = {}; buyerData.resources[listing.itemId] = (buyerData.resources[listing.itemId] || 0) + listing.quantity; } else { if (!buyerData.inventory) buyerData.inventory = []; try { buyerData.inventory.push(JSON.parse(listing._itemData)); } catch(e) { /* fallback */ } } listing.isActive = false; listing.buyerId = buyerData.userId; listing.soldAt = new Date(); await listing.save(); // Return sale proceeds to seller (handled async โ€” seller gets credits on next login or via event) return { listing, proceeds: listing.totalPrice }; } /** Cancel own listing โ€” returns resources/item minus fee (no fee refund per GDD) */ async cancelListing(playerData, listingId) { const listing = await Listing.findOne({ listingId, sellerId: playerData.userId, isActive: true }); if (!listing) throw new Error('Listing not found'); listing.isActive = false; await listing.save(); // Return goods if (listing.itemType === 'resource') { if (!playerData.resources) playerData.resources = {}; playerData.resources[listing.itemId] = (playerData.resources[listing.itemId] || 0) + listing.quantity; } else { if (!playerData.inventory) playerData.inventory = []; try { playerData.inventory.push(JSON.parse(listing._itemData)); } catch(e) { /* skip */ } } return listing; } async getListings({ itemId, category, limit = 40 } = {}) { const filter = { isActive: true, expiresAt: { $gt: new Date() } }; if (itemId) filter.itemId = itemId; if (category) filter.itemType = category; return Listing.find(filter).sort({ pricePerUnit: 1 }).limit(limit).lean(); } async getMyListings(userId) { return Listing.find({ sellerId: userId, isActive: true }).sort({ listedAt: -1 }).lean(); } async expireListings() { const now = new Date(); const expired = await Listing.find({ isActive: true, expiresAt: { $lt: now } }); // TODO: return resources to sellers (deferred โ€” done on next login) await Listing.updateMany({ isActive: true, expiresAt: { $lt: now } }, { $set: { isActive: false } }); return expired.length; } getTradeableResources() { return TRADEABLE_RESOURCES; } } module.exports = { MarketSystem, Listing };