295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
/**
|
|
* 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 };
|