Client/GameServer/systems/MarketSystem.js
2026-03-10 11:20:02 -03:00

207 lines
8.5 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
*/
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 };