441 lines
13 KiB
JavaScript
441 lines
13 KiB
JavaScript
|
|
/**
|
||
|
|
* MarketManager.js
|
||
|
|
* Manages the Hub Marketplace - stock generation, transactions, and persistence.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {Object} MarketItem
|
||
|
|
* @property {string} id - Unique Stock ID (e.g. "STOCK_001")
|
||
|
|
* @property {string} defId - Reference to ItemRegistry (e.g. "ITEM_RUSTY_BLADE")
|
||
|
|
* @property {string} type - ItemType (cached for filtering)
|
||
|
|
* @property {string} rarity - Rarity (cached for sorting/styling)
|
||
|
|
* @property {number} price - Purchase price
|
||
|
|
* @property {number} discount - 0.0 to 1.0 (percent off)
|
||
|
|
* @property {boolean} purchased - If true, show as "Sold Out"
|
||
|
|
* @property {Object} [instanceData] - If this is a buyback, store the ItemInstance here
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {Object} MarketState
|
||
|
|
* @property {string} generationId - Timestamp or Mission Count when this stock was generated
|
||
|
|
* @property {MarketItem[]} stock - The active inventory for sale
|
||
|
|
* @property {MarketItem[]} buyback - Items sold by the player this session (can be bought back)
|
||
|
|
* @property {string} [specialOffer] - ID of a specific item (Daily Deal)
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {Object} StockTable
|
||
|
|
* @property {number} minItems - Minimum items to generate
|
||
|
|
* @property {number} maxItems - Maximum items to generate
|
||
|
|
* @property {Object} rarityWeights - Rarity distribution weights
|
||
|
|
* @property {string[]} allowedTypes - Item types this merchant can sell
|
||
|
|
*/
|
||
|
|
|
||
|
|
export class MarketManager {
|
||
|
|
/**
|
||
|
|
* @param {import("../core/Persistence.js").Persistence} persistence - Persistence manager
|
||
|
|
* @param {import("../managers/ItemRegistry.js").ItemRegistry} itemRegistry - Item registry
|
||
|
|
* @param {import("../managers/InventoryManager.js").InventoryManager} inventoryManager - Inventory manager (for hubStash access)
|
||
|
|
* @param {import("../managers/MissionManager.js").MissionManager} [missionManager] - Mission manager (optional, for tier calculation)
|
||
|
|
*/
|
||
|
|
constructor(persistence, itemRegistry, inventoryManager, missionManager = null) {
|
||
|
|
/** @type {import("../core/Persistence.js").Persistence} */
|
||
|
|
this.persistence = persistence;
|
||
|
|
/** @type {import("../managers/ItemRegistry.js").ItemRegistry} */
|
||
|
|
this.itemRegistry = itemRegistry;
|
||
|
|
/** @type {import("../managers/InventoryManager.js").InventoryManager} */
|
||
|
|
this.inventoryManager = inventoryManager;
|
||
|
|
/** @type {import("../managers/MissionManager.js").MissionManager | null} */
|
||
|
|
this.missionManager = missionManager;
|
||
|
|
|
||
|
|
/** @type {MarketState | null} */
|
||
|
|
this.marketState = null;
|
||
|
|
|
||
|
|
/** @type {boolean} */
|
||
|
|
this.needsRefresh = false;
|
||
|
|
|
||
|
|
// Listen for mission completion
|
||
|
|
this._boundHandleMissionVictory = this._handleMissionVictory.bind(this);
|
||
|
|
window.addEventListener("mission-victory", this._boundHandleMissionVictory);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initializes the market manager and loads state from IndexedDB.
|
||
|
|
* @returns {Promise<void>}
|
||
|
|
*/
|
||
|
|
async init() {
|
||
|
|
await this.itemRegistry.loadAll();
|
||
|
|
const savedState = await this.persistence.loadMarketState();
|
||
|
|
if (savedState) {
|
||
|
|
this.marketState = savedState;
|
||
|
|
} else {
|
||
|
|
// Generate initial Tier 1 stock
|
||
|
|
this.marketState = {
|
||
|
|
generationId: `INIT_${Date.now()}`,
|
||
|
|
stock: [],
|
||
|
|
buyback: [],
|
||
|
|
};
|
||
|
|
await this.generateStock(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handles mission victory event to set refresh flag.
|
||
|
|
* @private
|
||
|
|
*/
|
||
|
|
_handleMissionVictory() {
|
||
|
|
this.needsRefresh = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Checks if refresh is needed and generates new stock if entering hub.
|
||
|
|
* Should be called when player enters Hub.
|
||
|
|
* @returns {Promise<void>}
|
||
|
|
*/
|
||
|
|
async checkRefresh() {
|
||
|
|
if (this.needsRefresh) {
|
||
|
|
// Determine tier based on completed missions count
|
||
|
|
// For now, use Tier 1 for < 3 missions, Tier 2 for >= 3
|
||
|
|
const completedCount = this._getCompletedMissionCount();
|
||
|
|
const tier = completedCount < 3 ? 1 : 2;
|
||
|
|
await this.generateStock(tier);
|
||
|
|
this.needsRefresh = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Gets the number of completed missions.
|
||
|
|
* @returns {number}
|
||
|
|
* @private
|
||
|
|
*/
|
||
|
|
_getCompletedMissionCount() {
|
||
|
|
if (this.missionManager) {
|
||
|
|
return this.missionManager.completedMissions.size;
|
||
|
|
}
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generates new stock based on tier.
|
||
|
|
* @param {number} tier - Game tier (1 = Early, 2 = Mid)
|
||
|
|
* @returns {Promise<void>}
|
||
|
|
*/
|
||
|
|
async generateStock(tier) {
|
||
|
|
const allItems = this.itemRegistry.getAll();
|
||
|
|
const newStock = [];
|
||
|
|
|
||
|
|
if (tier === 1) {
|
||
|
|
// Tier 1: Smith (5 Common Weapons, 3 Common Armor)
|
||
|
|
const smithWeapons = this._generateMerchantStock(
|
||
|
|
allItems,
|
||
|
|
["WEAPON"],
|
||
|
|
{ COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 },
|
||
|
|
5
|
||
|
|
);
|
||
|
|
const smithArmor = this._generateMerchantStock(
|
||
|
|
allItems,
|
||
|
|
["ARMOR"],
|
||
|
|
{ COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 },
|
||
|
|
3
|
||
|
|
);
|
||
|
|
newStock.push(...smithWeapons, ...smithArmor);
|
||
|
|
|
||
|
|
// Alchemist (5 Potions, 2 Grenades) - simplified for now since we don't have potions in tier1_gear
|
||
|
|
// Will add when consumables are available
|
||
|
|
} else if (tier === 2) {
|
||
|
|
// Tier 2: Weights Common (60%), Uncommon (30%), Rare (10%)
|
||
|
|
const tier2Weights = {
|
||
|
|
COMMON: 0.6,
|
||
|
|
UNCOMMON: 0.3,
|
||
|
|
RARE: 0.1,
|
||
|
|
ANCIENT: 0,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Generate mixed stock
|
||
|
|
const weapons = this._generateMerchantStock(
|
||
|
|
allItems,
|
||
|
|
["WEAPON"],
|
||
|
|
tier2Weights,
|
||
|
|
8
|
||
|
|
);
|
||
|
|
const armor = this._generateMerchantStock(
|
||
|
|
allItems,
|
||
|
|
["ARMOR"],
|
||
|
|
tier2Weights,
|
||
|
|
5
|
||
|
|
);
|
||
|
|
const utility = this._generateMerchantStock(
|
||
|
|
allItems,
|
||
|
|
["UTILITY"],
|
||
|
|
tier2Weights,
|
||
|
|
3
|
||
|
|
);
|
||
|
|
newStock.push(...weapons, ...armor, ...utility);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Assign stock IDs and prices
|
||
|
|
const stockWithIds = newStock.map((item, index) => {
|
||
|
|
const itemDef = this.itemRegistry.get(item.defId);
|
||
|
|
const basePrice = this._calculateBasePrice(itemDef);
|
||
|
|
const variance = 1 + (Math.random() * 0.2 - 0.1); // ±10% variance
|
||
|
|
const price = Math.floor(basePrice * variance);
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: `STOCK_${Date.now()}_${index}`,
|
||
|
|
defId: item.defId,
|
||
|
|
type: item.type,
|
||
|
|
rarity: item.rarity,
|
||
|
|
price: price,
|
||
|
|
discount: 0,
|
||
|
|
purchased: false,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
this.marketState.stock = stockWithIds;
|
||
|
|
this.marketState.generationId = `TIER${tier}_${Date.now()}`;
|
||
|
|
await this.persistence.saveMarketState(this.marketState);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generates stock for a specific merchant type.
|
||
|
|
* @param {import("../items/Item.js").Item[]} allItems - All available items
|
||
|
|
* @param {string[]} allowedTypes - Item types to filter
|
||
|
|
* @param {Object} rarityWeights - Rarity weight distribution
|
||
|
|
* @param {number} count - Number of items to generate
|
||
|
|
* @returns {Array<{defId: string, type: string, rarity: string}>}
|
||
|
|
* @private
|
||
|
|
*/
|
||
|
|
_generateMerchantStock(allItems, allowedTypes, rarityWeights, count) {
|
||
|
|
// Filter by allowed types
|
||
|
|
const filtered = allItems.filter((item) => allowedTypes.includes(item.type));
|
||
|
|
|
||
|
|
if (filtered.length === 0) return [];
|
||
|
|
|
||
|
|
const result = [];
|
||
|
|
for (let i = 0; i < count; i++) {
|
||
|
|
// Roll for rarity
|
||
|
|
const roll = Math.random();
|
||
|
|
let selectedRarity = "COMMON";
|
||
|
|
let cumulative = 0;
|
||
|
|
for (const [rarity, weight] of Object.entries(rarityWeights)) {
|
||
|
|
cumulative += weight;
|
||
|
|
if (roll <= cumulative) {
|
||
|
|
selectedRarity = rarity;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter by selected rarity
|
||
|
|
const rarityFiltered = filtered.filter(
|
||
|
|
(item) => item.rarity === selectedRarity
|
||
|
|
);
|
||
|
|
|
||
|
|
if (rarityFiltered.length === 0) {
|
||
|
|
// Fallback to any rarity if none found
|
||
|
|
const randomItem =
|
||
|
|
filtered[Math.floor(Math.random() * filtered.length)];
|
||
|
|
result.push({
|
||
|
|
defId: randomItem.id,
|
||
|
|
type: randomItem.type,
|
||
|
|
rarity: randomItem.rarity,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
const randomItem =
|
||
|
|
rarityFiltered[Math.floor(Math.random() * rarityFiltered.length)];
|
||
|
|
result.push({
|
||
|
|
defId: randomItem.id,
|
||
|
|
type: randomItem.type,
|
||
|
|
rarity: randomItem.rarity,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculates base price for an item based on its stats and rarity.
|
||
|
|
* @param {import("../items/Item.js").Item} itemDef - Item definition
|
||
|
|
* @returns {number}
|
||
|
|
* @private
|
||
|
|
*/
|
||
|
|
_calculateBasePrice(itemDef) {
|
||
|
|
// Base price calculation based on stats and rarity
|
||
|
|
let basePrice = 50; // Base value
|
||
|
|
|
||
|
|
// Add stat values
|
||
|
|
const stats = itemDef.stats || {};
|
||
|
|
const statValue = Object.values(stats).reduce((sum, val) => sum + val, 0);
|
||
|
|
basePrice += statValue * 10;
|
||
|
|
|
||
|
|
// Rarity multiplier
|
||
|
|
const rarityMultipliers = {
|
||
|
|
COMMON: 1,
|
||
|
|
UNCOMMON: 1.5,
|
||
|
|
RARE: 2.5,
|
||
|
|
ANCIENT: 5,
|
||
|
|
};
|
||
|
|
basePrice *= rarityMultipliers[itemDef.rarity] || 1;
|
||
|
|
|
||
|
|
return Math.floor(basePrice);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Buys an item from the market.
|
||
|
|
* Atomic transaction: checks currency, deducts, creates instance, adds to stash, marks purchased, saves.
|
||
|
|
* @param {string} stockId - Stock ID of the item to buy
|
||
|
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||
|
|
*/
|
||
|
|
async buyItem(stockId) {
|
||
|
|
// Check both stock and buyback
|
||
|
|
let marketItem = this.marketState.stock.find((item) => item.id === stockId);
|
||
|
|
if (!marketItem) {
|
||
|
|
marketItem = this.marketState.buyback.find((item) => item.id === stockId);
|
||
|
|
}
|
||
|
|
if (!marketItem || marketItem.purchased) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get wallet from hub stash
|
||
|
|
const wallet = this.inventoryManager.hubStash.currency;
|
||
|
|
if (wallet.aetherShards < marketItem.price) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Atomic transaction
|
||
|
|
try {
|
||
|
|
// 1. Deduct currency
|
||
|
|
wallet.aetherShards -= marketItem.price;
|
||
|
|
|
||
|
|
// 2. Generate ItemInstance (or restore from buyback)
|
||
|
|
let itemInstance;
|
||
|
|
if (marketItem.instanceData) {
|
||
|
|
// Restore original instance from buyback
|
||
|
|
itemInstance = marketItem.instanceData;
|
||
|
|
} else {
|
||
|
|
// Create new instance
|
||
|
|
itemInstance = {
|
||
|
|
uid: `ITEM_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||
|
|
defId: marketItem.defId,
|
||
|
|
isNew: true,
|
||
|
|
quantity: 1,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Add to hubStash
|
||
|
|
this.inventoryManager.hubStash.addItem(itemInstance);
|
||
|
|
|
||
|
|
// 4. Mark as purchased
|
||
|
|
marketItem.purchased = true;
|
||
|
|
|
||
|
|
// 5. Save state
|
||
|
|
await this.persistence.saveMarketState(this.marketState);
|
||
|
|
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error buying item:", error);
|
||
|
|
// Rollback would go here in a production system
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sells an item to the market.
|
||
|
|
* Atomic transaction: removes from stash, calculates value, adds currency, creates buyback, saves.
|
||
|
|
* @param {string} itemUid - UID of the item instance to sell
|
||
|
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||
|
|
*/
|
||
|
|
async sellItem(itemUid) {
|
||
|
|
// Find item in hubStash
|
||
|
|
const itemInstance = this.inventoryManager.hubStash.findItem(itemUid);
|
||
|
|
if (!itemInstance) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get item definition
|
||
|
|
const itemDef = this.itemRegistry.get(itemInstance.defId);
|
||
|
|
if (!itemDef) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Atomic transaction
|
||
|
|
try {
|
||
|
|
// 1. Remove from hubStash
|
||
|
|
this.inventoryManager.hubStash.removeItem(itemUid);
|
||
|
|
|
||
|
|
// 2. Calculate sell price (25% of base price)
|
||
|
|
const basePrice = this._calculateBasePrice(itemDef);
|
||
|
|
const sellPrice = Math.floor(basePrice * 0.25);
|
||
|
|
|
||
|
|
// 3. Add currency
|
||
|
|
this.inventoryManager.hubStash.currency.aetherShards += sellPrice;
|
||
|
|
|
||
|
|
// 4. Create buyback entry (limit 10)
|
||
|
|
if (this.marketState.buyback.length >= 10) {
|
||
|
|
this.marketState.buyback.shift(); // Remove oldest
|
||
|
|
}
|
||
|
|
|
||
|
|
const buybackItem = {
|
||
|
|
id: `BUYBACK_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||
|
|
defId: itemInstance.defId,
|
||
|
|
type: itemDef.type,
|
||
|
|
rarity: itemDef.rarity,
|
||
|
|
price: sellPrice, // Buyback price = sell price
|
||
|
|
discount: 0,
|
||
|
|
purchased: false,
|
||
|
|
instanceData: { ...itemInstance }, // Store copy of original instance
|
||
|
|
};
|
||
|
|
|
||
|
|
this.marketState.buyback.push(buybackItem);
|
||
|
|
|
||
|
|
// 5. Save state
|
||
|
|
await this.persistence.saveMarketState(this.marketState);
|
||
|
|
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error selling item:", error);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Gets the current market state.
|
||
|
|
* @returns {MarketState}
|
||
|
|
*/
|
||
|
|
getState() {
|
||
|
|
return this.marketState;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Gets stock filtered by merchant type.
|
||
|
|
* @param {string} merchantType - "SMITH" | "TAILOR" | "ALCHEMIST" | "SCAVENGER" | "BUYBACK"
|
||
|
|
* @returns {MarketItem[]}
|
||
|
|
*/
|
||
|
|
getStockForMerchant(merchantType) {
|
||
|
|
if (merchantType === "BUYBACK") {
|
||
|
|
return this.marketState.buyback;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter stock by merchant type
|
||
|
|
const typeMap = {
|
||
|
|
SMITH: ["WEAPON", "ARMOR"],
|
||
|
|
TAILOR: ["ARMOR"],
|
||
|
|
ALCHEMIST: ["CONSUMABLE", "UTILITY"],
|
||
|
|
SCAVENGER: ["RELIC", "UTILITY"],
|
||
|
|
};
|
||
|
|
|
||
|
|
const allowedTypes = typeMap[merchantType] || [];
|
||
|
|
return this.marketState.stock.filter((item) =>
|
||
|
|
allowedTypes.includes(item.type)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cleanup - remove event listeners.
|
||
|
|
*/
|
||
|
|
destroy() {
|
||
|
|
window.removeEventListener("mission-victory", this._boundHandleMissionVictory);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|