aether-shards/src/managers/MarketManager.js
Matthew Mone 45276d1bd4 Implement BarracksScreen for roster management and enhance game state integration
- Introduce the BarracksScreen component to manage the player roster, allowing for unit selection, healing, and dismissal.
- Update GameStateManager to support roster persistence and integrate with the new BarracksScreen for seamless data handling.
- Enhance UI components for improved user experience, including dynamic filtering and sorting of units.
- Implement detailed unit information display and actions for healing and dismissing units.
- Add comprehensive tests for the BarracksScreen to validate functionality and integration with the overall game architecture.
2025-12-31 20:11:00 -08:00

474 lines
14 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 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<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. 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
);
}
}