/** * 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} */ 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} */ 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} */ 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} - 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 market state await this.persistence.saveMarketState(this.marketState); // 6. Save hub stash to localStorage (via GameStateManager) // Import GameStateManager to access _saveHubStash const { gameStateManager } = await import("../core/GameStateManager.js"); await gameStateManager._saveHubStash(); // 7. Dispatch wallet-updated event to refresh UI window.dispatchEvent( new CustomEvent("wallet-updated", { bubbles: true, composed: true, }) ); 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} - 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. Save hub stash to localStorage (via GameStateManager) const { gameStateManager } = await import("../core/GameStateManager.js"); await gameStateManager._saveHubStash(); // 5. Dispatch wallet-updated event to refresh UI window.dispatchEvent( new CustomEvent("wallet-updated", { bubbles: true, composed: true, }) ); // 6. 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); // 7. Save market 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 ); } }