aether-shards/test/managers/MarketManager.test.js

541 lines
17 KiB
JavaScript
Raw Permalink Normal View History

import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
import { MarketManager } from "../../src/managers/MarketManager.js";
import { InventoryManager } from "../../src/managers/InventoryManager.js";
import { InventoryContainer } from "../../src/models/InventoryContainer.js";
import { Item } from "../../src/items/Item.js";
import { ItemRegistry } from "../../src/managers/ItemRegistry.js";
describe("Manager: MarketManager", () => {
let marketManager;
let mockPersistence;
let mockItemRegistry;
let mockInventoryManager;
let mockMissionManager;
let hubStash;
beforeEach(() => {
// Mock Persistence
mockPersistence = {
init: sinon.stub().resolves(),
saveMarketState: sinon.stub().resolves(),
loadMarketState: sinon.stub().resolves(null),
};
// Mock Item Registry
mockItemRegistry = {
loadAll: sinon.stub().resolves(),
get: (defId) => {
const items = {
"ITEM_RUSTY_BLADE": new Item({
id: "ITEM_RUSTY_BLADE",
name: "Rusty Infantry Blade",
type: "WEAPON",
rarity: "COMMON",
stats: { attack: 3 },
}),
"ITEM_SCRAP_PLATE": new Item({
id: "ITEM_SCRAP_PLATE",
name: "Scrap Plate Armor",
type: "ARMOR",
rarity: "COMMON",
stats: { defense: 3 },
}),
"ITEM_APPRENTICE_WAND": new Item({
id: "ITEM_APPRENTICE_WAND",
name: "Apprentice Spark-Wand",
type: "WEAPON",
rarity: "COMMON",
stats: { magic: 4 },
}),
"ITEM_ROBES": new Item({
id: "ITEM_ROBES",
name: "Novice Robes",
type: "ARMOR",
rarity: "COMMON",
stats: { willpower: 3 },
}),
"ITEM_UNCOMMON_SWORD": new Item({
id: "ITEM_UNCOMMON_SWORD",
name: "Steel Blade",
type: "WEAPON",
rarity: "UNCOMMON",
stats: { attack: 5 },
}),
};
return items[defId] || null;
},
getAll: () => {
return [
new Item({
id: "ITEM_RUSTY_BLADE",
name: "Rusty Infantry Blade",
type: "WEAPON",
rarity: "COMMON",
stats: { attack: 3 },
}),
new Item({
id: "ITEM_SCRAP_PLATE",
name: "Scrap Plate Armor",
type: "ARMOR",
rarity: "COMMON",
stats: { defense: 3 },
}),
new Item({
id: "ITEM_APPRENTICE_WAND",
name: "Apprentice Spark-Wand",
type: "WEAPON",
rarity: "COMMON",
stats: { magic: 4 },
}),
new Item({
id: "ITEM_ROBES",
name: "Novice Robes",
type: "ARMOR",
rarity: "COMMON",
stats: { willpower: 3 },
}),
new Item({
id: "ITEM_UNCOMMON_SWORD",
name: "Steel Blade",
type: "WEAPON",
rarity: "UNCOMMON",
stats: { attack: 5 },
}),
];
},
};
// Create real hub stash
hubStash = new InventoryContainer("HUB_VAULT");
hubStash.currency.aetherShards = 1000; // Give player some currency
// Mock Inventory Manager
mockInventoryManager = {
hubStash: hubStash,
};
// Mock Mission Manager
mockMissionManager = {
completedMissions: new Set(),
};
marketManager = new MarketManager(
mockPersistence,
mockItemRegistry,
mockInventoryManager,
mockMissionManager
);
});
afterEach(() => {
// Clean up event listeners
if (marketManager) {
marketManager.destroy();
}
// Remove any event listeners
window.removeEventListener("mission-victory", () => {});
});
describe("Initialization", () => {
it("should initialize and load state from persistence", async () => {
const savedState = {
generationId: "TEST_123",
stock: [],
buyback: [],
};
mockPersistence.loadMarketState.resolves(savedState);
await marketManager.init();
expect(mockPersistence.loadMarketState.called).to.be.true;
expect(mockItemRegistry.loadAll.called).to.be.true;
expect(marketManager.marketState).to.deep.equal(savedState);
});
it("should generate initial Tier 1 stock if no saved state", async () => {
mockPersistence.loadMarketState.resolves(null);
await marketManager.init();
expect(marketManager.marketState).to.exist;
expect(marketManager.marketState.stock.length).to.be.greaterThan(0);
expect(mockPersistence.saveMarketState.called).to.be.true;
});
});
describe("Stock Generation", () => {
beforeEach(async () => {
await marketManager.init();
});
it("should generate Tier 1 stock with only Common items", async () => {
await marketManager.generateStock(1);
const stock = marketManager.marketState.stock;
expect(stock.length).to.be.greaterThan(0);
stock.forEach((item) => {
expect(item.rarity).to.equal("COMMON");
});
});
it("should generate Tier 2 stock with proper rarity distribution", async () => {
await marketManager.generateStock(2);
const stock = marketManager.marketState.stock;
expect(stock.length).to.be.greaterThan(0);
// Check that we have items of different rarities
// Tier 2 uses weights: COMMON (60%), UNCOMMON (30%), RARE (10%)
// With 16 items total, we should have multiple rarities
const rarities = stock.map((item) => item.rarity);
const uniqueRarities = [...new Set(rarities)];
// Should have at least 2 different rarities (very likely with 16 items)
// If we only get one rarity, try again (probabilistic test)
if (uniqueRarities.length < 2) {
// Retry once
await marketManager.generateStock(2);
const stock2 = marketManager.marketState.stock;
const rarities2 = stock2.map((item) => item.rarity);
const uniqueRarities2 = [...new Set(rarities2)];
expect(uniqueRarities2.length).to.be.at.least(1);
// Verify rarities are valid Tier 2 rarities
uniqueRarities2.forEach((rarity) => {
expect(["COMMON", "UNCOMMON", "RARE"]).to.include(rarity);
});
} else {
// Verify rarities are valid Tier 2 rarities
uniqueRarities.forEach((rarity) => {
expect(["COMMON", "UNCOMMON", "RARE"]).to.include(rarity);
});
}
});
it("should assign unique stock IDs", async () => {
await marketManager.generateStock(1);
const stock = marketManager.marketState.stock;
const ids = stock.map((item) => item.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).to.equal(ids.length);
});
it("should calculate prices with variance", async () => {
await marketManager.generateStock(1);
const stock = marketManager.marketState.stock;
stock.forEach((item) => {
expect(item.price).to.be.a("number");
expect(item.price).to.be.greaterThan(0);
});
});
it("should save state after generation", async () => {
await marketManager.generateStock(1);
expect(mockPersistence.saveMarketState.called).to.be.true;
const savedState = mockPersistence.saveMarketState.firstCall.args[0];
expect(savedState).to.equal(marketManager.marketState);
});
});
describe("CoA 1: Persistence Integrity", () => {
beforeEach(async () => {
await marketManager.init();
await marketManager.generateStock(1);
});
it("should maintain stock after reload", async () => {
const originalStock = [...marketManager.marketState.stock];
const originalGenerationId = marketManager.marketState.generationId;
// Simulate reload
mockPersistence.loadMarketState.resolves(marketManager.marketState);
await marketManager.init();
expect(marketManager.marketState.generationId).to.equal(
originalGenerationId
);
expect(marketManager.marketState.stock.length).to.equal(
originalStock.length
);
});
it("should mark purchased items as sold out after buy", async () => {
const stockItem = marketManager.marketState.stock[0];
const originalPrice = stockItem.price;
// Set currency
hubStash.currency.aetherShards = originalPrice;
const success = await marketManager.buyItem(stockItem.id);
expect(success).to.be.true;
expect(stockItem.purchased).to.be.true;
// Reload and verify
mockPersistence.loadMarketState.resolves(marketManager.marketState);
await marketManager.init();
const reloadedItem = marketManager.marketState.stock.find(
(item) => item.id === stockItem.id
);
expect(reloadedItem.purchased).to.be.true;
});
});
describe("CoA 2: Currency Math", () => {
beforeEach(async () => {
await marketManager.init();
await marketManager.generateStock(1);
});
it("should deduct exact price when buying", async () => {
const stockItem = marketManager.marketState.stock[0];
const originalPrice = stockItem.price;
const originalCurrency = 1000;
hubStash.currency.aetherShards = originalCurrency;
await marketManager.buyItem(stockItem.id);
expect(hubStash.currency.aetherShards).to.equal(
originalCurrency - originalPrice
);
});
it("should refund exact sell price (25% of base)", async () => {
// Add an item to stash first
const itemInstance = {
uid: "ITEM_TEST_123",
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
};
hubStash.addItem(itemInstance);
const originalCurrency = 1000;
hubStash.currency.aetherShards = originalCurrency;
// Get base price calculation
const itemDef = mockItemRegistry.get("ITEM_RUSTY_BLADE");
const basePrice = 50 + Object.values(itemDef.stats).reduce((sum, val) => sum + val, 0) * 10;
const expectedSellPrice = Math.floor(basePrice * 0.25);
const success = await marketManager.sellItem(itemInstance.uid);
expect(success).to.be.true;
expect(hubStash.currency.aetherShards).to.equal(
originalCurrency + expectedSellPrice
);
});
it("should allow buyback at exact sell price", async () => {
// Add and sell an item
const itemInstance = {
uid: "ITEM_TEST_456",
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
};
hubStash.addItem(itemInstance);
const originalCurrency = 1000;
hubStash.currency.aetherShards = originalCurrency;
await marketManager.sellItem(itemInstance.uid);
const sellPrice = hubStash.currency.aetherShards - originalCurrency;
const buybackItem = marketManager.marketState.buyback[0];
// Try to buy back
hubStash.currency.aetherShards = originalCurrency + sellPrice;
const buybackSuccess = await marketManager.buyItem(buybackItem.id);
expect(buybackSuccess).to.be.true;
expect(buybackItem.price).to.equal(sellPrice);
});
});
describe("CoA 3: Atomic Transactions", () => {
beforeEach(async () => {
await marketManager.init();
await marketManager.generateStock(1);
});
it("should not add item if currency is insufficient", async () => {
const stockItem = marketManager.marketState.stock[0];
hubStash.currency.aetherShards = stockItem.price - 1; // Not enough
const originalItemCount = hubStash.items.length;
const success = await marketManager.buyItem(stockItem.id);
expect(success).to.be.false;
expect(hubStash.items.length).to.equal(originalItemCount);
expect(hubStash.currency.aetherShards).to.equal(stockItem.price - 1);
});
it("should not add currency if item removal fails", async () => {
const originalCurrency = hubStash.currency.aetherShards;
const nonExistentUid = "ITEM_NONEXISTENT";
const success = await marketManager.sellItem(nonExistentUid);
expect(success).to.be.false;
expect(hubStash.currency.aetherShards).to.equal(originalCurrency);
});
it("should complete transaction atomically on success", async () => {
const stockItem = marketManager.marketState.stock[0];
hubStash.currency.aetherShards = stockItem.price;
const originalItemCount = hubStash.items.length;
const success = await marketManager.buyItem(stockItem.id);
expect(success).to.be.true;
expect(hubStash.items.length).to.equal(originalItemCount + 1);
expect(stockItem.purchased).to.be.true;
expect(hubStash.currency.aetherShards).to.equal(0);
});
});
describe("CoA 4: Stock Generation", () => {
it("should generate Tier 1 stock with only Common items", async () => {
await marketManager.init();
await marketManager.generateStock(1);
const stock = marketManager.marketState.stock;
stock.forEach((item) => {
expect(item.rarity).to.equal("COMMON");
});
});
it("should generate stock only when needsRefresh is true", async () => {
await marketManager.init();
const originalStock = [...marketManager.marketState.stock];
// Check refresh without flag
await marketManager.checkRefresh();
expect(marketManager.marketState.stock).to.deep.equal(originalStock);
// Set flag and check again
marketManager.needsRefresh = true;
await marketManager.checkRefresh();
expect(marketManager.needsRefresh).to.be.false;
// Stock should be different (new generation)
expect(marketManager.marketState.generationId).to.not.equal(
originalStock.length > 0 ? originalStock[0].id : "none"
);
});
it("should listen for mission-victory event", async () => {
await marketManager.init();
// Reset needsRefresh to false first
marketManager.needsRefresh = false;
window.dispatchEvent(new CustomEvent("mission-victory"));
// Give event listener time to process
await new Promise((resolve) => setTimeout(resolve, 10));
expect(marketManager.needsRefresh).to.be.true;
});
});
describe("CoA 5: Buyback Limit", () => {
beforeEach(async () => {
await marketManager.init();
});
it("should limit buyback to 10 items", async () => {
// Sell 11 items
for (let i = 0; i < 11; i++) {
const itemInstance = {
uid: `ITEM_TEST_${i}`,
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
};
hubStash.addItem(itemInstance);
await marketManager.sellItem(itemInstance.uid);
}
expect(marketManager.marketState.buyback.length).to.equal(10);
});
it("should remove oldest item when limit exceeded", async () => {
// Sell 10 items first
const firstItemUid = "ITEM_TEST_FIRST";
hubStash.addItem({
uid: firstItemUid,
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
});
await marketManager.sellItem(firstItemUid);
const firstBuybackId = marketManager.marketState.buyback[0].id;
// Sell 10 more items
for (let i = 0; i < 10; i++) {
const itemInstance = {
uid: `ITEM_TEST_${i}`,
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
};
hubStash.addItem(itemInstance);
await marketManager.sellItem(itemInstance.uid);
}
// First item should be removed
const buybackIds = marketManager.marketState.buyback.map(
(item) => item.id
);
expect(buybackIds).to.not.include(firstBuybackId);
expect(marketManager.marketState.buyback.length).to.equal(10);
});
});
describe("Merchant Filtering", () => {
beforeEach(async () => {
await marketManager.init();
await marketManager.generateStock(1);
});
it("should filter stock by merchant type", () => {
const smithStock = marketManager.getStockForMerchant("SMITH");
smithStock.forEach((item) => {
expect(["WEAPON", "ARMOR"]).to.include(item.type);
});
const buybackStock = marketManager.getStockForMerchant("BUYBACK");
expect(buybackStock).to.deep.equal(marketManager.marketState.buyback);
});
});
describe("State Management", () => {
it("should return current state", async () => {
await marketManager.init();
const state = marketManager.getState();
expect(state).to.exist;
expect(state).to.have.property("generationId");
expect(state).to.have.property("stock");
expect(state).to.have.property("buyback");
});
it("should clean up event listeners on destroy", () => {
const removeSpy = sinon.spy(window, "removeEventListener");
marketManager.destroy();
expect(removeSpy.called).to.be.true;
removeSpy.restore();
});
});
});