/** * 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 * v3.2: Price history tracking โ€” rolling 30-day window, per-item median/sparkline data */ 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 }, 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 }); listingSchema.index({ itemId: 1, soldAt: -1 }); // for price history queries const Listing = mongoose.model('MarketListing', listingSchema); // โ”€โ”€ Price History Schema (GDD ยง14 v3.2 extension) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Stores daily OHLC-style buckets per item for sparkline/median display const priceHistorySchema = new mongoose.Schema({ itemId: { type: String, required: true }, date: { type: String, required: true }, // 'YYYY-MM-DD' open: { type: Number, default: 0 }, high: { type: Number, default: 0 }, low: { type: Number, default: 0 }, close: { type: Number, default: 0 }, volume: { type: Number, default: 0 }, // units sold saleCount: { type: Number, default: 0 }, // number of transactions sumPrice: { type: Number, default: 0 }, // for average calculation }, { timestamps: true }); priceHistorySchema.index({ itemId: 1, date: -1 }, { unique: true }); const PriceHistory = mongoose.model('MarketPriceHistory', priceHistorySchema); const LISTING_FEE_PCT = 0.02; const MAX_LISTINGS = 10; const VALID_DURATIONS = [24, 48, 72]; const HISTORY_DAYS = 30; // rolling window kept 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; this.PriceHistory = PriceHistory; } // โ”€โ”€ Internal: record a sale into price history buckets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async _recordSale(itemId, pricePerUnit, quantity) { try { const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD const existing = await PriceHistory.findOne({ itemId, date }); if (existing) { existing.high = Math.max(existing.high, pricePerUnit); existing.low = existing.low > 0 ? Math.min(existing.low, pricePerUnit) : pricePerUnit; existing.close = pricePerUnit; existing.volume += quantity; existing.saleCount += 1; existing.sumPrice += pricePerUnit * quantity; await existing.save(); } else { await PriceHistory.create({ itemId, date, open: pricePerUnit, high: pricePerUnit, low: pricePerUnit, close: pricePerUnit, volume: quantity, saleCount: 1, sumPrice: pricePerUnit * quantity, }); } // Prune old records beyond HISTORY_DAYS const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - HISTORY_DAYS); const cutoffStr = cutoff.toISOString().slice(0, 10); await PriceHistory.deleteMany({ itemId, date: { $lt: cutoffStr } }); } catch (e) { console.warn('[MarketSystem] _recordSale error (non-fatal):', e.message); } } // โ”€โ”€ Public: get price history for an item (last N days) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async getPriceHistory(itemId, days = 14) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); const cutoffStr = cutoff.toISOString().slice(0, 10); const rows = await PriceHistory.find({ itemId, date: { $gte: cutoffStr } }) .sort({ date: 1 }).lean(); const median = rows.length > 0 ? rows.reduce((s, r) => s + (r.sumPrice / Math.max(r.volume, 1)), 0) / rows.length : null; return { itemId, days: rows.map(r => ({ date: r.date, open: r.open, high: r.high, low: r.low, close: r.close, volume: r.volume, saleCount: r.saleCount, avg: r.volume > 0 ? Math.round(r.sumPrice / r.volume) : r.close, })), median: median !== null ? Math.round(median) : null, allTimeMin: rows.length ? Math.min(...rows.map(r => r.low)) : null, allTimeMax: rows.length ? Math.max(...rows.map(r => r.high)) : null, }; } /** 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'); const res = playerData.resources || {}; if ((res[resource] || 0) < quantity) throw new Error(`Insufficient ${resource}: have ${res[resource]||0}, need ${quantity}`); const myListings = await Listing.countDocuments({ sellerId: playerData.userId, isActive: true }); if (myListings >= MAX_LISTINGS) throw new Error(`Max ${MAX_LISTINGS} active listings`); res[resource] -= quantity; const totalPrice = pricePerUnit * quantity; // Apply Merchant Guild reputation discount let effectiveFee = LISTING_FEE_PCT; if (playerData.reputation?.merchant_guild >= 1500) effectiveFee = 0.01; else if (playerData.reputation?.merchant_guild >= 750) effectiveFee = 0.015; const listingFee = Math.ceil(totalPrice * effectiveFee); 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?.items || playerData.inventory || []; const idx = inventory.findIndex(i => 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 โ€” records price history on sale */ 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; if (listing.itemType === 'resource') { if (!buyerData.resources) buyerData.resources = {}; buyerData.resources[listing.itemId] = (buyerData.resources[listing.itemId] || 0) + listing.quantity; } else { const inv = buyerData.inventory?.items || buyerData.inventory; if (Array.isArray(inv)) { try { inv.push(JSON.parse(listing._itemData)); } catch(e) {} } } listing.isActive = false; listing.buyerId = buyerData.userId; listing.soldAt = new Date(); await listing.save(); // Record the sale in price history (non-blocking) this._recordSale(listing.itemId, listing.pricePerUnit, listing.quantity); return { listing, proceeds: listing.totalPrice }; } /** Cancel own listing */ 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(); if (listing.itemType === 'resource') { if (!playerData.resources) playerData.resources = {}; playerData.resources[listing.itemId] = (playerData.resources[listing.itemId] || 0) + listing.quantity; } else { const inv = playerData.inventory?.items || playerData.inventory; if (Array.isArray(inv)) { try { inv.push(JSON.parse(listing._itemData)); } catch(e) {} } } 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 } }); await Listing.updateMany( { isActive: true, expiresAt: { $lt: now } }, { $set: { isActive: false } } ); return expired.length; } getTradeableResources() { return TRADEABLE_RESOURCES; } } module.exports = { MarketSystem, Listing, PriceHistory };