import { expect } from "@esm-bundle/chai"; import sinon from "sinon"; import { MissionManager } from "../../src/managers/MissionManager.js"; import { narrativeManager } from "../../src/managers/NarrativeManager.js"; describe("Manager: MissionManager", () => { let manager; let mockNarrativeManager; let mockPersistence; beforeEach(() => { // Create mock persistence mockPersistence = { loadUnlocks: sinon.stub().resolves([]), saveUnlocks: sinon.stub().resolves(), }; manager = new MissionManager(mockPersistence); // Mock narrativeManager mockNarrativeManager = { startSequence: sinon.stub(), addEventListener: sinon.stub(), removeEventListener: sinon.stub(), }; // Replace the singleton reference in the manager if possible // Since it's imported, we'll need to stub the methods we use sinon.stub(narrativeManager, "startSequence"); sinon.stub(narrativeManager, "addEventListener"); sinon.stub(narrativeManager, "removeEventListener"); }); afterEach(() => { sinon.restore(); }); it("CoA 1: Should initialize with tutorial mission registered", async () => { await manager._ensureMissionsLoaded(); expect(manager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true; expect(manager.activeMissionId).to.be.null; expect(manager.completedMissions).to.be.instanceof(Set); }); it("CoA 2: registerMission should add mission to registry", () => { const newMission = { id: "MISSION_TEST_01", config: { title: "Test Mission" }, objectives: { primary: [] }, }; manager.registerMission(newMission); expect(manager.missionRegistry.has("MISSION_TEST_01")).to.be.true; expect(manager.missionRegistry.get("MISSION_TEST_01")).to.equal(newMission); }); it("CoA 3: getActiveMission should return tutorial if no active mission", async () => { await manager._ensureMissionsLoaded(); const mission = await manager.getActiveMission(); expect(mission).to.exist; expect(mission.id).to.equal("MISSION_TUTORIAL_01"); }); it("CoA 4: getActiveMission should return active mission if set", async () => { const testMission = { id: "MISSION_TEST_01", config: { title: "Test" }, objectives: { primary: [] }, }; manager.registerMission(testMission); manager.activeMissionId = "MISSION_TEST_01"; const mission = await manager.getActiveMission(); expect(mission.id).to.equal("MISSION_TEST_01"); }); it("CoA 5: setupActiveMission should initialize objectives", async () => { await manager._ensureMissionsLoaded(); const mission = await manager.getActiveMission(); mission.objectives = { primary: [ { type: "ELIMINATE_ALL", target_count: 5 }, { type: "ELIMINATE_UNIT", target_def_id: "ENEMY_GOBLIN", target_count: 3 }, ], }; await manager.setupActiveMission(); expect(manager.currentObjectives).to.have.length(2); expect(manager.currentObjectives[0].current).to.equal(0); expect(manager.currentObjectives[0].complete).to.be.false; expect(manager.currentObjectives[1].target_count).to.equal(3); }); it("CoA 6: onGameEvent should complete ELIMINATE_ALL when all enemies are dead", () => { const mockUnitManager = { activeUnits: new Map([ ["PLAYER_1", { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }], ]), getUnitsByTeam: sinon.stub().returns([]), // No enemies left }; manager.setUnitManager(mockUnitManager); manager.setupActiveMission(); manager.currentObjectives = [ { type: "ELIMINATE_ALL", complete: false }, ]; manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" }); expect(manager.currentObjectives[0].complete).to.be.true; }); it("CoA 7: onGameEvent should update ELIMINATE_UNIT objectives for specific unit", () => { manager.setupActiveMission(); manager.currentObjectives = [ { type: "ELIMINATE_UNIT", target_def_id: "ENEMY_GOBLIN", target_count: 2, current: 0, complete: false, }, ]; manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN" }); manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_OTHER" }); // Should not count manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN" }); expect(manager.currentObjectives[0].current).to.equal(2); expect(manager.currentObjectives[0].complete).to.be.true; }); it("CoA 8: checkVictory should dispatch mission-victory event when all objectives complete", async () => { const victorySpy = sinon.spy(); window.addEventListener("mission-victory", victorySpy); // Stub completeActiveMission to avoid async issues sinon.stub(manager, "completeActiveMission").resolves(); manager.setupActiveMission(); manager.currentObjectives = [ { type: "ELIMINATE_ALL", target_count: 2, current: 2, complete: true }, ]; manager.activeMissionId = "MISSION_TUTORIAL_01"; manager.currentMissionDef = { id: "MISSION_TUTORIAL_01", rewards: { guaranteed: {} }, }; manager.checkVictory(); expect(victorySpy.called).to.be.true; expect(manager.completeActiveMission.called).to.be.true; window.removeEventListener("mission-victory", victorySpy); }); it("CoA 9: completeActiveMission should add mission to completed set", async () => { manager.activeMissionId = "MISSION_TUTORIAL_01"; manager.currentMissionDef = { id: "MISSION_TUTORIAL_01", rewards: { guaranteed: {} }, }; // Spy on window.dispatchEvent to verify campaign-data-changed event const eventSpy = sinon.spy(); window.addEventListener("campaign-data-changed", eventSpy); await manager.completeActiveMission(); expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true; expect(eventSpy.called).to.be.true; expect(eventSpy.firstCall.args[0].detail.missionCompleted).to.equal("MISSION_TUTORIAL_01"); window.removeEventListener("campaign-data-changed", eventSpy); }); it("CoA 10: load should restore completed missions", () => { const saveData = { completedMissions: ["MISSION_TUTORIAL_01", "MISSION_TEST_01"], }; manager.load(saveData); expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true; expect(manager.completedMissions.has("MISSION_TEST_01")).to.be.true; }); it("CoA 11: save should serialize completed missions", () => { manager.completedMissions.add("MISSION_TUTORIAL_01"); manager.completedMissions.add("MISSION_TEST_01"); const saved = manager.save(); expect(saved.completedMissions).to.be.an("array"); expect(saved.completedMissions).to.include("MISSION_TUTORIAL_01"); expect(saved.completedMissions).to.include("MISSION_TEST_01"); }); it("CoA 12: _mapNarrativeIdToFileName should convert narrative IDs to filenames", () => { expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_INTRO")).to.equal( "tutorial_intro" ); expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_SUCCESS")).to.equal( "tutorial_success" ); // The implementation converts NARRATIVE_UNKNOWN to narrative_unknown (lowercase with NARRATIVE_ prefix removed) expect(manager._mapNarrativeIdToFileName("NARRATIVE_UNKNOWN")).to.equal("narrative_unknown"); }); it("CoA 13: getActiveMission should expose enemy_spawns from mission definition", () => { const missionWithEnemies = { id: "MISSION_TEST", config: { title: "Test Mission" }, enemy_spawns: [ { enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", count: 2 }, ], objectives: { primary: [] }, }; manager.registerMission(missionWithEnemies); manager.activeMissionId = "MISSION_TEST"; const mission = manager.getActiveMission(); expect(mission.enemy_spawns).to.exist; expect(mission.enemy_spawns).to.have.length(1); expect(mission.enemy_spawns[0].enemy_def_id).to.equal("ENEMY_SHARDBORN_SENTINEL"); expect(mission.enemy_spawns[0].count).to.equal(2); }); it("CoA 14: getActiveMission should expose deployment constraints with tutorial hints", () => { const missionWithDeployment = { id: "MISSION_TEST", config: { title: "Test Mission" }, deployment: { suggested_units: ["CLASS_VANGUARD", "CLASS_AETHER_WEAVER"], tutorial_hint: "Drag units from the bench to the Green Zone.", }, objectives: { primary: [] }, }; manager.registerMission(missionWithDeployment); manager.activeMissionId = "MISSION_TEST"; const mission = manager.getActiveMission(); expect(mission.deployment).to.exist; expect(mission.deployment.suggested_units).to.deep.equal([ "CLASS_VANGUARD", "CLASS_AETHER_WEAVER", ]); expect(mission.deployment.tutorial_hint).to.equal( "Drag units from the bench to the Green Zone." ); }); describe("Failure Conditions", () => { it("CoA 15: Should trigger SQUAD_WIPE failure when all player units die", () => { const failureSpy = sinon.spy(); window.addEventListener("mission-failure", failureSpy); const mockUnitManager = { activeUnits: new Map(), // No player units alive getUnitById: sinon.stub(), getUnitsByTeam: sinon.stub().returns([]), // No player units }; manager.setUnitManager(mockUnitManager); manager.setupActiveMission(); manager.failureConditions = [{ type: "SQUAD_WIPE" }]; manager.checkFailureConditions("PLAYER_DEATH", { unitId: "PLAYER_1" }); expect(failureSpy.called).to.be.true; expect(failureSpy.firstCall.args[0].detail.reason).to.equal("SQUAD_WIPE"); window.removeEventListener("mission-failure", failureSpy); }); it("CoA 16: Should trigger VIP_DEATH failure when VIP unit dies", () => { const failureSpy = sinon.spy(); window.addEventListener("mission-failure", failureSpy); const mockUnit = { id: "VIP_1", team: "PLAYER", tags: ["VIP_ESCORT"], }; const mockUnitManager = { getUnitById: sinon.stub().returns(mockUnit), }; manager.setUnitManager(mockUnitManager); manager.failureConditions = [ { type: "VIP_DEATH", target_tag: "VIP_ESCORT" }, ]; manager.checkFailureConditions("PLAYER_DEATH", { unitId: "VIP_1" }); expect(failureSpy.called).to.be.true; expect(failureSpy.firstCall.args[0].detail.reason).to.equal("VIP_DEATH"); window.removeEventListener("mission-failure", failureSpy); }); it("CoA 17: Should trigger TURN_LIMIT_EXCEEDED failure when turn limit is exceeded", () => { const failureSpy = sinon.spy(); window.addEventListener("mission-failure", failureSpy); manager.currentTurn = 11; manager.failureConditions = [{ type: "TURN_LIMIT_EXCEEDED", turn_limit: 10 }]; manager.checkFailureConditions("TURN_END", {}); expect(failureSpy.called).to.be.true; expect(failureSpy.firstCall.args[0].detail.reason).to.equal( "TURN_LIMIT_EXCEEDED" ); window.removeEventListener("mission-failure", failureSpy); }); }); describe("Additional Objective Types", () => { it("CoA 18: Should complete SURVIVE objective when turn count is reached", () => { manager.setupActiveMission(); manager.currentObjectives = [ { type: "SURVIVE", turn_count: 5, current: 0, complete: false, }, ]; manager.currentTurn = 0; manager.updateTurn(5); manager.onGameEvent("TURN_END", { turn: 5 }); expect(manager.currentObjectives[0].complete).to.be.true; }); it("CoA 19: Should complete REACH_ZONE objective when unit reaches target zone", () => { manager.setupActiveMission(); manager.currentObjectives = [ { type: "REACH_ZONE", zone_coords: [{ x: 5, y: 0, z: 5 }], complete: false, }, ]; manager.onGameEvent("UNIT_MOVE", { position: { x: 5, y: 0, z: 5 }, }); expect(manager.currentObjectives[0].complete).to.be.true; }); it("CoA 20: Should complete INTERACT objective when unit interacts with target object", () => { manager.setupActiveMission(); manager.currentObjectives = [ { type: "INTERACT", target_object_id: "OBJECT_LEVER", complete: false, }, ]; manager.onGameEvent("INTERACT", { objectId: "OBJECT_LEVER" }); expect(manager.currentObjectives[0].complete).to.be.true; }); it("CoA 21: Should complete ELIMINATE_ALL when all enemies are eliminated", () => { // Mock UnitManager with only player units (no enemies) const mockUnitManager = { activeUnits: new Map([ ["PLAYER_1", { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }], ]), }; manager.setUnitManager(mockUnitManager); manager.setupActiveMission(); manager.currentObjectives = [ { type: "ELIMINATE_ALL", complete: false, }, ]; manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" }); expect(manager.currentObjectives[0].complete).to.be.true; }); it("CoA 22: Should complete SQUAD_SURVIVAL objective when minimum units survive", () => { const mockUnitManager = { activeUnits: new Map([ ["PLAYER_1", { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }], ["PLAYER_2", { id: "PLAYER_2", team: "PLAYER", currentHealth: 100 }], ["PLAYER_3", { id: "PLAYER_3", team: "PLAYER", currentHealth: 100 }], ["PLAYER_4", { id: "PLAYER_4", team: "PLAYER", currentHealth: 100 }], ]), getUnitsByTeam: sinon.stub().returns([ { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }, { id: "PLAYER_2", team: "PLAYER", currentHealth: 100 }, { id: "PLAYER_3", team: "PLAYER", currentHealth: 100 }, { id: "PLAYER_4", team: "PLAYER", currentHealth: 100 }, ]), }; manager.setUnitManager(mockUnitManager); manager.setupActiveMission(); manager.currentObjectives = [ { type: "SQUAD_SURVIVAL", min_alive: 4, complete: false, }, ]; manager.onGameEvent("PLAYER_DEATH", { unitId: "PLAYER_5" }); expect(manager.currentObjectives[0].complete).to.be.true; }); }); describe("Secondary Objectives", () => { it("CoA 23: Should track secondary objectives separately from primary", () => { const mission = { id: "MISSION_TEST", config: { title: "Test" }, objectives: { primary: [{ type: "ELIMINATE_ALL", id: "PRIMARY_1" }], secondary: [ { type: "SURVIVE", turn_count: 10, id: "SECONDARY_1" }, ], }, }; manager.registerMission(mission); manager.activeMissionId = "MISSION_TEST"; manager.setupActiveMission(); expect(manager.currentObjectives).to.have.length(1); expect(manager.secondaryObjectives).to.have.length(1); expect(manager.secondaryObjectives[0].id).to.equal("SECONDARY_1"); }); it("CoA 24: Should update secondary objectives on game events", () => { manager.setupActiveMission(); manager.secondaryObjectives = [ { type: "SURVIVE", turn_count: 3, current: 0, complete: false, }, ]; manager.currentTurn = 0; manager.updateTurn(3); manager.onGameEvent("TURN_END", { turn: 3 }); expect(manager.secondaryObjectives[0].complete).to.be.true; }); }); describe("Reward Distribution", () => { beforeEach(() => { // Clear localStorage before each test localStorage.clear(); }); it("CoA 25: Should distribute guaranteed rewards on mission completion", () => { const rewardSpy = sinon.spy(); window.addEventListener("mission-rewards", rewardSpy); manager.activeMissionId = "MISSION_TEST"; manager.currentMissionDef = { id: "MISSION_TEST", rewards: { guaranteed: { xp: 500, currency: { aether_shards: 200 }, items: ["ITEM_ELITE_BLAST_PLATE"], unlocks: ["CLASS_SAPPER"], }, }, }; manager.distributeRewards(); expect(rewardSpy.called).to.be.true; const rewardData = rewardSpy.firstCall.args[0].detail; expect(rewardData.xp).to.equal(500); expect(rewardData.currency.aether_shards).to.equal(200); expect(rewardData.items).to.include("ITEM_ELITE_BLAST_PLATE"); expect(rewardData.unlocks).to.include("CLASS_SAPPER"); window.removeEventListener("mission-rewards", rewardSpy); }); it("CoA 26: Should distribute conditional rewards for completed secondary objectives", () => { const rewardSpy = sinon.spy(); window.addEventListener("mission-rewards", rewardSpy); manager.activeMissionId = "MISSION_TEST"; manager.currentMissionDef = { id: "MISSION_TEST", rewards: { guaranteed: { xp: 100 }, conditional: [ { objective_id: "OBJ_TIME_LIMIT", reward: { currency: { aether_shards: 100 } }, }, ], }, }; manager.secondaryObjectives = [ { id: "OBJ_TIME_LIMIT", complete: true, }, ]; manager.distributeRewards(); const rewardData = rewardSpy.firstCall.args[0].detail; expect(rewardData.currency.aether_shards).to.equal(100); window.removeEventListener("mission-rewards", rewardSpy); }); it("CoA 27: Should unlock classes and store in IndexedDB", async () => { await manager.unlockClasses(["CLASS_TINKER", "CLASS_SAPPER"]); expect(mockPersistence.saveUnlocks.calledOnce).to.be.true; const savedUnlocks = mockPersistence.saveUnlocks.firstCall.args[0]; expect(savedUnlocks).to.include("CLASS_TINKER"); expect(savedUnlocks).to.include("CLASS_SAPPER"); }); it("CoA 28: Should merge new unlocks with existing unlocks", async () => { // Set up existing unlocks mockPersistence.loadUnlocks.resolves(["CLASS_VANGUARD"]); await manager.unlockClasses(["CLASS_TINKER"]); expect(mockPersistence.saveUnlocks.calledOnce).to.be.true; const savedUnlocks = mockPersistence.saveUnlocks.firstCall.args[0]; expect(savedUnlocks).to.include("CLASS_VANGUARD"); expect(savedUnlocks).to.include("CLASS_TINKER"); }); it("CoA 29: Should distribute faction reputation rewards", () => { const rewardSpy = sinon.spy(); window.addEventListener("mission-rewards", rewardSpy); manager.activeMissionId = "MISSION_TEST"; manager.currentMissionDef = { id: "MISSION_TEST", rewards: { guaranteed: {}, faction_reputation: { IRON_LEGION: 50, COGWORK_CONCORD: -10, }, }, }; manager.distributeRewards(); const rewardData = rewardSpy.firstCall.args[0].detail; expect(rewardData.factionReputation.IRON_LEGION).to.equal(50); expect(rewardData.factionReputation.COGWORK_CONCORD).to.equal(-10); window.removeEventListener("mission-rewards", rewardSpy); }); }); describe("Mission Conclusion", () => { it("CoA 30: completeActiveMission should mark mission as completed", async () => { manager.activeMissionId = "MISSION_TEST"; manager.currentMissionDef = { id: "MISSION_TEST", rewards: { guaranteed: {} }, }; await manager.completeActiveMission(); expect(manager.completedMissions.has("MISSION_TEST")).to.be.true; }); it("CoA 31: completeActiveMission should play outro narrative if available", async () => { const outroPromise = Promise.resolve(); sinon.stub(manager, "playOutro").returns(outroPromise); manager.activeMissionId = "MISSION_TEST"; manager.currentMissionDef = { id: "MISSION_TEST", narrative: { outro_success: "NARRATIVE_TUTORIAL_SUCCESS" }, rewards: { guaranteed: {} }, }; await manager.completeActiveMission(); expect(manager.playOutro.calledWith("NARRATIVE_TUTORIAL_SUCCESS")).to.be .true; }); it("CoA 32: checkVictory should not trigger if not all primary objectives complete", () => { const victorySpy = sinon.spy(); window.addEventListener("mission-victory", victorySpy); manager.setupActiveMission(); manager.currentObjectives = [ { type: "ELIMINATE_ALL", complete: true }, { type: "SURVIVE", turn_count: 5, complete: false }, ]; manager.activeMissionId = "MISSION_TEST"; manager.checkVictory(); expect(victorySpy.called).to.be.false; window.removeEventListener("mission-victory", victorySpy); }); it("CoA 33: checkVictory should include objective data in victory event", async () => { const victorySpy = sinon.spy(); window.addEventListener("mission-victory", victorySpy); // Stub completeActiveMission to avoid async issues sinon.stub(manager, "completeActiveMission").resolves(); manager.setupActiveMission(); manager.currentObjectives = [ { type: "ELIMINATE_ALL", complete: true, id: "OBJ_1" }, ]; manager.secondaryObjectives = [ { type: "SURVIVE", complete: true, id: "OBJ_2" }, ]; manager.activeMissionId = "MISSION_TEST"; manager.currentMissionDef = { id: "MISSION_TEST", rewards: { guaranteed: {} }, }; manager.checkVictory(); expect(victorySpy.called).to.be.true; const detail = victorySpy.firstCall.args[0].detail; expect(detail.primaryObjectives).to.exist; expect(detail.secondaryObjectives).to.exist; window.removeEventListener("mission-victory", victorySpy); }); }); describe("UnitManager and TurnSystem Integration", () => { it("CoA 34: setUnitManager should store UnitManager reference", () => { const mockUnitManager = { activeUnits: new Map() }; manager.setUnitManager(mockUnitManager); expect(manager.unitManager).to.equal(mockUnitManager); }); it("CoA 35: setTurnSystem should store TurnSystem reference", () => { const mockTurnSystem = { round: 1 }; manager.setTurnSystem(mockTurnSystem); expect(manager.turnSystem).to.equal(mockTurnSystem); }); it("CoA 36: updateTurn should update current turn count", () => { manager.updateTurn(5); expect(manager.currentTurn).to.equal(5); manager.updateTurn(10); expect(manager.currentTurn).to.equal(10); }); it("CoA 37: setupActiveMission should initialize failure conditions", () => { const mission = { id: "MISSION_TEST", config: { title: "Test" }, objectives: { primary: [], failure_conditions: [ { type: "SQUAD_WIPE" }, { type: "VIP_DEATH", target_tag: "VIP_ESCORT" }, ], }, }; manager.registerMission(mission); manager.activeMissionId = "MISSION_TEST"; manager.setupActiveMission(); expect(manager.failureConditions).to.have.length(2); expect(manager.failureConditions[0].type).to.equal("SQUAD_WIPE"); expect(manager.failureConditions[1].type).to.equal("VIP_DEATH"); }); }); describe("Lazy Loading", () => { it("CoA 31: Should lazy-load missions on first access", async () => { // Create a fresh manager to test lazy loading const freshManager = new MissionManager(mockPersistence); // Initially, registry should be empty (missions not loaded) expect(freshManager.missionRegistry.size).to.equal(0); // Trigger lazy loading await freshManager._ensureMissionsLoaded(); // Now missions should be loaded expect(freshManager.missionRegistry.size).to.be.greaterThan(0); expect(freshManager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true; }); it("CoA 32: Should not reload missions if already loaded", async () => { // Load missions first time await manager._ensureMissionsLoaded(); const firstSize = manager.missionRegistry.size; // Load again - should not duplicate await manager._ensureMissionsLoaded(); const secondSize = manager.missionRegistry.size; expect(firstSize).to.equal(secondSize); }); it("CoA 33: Should handle lazy loading errors gracefully", async () => { // Create a manager with a failing persistence (if needed) const freshManager = new MissionManager(mockPersistence); // Should not throw even if missions fail to load try { await freshManager._ensureMissionsLoaded(); // If we get here, it handled gracefully expect(true).to.be.true; } catch (error) { // If error occurs, it should be handled expect(error).to.exist; } }); }); });