- Introduce the MissionDebrief component to display after-action reports, including XP, rewards, and squad status. - Implement the MissionGenerator class to create procedural side missions, enhancing replayability and resource management. - Update mission schema to include mission objects for INTERACT objectives, improving mission complexity. - Enhance GameLoop and MissionManager to support new mission features and interactions. - Add tests for MissionDebrief and MissionGenerator to ensure functionality and integration within the game architecture.
540 lines
17 KiB
JavaScript
540 lines
17 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
|
|
// 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();
|
|
});
|
|
});
|
|
});
|
|
|