- Introduce the Marketplace system, managed by MarketManager, to facilitate buying and selling items, enhancing player engagement and resource management. - Update GameStateManager to integrate the new MarketManager, ensuring seamless data handling and persistence for market transactions. - Add specifications for the Marketplace UI, detailing layout, functionality, and conditions of acceptance to ensure a robust user experience. - Refactor existing components to support the new marketplace features, including dynamic inventory updates and currency management. - Enhance testing coverage for the MarketManager and MarketplaceScreen to validate functionality and integration within the game architecture.
518 lines
16 KiB
JavaScript
518 lines
16 KiB
JavaScript
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 (at least some)
|
|
const rarities = stock.map((item) => item.rarity);
|
|
expect(rarities).to.include.members(["COMMON", "UNCOMMON"]);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|
|
|