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), loadRoster: sinon.stub().resolves(null), saveRoster: sinon.stub().resolves(), loadCampaign: sinon.stub().resolves(null), saveCampaign: sinon.stub().resolves(), loadMarketState: sinon.stub().resolves(null), saveMarketState: sinon.stub().resolves(), loadHubStash: sinon.stub().resolves(null), saveHubStash: sinon.stub().resolves(), loadUnlocks: sinon.stub().resolves([]), saveUnlocks: sinon.stub().resolves(), clearRun: sinon.stub().resolves(), }; // Inject Mock (replacing the real Persistence instance) gameStateManager.persistence = mockPersistence; // 3. Mock GameLoop mockGameLoop = { init: sinon.spy(), startLevel: sinon.spy(), stop: sinon.spy(), }; // 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(), load: sinon.stub(), save: sinon.stub().returns({ completedMissions: [] }), completedMissions: new Set(), }; }); 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 ); }); 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"); }); it("CoA 3: handleEmbark should initialize run, save, and start engine", async () => { // 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); gameStateManager.setGameLoop(mockGameLoop); await gameStateManager.init(); const mockSquad = [{ id: "u1", isNew: false }]; // Existing unit, not new // Mock startLevel to resolve immediately mockGameLoop.startLevel = sinon.stub().resolves(); // Await the full async chain await gameStateManager.handleEmbark({ detail: { squad: mockSquad, mode: "SELECT" }, }); expect(gameStateManager.currentState).to.equal( GameStateManager.STATES.DEPLOYMENT ); expect(mockPersistence.saveRun.calledWith(gameStateManager.activeRunData)) .to.be.true; expect(mockGameLoop.startLevel.calledWith(gameStateManager.activeRunData)) .to.be.true; }); 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"); }); 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; }); 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"); }); it("CoA 8: mission-sequence-complete should clear run but NOT transition to MAIN_MENU automatically", async () => { // Setup await gameStateManager.init(); gameStateManager.activeRunData = { id: "RUN_123" }; // Spy on transitionTo const transitionSpy = sinon.spy(gameStateManager, "transitionTo"); // Dispatch event window.dispatchEvent( new CustomEvent("mission-sequence-complete", { detail: { missionId: "MISSION_TEST" }, }) ); // Wait for async listeners await new Promise((resolve) => setTimeout(resolve, 50)); // Assert: Run should be cleared expect(mockPersistence.clearRun.called).to.be.true; expect(gameStateManager.activeRunData).to.be.null; // Assert: Should NOT have transitioned to MAIN_MENU (GameLoop handles this after debrief) const callsToMainMenu = transitionSpy .getCalls() .filter((call) => call.args[0] === GameStateManager.STATES.MAIN_MENU); // Initial init() calls it once, so we check if it was called AGAIN // Actually, checking "called" might be tricky if init() called it. // Let's check call count. init() makes 1 call. // If mission-sequence-complete triggered it, we'd see 2. // But better yet, let's reset history before dispatch. transitionSpy.resetHistory(); // Re-dispatch to be sure we are testing the listener window.dispatchEvent( new CustomEvent("mission-sequence-complete", { detail: { missionId: "MISSION_TEST" }, }) ); await new Promise((resolve) => setTimeout(resolve, 50)); expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be .false; }); });