2025-12-19 23:07:36 +00:00
|
|
|
import { expect } from "@esm-bundle/chai";
|
|
|
|
|
import sinon from "sinon";
|
|
|
|
|
// Import the singleton instance AND the class for constants
|
|
|
|
|
import {
|
|
|
|
|
gameStateManager,
|
|
|
|
|
GameStateManager,
|
|
|
|
|
} from "../../src/core/GameStateManager.js";
|
|
|
|
|
|
|
|
|
|
describe("Core: GameStateManager (Singleton)", () => {
|
|
|
|
|
let mockPersistence;
|
|
|
|
|
let mockGameLoop;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
// 1. Reset Singleton State
|
|
|
|
|
gameStateManager.reset();
|
|
|
|
|
|
|
|
|
|
// 2. Mock Persistence
|
|
|
|
|
mockPersistence = {
|
|
|
|
|
init: sinon.stub().resolves(),
|
|
|
|
|
saveRun: sinon.stub().resolves(),
|
|
|
|
|
loadRun: sinon.stub().resolves(null),
|
2025-12-22 05:20:33 +00:00
|
|
|
loadRoster: sinon.stub().resolves(null),
|
|
|
|
|
saveRoster: sinon.stub().resolves(),
|
2025-12-31 23:06:07 +00:00
|
|
|
loadCampaign: sinon.stub().resolves(null),
|
|
|
|
|
saveCampaign: sinon.stub().resolves(),
|
|
|
|
|
loadMarketState: sinon.stub().resolves(null),
|
|
|
|
|
saveMarketState: sinon.stub().resolves(),
|
2026-01-01 04:11:00 +00:00
|
|
|
loadHubStash: sinon.stub().resolves(null),
|
|
|
|
|
saveHubStash: sinon.stub().resolves(),
|
|
|
|
|
loadUnlocks: sinon.stub().resolves([]),
|
|
|
|
|
saveUnlocks: sinon.stub().resolves(),
|
2025-12-19 23:07:36 +00:00
|
|
|
};
|
|
|
|
|
// Inject Mock (replacing the real Persistence instance)
|
|
|
|
|
gameStateManager.persistence = mockPersistence;
|
|
|
|
|
|
|
|
|
|
// 3. Mock GameLoop
|
|
|
|
|
mockGameLoop = {
|
|
|
|
|
init: sinon.spy(),
|
|
|
|
|
startLevel: sinon.spy(),
|
|
|
|
|
stop: sinon.spy(),
|
|
|
|
|
};
|
2025-12-22 05:20:33 +00:00
|
|
|
|
|
|
|
|
// 4. Mock MissionManager
|
|
|
|
|
gameStateManager.missionManager = {
|
|
|
|
|
setupActiveMission: sinon.stub(),
|
|
|
|
|
getActiveMission: sinon.stub().returns({
|
|
|
|
|
id: "MISSION_TUTORIAL_01",
|
|
|
|
|
config: { title: "Test Mission" },
|
|
|
|
|
biome: {
|
|
|
|
|
generator_config: {
|
|
|
|
|
seed_type: "RANDOM",
|
|
|
|
|
seed: 12345,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
objectives: [],
|
|
|
|
|
}),
|
|
|
|
|
playIntro: sinon.stub().resolves(),
|
2025-12-31 23:06:07 +00:00
|
|
|
load: sinon.stub(),
|
|
|
|
|
save: sinon.stub().returns({ completedMissions: [] }),
|
|
|
|
|
completedMissions: new Set(),
|
2025-12-22 05:20:33 +00:00
|
|
|
};
|
2025-12-19 23:07:36 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 1: Should initialize and transition to MAIN_MENU", async () => {
|
|
|
|
|
const eventSpy = sinon.spy();
|
|
|
|
|
window.addEventListener("gamestate-changed", eventSpy);
|
|
|
|
|
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
|
|
|
|
|
expect(mockPersistence.init.calledOnce).to.be.true;
|
|
|
|
|
expect(gameStateManager.currentState).to.equal(
|
|
|
|
|
GameStateManager.STATES.MAIN_MENU
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(eventSpy.called).to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 2: startNewGame should transition to TEAM_BUILDER", async () => {
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
gameStateManager.startNewGame();
|
|
|
|
|
|
|
|
|
|
expect(gameStateManager.currentState).to.equal(
|
|
|
|
|
GameStateManager.STATES.TEAM_BUILDER
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-01 04:11:00 +00:00
|
|
|
it("Should load hub stash from IndexedDB on init", async () => {
|
|
|
|
|
const hubStashData = {
|
|
|
|
|
currency: { aetherShards: 500, ancientCores: 10 },
|
|
|
|
|
items: [
|
|
|
|
|
{ uid: "ITEM_1", defId: "ITEM_RUSTY_BLADE", isNew: false, quantity: 1 },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
mockPersistence.loadHubStash.resolves(hubStashData);
|
|
|
|
|
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
|
|
|
|
|
expect(mockPersistence.loadHubStash.calledOnce).to.be.true;
|
|
|
|
|
expect(gameStateManager.hubStash.currency.aetherShards).to.equal(500);
|
|
|
|
|
expect(gameStateManager.hubStash.currency.ancientCores).to.equal(10);
|
|
|
|
|
expect(gameStateManager.hubStash.getAllItems()).to.have.length(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("Should save hub stash to IndexedDB", async () => {
|
|
|
|
|
gameStateManager.hubStash.currency.aetherShards = 1000;
|
|
|
|
|
gameStateManager.hubStash.addItem({
|
|
|
|
|
uid: "ITEM_2",
|
|
|
|
|
defId: "ITEM_SCRAP_PLATE",
|
|
|
|
|
isNew: true,
|
|
|
|
|
quantity: 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await gameStateManager._saveHubStash();
|
|
|
|
|
|
|
|
|
|
expect(mockPersistence.saveHubStash.calledOnce).to.be.true;
|
|
|
|
|
const savedData = mockPersistence.saveHubStash.firstCall.args[0];
|
|
|
|
|
expect(savedData.currency.aetherShards).to.equal(1000);
|
|
|
|
|
expect(savedData.items).to.have.length(1);
|
|
|
|
|
expect(savedData.items[0].defId).to.equal("ITEM_SCRAP_PLATE");
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
it("CoA 3: handleEmbark should initialize run, save, and start engine", async () => {
|
2025-12-28 00:54:03 +00:00
|
|
|
// Mock RosterManager.recruitUnit to return async unit with generated name
|
|
|
|
|
const mockRecruitedUnit = {
|
|
|
|
|
id: "UNIT_123",
|
|
|
|
|
name: "Valerius", // Generated character name
|
|
|
|
|
className: "Vanguard", // Class name
|
|
|
|
|
classId: "CLASS_VANGUARD",
|
|
|
|
|
};
|
|
|
|
|
gameStateManager.rosterManager.recruitUnit = sinon.stub().resolves(mockRecruitedUnit);
|
2025-12-19 23:07:36 +00:00
|
|
|
gameStateManager.setGameLoop(mockGameLoop);
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
const mockSquad = [{ id: "u1", isNew: false }]; // Existing unit, not new
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-22 05:20:33 +00:00
|
|
|
// Mock startLevel to resolve immediately
|
|
|
|
|
mockGameLoop.startLevel = sinon.stub().resolves();
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-22 05:20:33 +00:00
|
|
|
// Await the full async chain
|
2025-12-28 00:54:03 +00:00
|
|
|
await gameStateManager.handleEmbark({ detail: { squad: mockSquad, mode: "SELECT" } });
|
2025-12-19 23:07:36 +00:00
|
|
|
|
|
|
|
|
expect(gameStateManager.currentState).to.equal(
|
2025-12-22 05:20:33 +00:00
|
|
|
GameStateManager.STATES.DEPLOYMENT
|
2025-12-19 23:07:36 +00:00
|
|
|
);
|
|
|
|
|
expect(mockPersistence.saveRun.calledWith(gameStateManager.activeRunData))
|
|
|
|
|
.to.be.true;
|
|
|
|
|
expect(mockGameLoop.startLevel.calledWith(gameStateManager.activeRunData))
|
|
|
|
|
.to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
it("CoA 3b: handleEmbark should dispatch run-data-updated event", async () => {
|
|
|
|
|
// Mock RosterManager.recruitUnit
|
|
|
|
|
const mockRecruitedUnit = {
|
|
|
|
|
id: "UNIT_123",
|
|
|
|
|
name: "Valerius",
|
|
|
|
|
className: "Vanguard",
|
|
|
|
|
classId: "CLASS_VANGUARD",
|
|
|
|
|
};
|
|
|
|
|
gameStateManager.rosterManager.recruitUnit = sinon.stub().resolves(mockRecruitedUnit);
|
|
|
|
|
gameStateManager.setGameLoop(mockGameLoop);
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
|
|
|
|
|
const mockSquad = [{ id: "u1", isNew: true, name: "Vanguard", classId: "CLASS_VANGUARD" }];
|
|
|
|
|
mockGameLoop.startLevel = sinon.stub().resolves();
|
|
|
|
|
|
|
|
|
|
let eventDispatched = false;
|
|
|
|
|
let eventData = null;
|
|
|
|
|
window.addEventListener("run-data-updated", (e) => {
|
|
|
|
|
eventDispatched = true;
|
|
|
|
|
eventData = e.detail.runData;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await gameStateManager.handleEmbark({ detail: { squad: mockSquad, mode: "DRAFT" } });
|
|
|
|
|
|
|
|
|
|
expect(eventDispatched).to.be.true;
|
|
|
|
|
expect(eventData).to.exist;
|
|
|
|
|
expect(eventData.squad).to.exist;
|
|
|
|
|
expect(eventData.squad[0].name).to.equal("Valerius");
|
|
|
|
|
expect(eventData.squad[0].className).to.equal("Vanguard");
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
it("CoA 4: continueGame should load save and resume engine", async () => {
|
|
|
|
|
gameStateManager.setGameLoop(mockGameLoop);
|
|
|
|
|
|
|
|
|
|
const savedData = { seed: 999, depth: 5, squad: [] };
|
|
|
|
|
mockPersistence.loadRun.resolves(savedData);
|
|
|
|
|
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
await gameStateManager.continueGame();
|
|
|
|
|
|
|
|
|
|
expect(mockPersistence.loadRun.called).to.be.true;
|
|
|
|
|
expect(gameStateManager.activeRunData).to.deep.equal(savedData);
|
|
|
|
|
expect(mockGameLoop.startLevel.calledWith(savedData)).to.be.true;
|
|
|
|
|
});
|
2025-12-31 23:06:07 +00:00
|
|
|
|
|
|
|
|
it("CoA 5: init should load campaign data if available", async () => {
|
|
|
|
|
const savedCampaignData = {
|
|
|
|
|
completedMissions: ["MISSION_TUTORIAL_01"],
|
|
|
|
|
};
|
|
|
|
|
mockPersistence.loadCampaign.resolves(savedCampaignData);
|
|
|
|
|
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
|
|
|
|
|
expect(mockPersistence.loadCampaign.called).to.be.true;
|
|
|
|
|
expect(gameStateManager.missionManager.load.calledWith(savedCampaignData)).to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 6: init should handle missing campaign data gracefully", async () => {
|
|
|
|
|
mockPersistence.loadCampaign.resolves(null);
|
|
|
|
|
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
|
|
|
|
|
expect(mockPersistence.loadCampaign.called).to.be.true;
|
|
|
|
|
expect(gameStateManager.missionManager.load.called).to.be.false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 7: campaign-data-changed event should trigger save", async () => {
|
|
|
|
|
await gameStateManager.init();
|
|
|
|
|
|
|
|
|
|
// Clear any previous calls
|
|
|
|
|
mockPersistence.saveCampaign.resetHistory();
|
|
|
|
|
|
|
|
|
|
// Dispatch campaign-data-changed event
|
|
|
|
|
window.dispatchEvent(new CustomEvent("campaign-data-changed", {
|
|
|
|
|
detail: { missionCompleted: "MISSION_TUTORIAL_01" }
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Wait for async save (event listener is synchronous but save is async)
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
|
|
|
|
|
|
expect(mockPersistence.saveCampaign.called).to.be.true;
|
|
|
|
|
const savedData = mockPersistence.saveCampaign.firstCall.args[0];
|
|
|
|
|
expect(savedData).to.exist;
|
|
|
|
|
expect(savedData.completedMissions).to.be.an("array");
|
|
|
|
|
});
|
2025-12-19 23:07:36 +00:00
|
|
|
});
|