2025-12-22 20:57:04 +00:00
|
|
|
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;
|
2026-01-01 04:11:00 +00:00
|
|
|
let mockPersistence;
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2026-01-01 04:11:00 +00:00
|
|
|
// Create mock persistence
|
|
|
|
|
mockPersistence = {
|
|
|
|
|
loadUnlocks: sinon.stub().resolves([]),
|
|
|
|
|
saveUnlocks: sinon.stub().resolves(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
manager = new MissionManager(mockPersistence);
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-01 17:18:09 +00:00
|
|
|
it("CoA 1: Should initialize with tutorial mission registered", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(manager.missionRegistry.has("MISSION_ACT1_01")).to.be.true;
|
2025-12-22 20:57:04 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-01 17:18:09 +00:00
|
|
|
it("CoA 3: getActiveMission should return tutorial if no active mission", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
|
|
|
|
const mission = await manager.getActiveMission();
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
expect(mission).to.exist;
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(mission.id).to.equal("MISSION_ACT1_01");
|
2025-12-22 20:57:04 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-01 17:18:09 +00:00
|
|
|
it("CoA 4: getActiveMission should return active mission if set", async () => {
|
2025-12-22 20:57:04 +00:00
|
|
|
const testMission = {
|
|
|
|
|
id: "MISSION_TEST_01",
|
|
|
|
|
config: { title: "Test" },
|
|
|
|
|
objectives: { primary: [] },
|
|
|
|
|
};
|
|
|
|
|
manager.registerMission(testMission);
|
|
|
|
|
manager.activeMissionId = "MISSION_TEST_01";
|
|
|
|
|
|
2026-01-01 17:18:09 +00:00
|
|
|
const mission = await manager.getActiveMission();
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
expect(mission.id).to.equal("MISSION_TEST_01");
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-01 17:18:09 +00:00
|
|
|
it("CoA 5: setupActiveMission should initialize objectives", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
|
|
|
|
const mission = await manager.getActiveMission();
|
2025-12-22 20:57:04 +00:00
|
|
|
mission.objectives = {
|
|
|
|
|
primary: [
|
|
|
|
|
{ type: "ELIMINATE_ALL", target_count: 5 },
|
2026-01-02 23:25:26 +00:00
|
|
|
{
|
|
|
|
|
type: "ELIMINATE_UNIT",
|
|
|
|
|
target_def_id: "ENEMY_GOBLIN",
|
|
|
|
|
target_count: 3,
|
|
|
|
|
},
|
2025-12-22 20:57:04 +00:00
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-01 17:18:09 +00:00
|
|
|
await manager.setupActiveMission();
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 6: onGameEvent should complete ELIMINATE_ALL when all enemies are dead", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
2025-12-31 18:49:26 +00:00
|
|
|
const mockUnitManager = {
|
|
|
|
|
activeUnits: new Map([
|
|
|
|
|
["PLAYER_1", { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }],
|
|
|
|
|
]),
|
|
|
|
|
getUnitsByTeam: sinon.stub().returns([]), // No enemies left
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
manager.setUnitManager(mockUnitManager);
|
2026-01-02 00:08:54 +00:00
|
|
|
await manager.setupActiveMission();
|
2026-01-02 23:25:26 +00:00
|
|
|
manager.currentObjectives = [{ type: "ELIMINATE_ALL", complete: false }];
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" });
|
|
|
|
|
|
|
|
|
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 7: onGameEvent should update ELIMINATE_UNIT objectives for specific unit", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
|
|
|
|
await manager.setupActiveMission();
|
2025-12-22 20:57:04 +00:00
|
|
|
manager.currentObjectives = [
|
|
|
|
|
{
|
|
|
|
|
type: "ELIMINATE_UNIT",
|
|
|
|
|
target_def_id: "ENEMY_GOBLIN",
|
|
|
|
|
target_count: 2,
|
|
|
|
|
current: 0,
|
|
|
|
|
complete: false,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-01-02 23:25:26 +00:00
|
|
|
manager.onGameEvent("ENEMY_DEATH", {
|
|
|
|
|
unitId: "ENEMY_GOBLIN",
|
|
|
|
|
defId: "ENEMY_GOBLIN",
|
|
|
|
|
});
|
|
|
|
|
manager.onGameEvent("ENEMY_DEATH", {
|
|
|
|
|
unitId: "ENEMY_OTHER",
|
|
|
|
|
defId: "ENEMY_OTHER",
|
|
|
|
|
}); // Should not count
|
|
|
|
|
manager.onGameEvent("ENEMY_DEATH", {
|
|
|
|
|
unitId: "ENEMY_GOBLIN",
|
|
|
|
|
defId: "ENEMY_GOBLIN",
|
|
|
|
|
});
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
expect(manager.currentObjectives[0].current).to.equal(2);
|
|
|
|
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-31 18:49:26 +00:00
|
|
|
it("CoA 8: checkVictory should dispatch mission-victory event when all objectives complete", async () => {
|
2026-01-02 00:08:54 +00:00
|
|
|
await manager._ensureMissionsLoaded();
|
2025-12-22 20:57:04 +00:00
|
|
|
const victorySpy = sinon.spy();
|
|
|
|
|
window.addEventListener("mission-victory", victorySpy);
|
|
|
|
|
|
2025-12-31 18:49:26 +00:00
|
|
|
// Stub completeActiveMission to avoid async issues
|
|
|
|
|
sinon.stub(manager, "completeActiveMission").resolves();
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
await manager.setupActiveMission();
|
2025-12-22 20:57:04 +00:00
|
|
|
manager.currentObjectives = [
|
|
|
|
|
{ type: "ELIMINATE_ALL", target_count: 2, current: 2, complete: true },
|
|
|
|
|
];
|
2026-01-02 23:25:26 +00:00
|
|
|
manager.activeMissionId = "MISSION_ACT1_01";
|
2025-12-31 18:49:26 +00:00
|
|
|
manager.currentMissionDef = {
|
2026-01-02 23:25:26 +00:00
|
|
|
id: "MISSION_ACT1_01",
|
2025-12-31 18:49:26 +00:00
|
|
|
rewards: { guaranteed: {} },
|
|
|
|
|
};
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
manager.checkVictory();
|
|
|
|
|
|
|
|
|
|
expect(victorySpy.called).to.be.true;
|
2025-12-31 18:49:26 +00:00
|
|
|
expect(manager.completeActiveMission.called).to.be.true;
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
window.removeEventListener("mission-victory", victorySpy);
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-31 18:49:26 +00:00
|
|
|
it("CoA 9: completeActiveMission should add mission to completed set", async () => {
|
2026-01-02 23:25:26 +00:00
|
|
|
manager.activeMissionId = "MISSION_ACT1_01";
|
2025-12-31 18:49:26 +00:00
|
|
|
manager.currentMissionDef = {
|
2026-01-02 23:25:26 +00:00
|
|
|
id: "MISSION_ACT1_01",
|
2025-12-31 18:49:26 +00:00
|
|
|
rewards: { guaranteed: {} },
|
|
|
|
|
};
|
2025-12-22 20:57:04 +00:00
|
|
|
|
2025-12-31 23:06:07 +00:00
|
|
|
// Spy on window.dispatchEvent to verify campaign-data-changed event
|
|
|
|
|
const eventSpy = sinon.spy();
|
|
|
|
|
window.addEventListener("campaign-data-changed", eventSpy);
|
|
|
|
|
|
2025-12-31 18:49:26 +00:00
|
|
|
await manager.completeActiveMission();
|
2025-12-22 20:57:04 +00:00
|
|
|
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(manager.completedMissions.has("MISSION_ACT1_01")).to.be.true;
|
2025-12-31 23:06:07 +00:00
|
|
|
expect(eventSpy.called).to.be.true;
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(eventSpy.firstCall.args[0].detail.missionCompleted).to.equal(
|
|
|
|
|
"MISSION_ACT1_01"
|
|
|
|
|
);
|
2025-12-31 23:06:07 +00:00
|
|
|
|
|
|
|
|
window.removeEventListener("campaign-data-changed", eventSpy);
|
2025-12-22 20:57:04 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 10: load should restore completed missions", () => {
|
|
|
|
|
const saveData = {
|
2026-01-02 23:25:26 +00:00
|
|
|
completedMissions: ["MISSION_ACT1_01", "MISSION_TEST_01"],
|
2025-12-22 20:57:04 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
manager.load(saveData);
|
|
|
|
|
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(manager.completedMissions.has("MISSION_ACT1_01")).to.be.true;
|
2025-12-22 20:57:04 +00:00
|
|
|
expect(manager.completedMissions.has("MISSION_TEST_01")).to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 11: save should serialize completed missions", () => {
|
2026-01-02 23:25:26 +00:00
|
|
|
manager.completedMissions.add("MISSION_ACT1_01");
|
2025-12-22 20:57:04 +00:00
|
|
|
manager.completedMissions.add("MISSION_TEST_01");
|
|
|
|
|
|
|
|
|
|
const saved = manager.save();
|
|
|
|
|
|
|
|
|
|
expect(saved.completedMissions).to.be.an("array");
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(saved.completedMissions).to.include("MISSION_ACT1_01");
|
2025-12-22 20:57:04 +00:00
|
|
|
expect(saved.completedMissions).to.include("MISSION_TEST_01");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 12: _mapNarrativeIdToFileName should convert narrative IDs to filenames", () => {
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(
|
|
|
|
|
manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_INTRO")
|
|
|
|
|
).to.equal("tutorial_intro");
|
|
|
|
|
expect(
|
|
|
|
|
manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_SUCCESS")
|
|
|
|
|
).to.equal("tutorial_success");
|
2025-12-22 20:57:04 +00:00
|
|
|
// The implementation converts NARRATIVE_UNKNOWN to narrative_unknown (lowercase with NARRATIVE_ prefix removed)
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(manager._mapNarrativeIdToFileName("NARRATIVE_UNKNOWN")).to.equal(
|
|
|
|
|
"narrative_unknown"
|
|
|
|
|
);
|
2025-12-22 20:57:04 +00:00
|
|
|
});
|
2025-12-28 00:54:03 +00:00
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 13: getActiveMission should expose enemy_spawns from mission definition", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
2025-12-28 00:54:03 +00:00
|
|
|
const missionWithEnemies = {
|
|
|
|
|
id: "MISSION_TEST",
|
|
|
|
|
config: { title: "Test Mission" },
|
2026-01-02 23:25:26 +00:00
|
|
|
enemy_spawns: [{ enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", count: 2 }],
|
2025-12-28 00:54:03 +00:00
|
|
|
objectives: { primary: [] },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
manager.registerMission(missionWithEnemies);
|
|
|
|
|
manager.activeMissionId = "MISSION_TEST";
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
const mission = await manager.getActiveMission();
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
expect(mission.enemy_spawns).to.exist;
|
|
|
|
|
expect(mission.enemy_spawns).to.have.length(1);
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(mission.enemy_spawns[0].enemy_def_id).to.equal(
|
|
|
|
|
"ENEMY_SHARDBORN_SENTINEL"
|
|
|
|
|
);
|
2025-12-28 00:54:03 +00:00
|
|
|
expect(mission.enemy_spawns[0].count).to.equal(2);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 14: getActiveMission should expose mission_objects from mission definition", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
|
|
|
|
const missionWithObjects = {
|
|
|
|
|
id: "MISSION_TEST",
|
|
|
|
|
config: { title: "Test Mission" },
|
|
|
|
|
mission_objects: [
|
|
|
|
|
{
|
|
|
|
|
object_id: "OBJ_SIGNAL_RELAY",
|
|
|
|
|
placement_strategy: "center_of_enemy_room",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
object_id: "OBJ_DATA_TERMINAL",
|
|
|
|
|
position: { x: 10, y: 1, z: 10 },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
objectives: { primary: [] },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
manager.registerMission(missionWithObjects);
|
|
|
|
|
manager.activeMissionId = "MISSION_TEST";
|
|
|
|
|
|
|
|
|
|
const mission = await manager.getActiveMission();
|
|
|
|
|
|
|
|
|
|
expect(mission.mission_objects).to.exist;
|
|
|
|
|
expect(mission.mission_objects).to.have.length(2);
|
|
|
|
|
expect(mission.mission_objects[0].object_id).to.equal("OBJ_SIGNAL_RELAY");
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(mission.mission_objects[0].placement_strategy).to.equal(
|
|
|
|
|
"center_of_enemy_room"
|
|
|
|
|
);
|
2026-01-02 00:08:54 +00:00
|
|
|
expect(mission.mission_objects[1].object_id).to.equal("OBJ_DATA_TERMINAL");
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(mission.mission_objects[1].position).to.deep.equal({
|
|
|
|
|
x: 10,
|
|
|
|
|
y: 1,
|
|
|
|
|
z: 10,
|
|
|
|
|
});
|
2026-01-02 00:08:54 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 15: getActiveMission should expose deployment constraints with tutorial hints", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
2025-12-28 00:54:03 +00:00
|
|
|
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";
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
const mission = await manager.getActiveMission();
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
);
|
|
|
|
|
});
|
2025-12-31 18:49:26 +00:00
|
|
|
|
|
|
|
|
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;
|
2026-01-02 23:25:26 +00:00
|
|
|
manager.failureConditions = [
|
|
|
|
|
{ type: "TURN_LIMIT_EXCEEDED", turn_limit: 10 },
|
|
|
|
|
];
|
2025-12-31 18:49:26 +00:00
|
|
|
|
|
|
|
|
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", () => {
|
2026-01-02 00:08:54 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 18: Should complete SURVIVE objective when turn count is reached", async () => {
|
|
|
|
|
await manager.setupActiveMission();
|
2025-12-31 18:49:26 +00:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 19: Should complete REACH_ZONE objective when unit reaches target zone", async () => {
|
|
|
|
|
await manager.setupActiveMission();
|
2025-12-31 18:49:26 +00:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 05:02:37 +00:00
|
|
|
it("CoA 19b: Should track progress for REACH_ZONE objective with multiple zones", async () => {
|
|
|
|
|
await manager.setupActiveMission();
|
|
|
|
|
manager.currentObjectives = [
|
|
|
|
|
{
|
|
|
|
|
type: "REACH_ZONE",
|
|
|
|
|
target_count: 3,
|
|
|
|
|
zone_coords: [
|
|
|
|
|
{ x: 5, y: 0, z: 5 },
|
|
|
|
|
{ x: 10, y: 0, z: 10 },
|
|
|
|
|
{ x: 15, y: 0, z: 15 },
|
|
|
|
|
],
|
|
|
|
|
current: 0,
|
|
|
|
|
complete: false,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Reach first zone
|
|
|
|
|
manager.onGameEvent("UNIT_MOVE", {
|
|
|
|
|
position: { x: 5, y: 0, z: 5 },
|
|
|
|
|
});
|
|
|
|
|
expect(manager.currentObjectives[0].current).to.equal(1);
|
|
|
|
|
expect(manager.currentObjectives[0].complete).to.be.false;
|
|
|
|
|
expect(manager.currentObjectives[0].zone_coords.length).to.equal(2);
|
|
|
|
|
|
|
|
|
|
// Reach second zone
|
|
|
|
|
manager.onGameEvent("UNIT_MOVE", {
|
|
|
|
|
position: { x: 10, y: 0, z: 10 },
|
|
|
|
|
});
|
|
|
|
|
expect(manager.currentObjectives[0].current).to.equal(2);
|
|
|
|
|
expect(manager.currentObjectives[0].complete).to.be.false;
|
|
|
|
|
expect(manager.currentObjectives[0].zone_coords.length).to.equal(1);
|
|
|
|
|
|
|
|
|
|
// Reach third zone - should complete
|
|
|
|
|
manager.onGameEvent("UNIT_MOVE", {
|
|
|
|
|
position: { x: 15, y: 0, z: 15 },
|
|
|
|
|
});
|
|
|
|
|
expect(manager.currentObjectives[0].current).to.equal(3);
|
|
|
|
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
|
|
|
|
expect(manager.currentObjectives[0].zone_coords.length).to.equal(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 19c: Should handle Y-level variance when matching zones (for teleport)", async () => {
|
|
|
|
|
await manager.setupActiveMission();
|
|
|
|
|
manager.currentObjectives = [
|
|
|
|
|
{
|
|
|
|
|
type: "REACH_ZONE",
|
|
|
|
|
zone_coords: [{ x: 5, y: 1, z: 5 }],
|
|
|
|
|
complete: false,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Teleport might place unit at slightly different Y level
|
|
|
|
|
manager.onGameEvent("UNIT_MOVE", {
|
|
|
|
|
position: { x: 5, y: 0, z: 5 }, // Y differs by 1
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 19d: Should complete REACH_ZONE objective when teleporting to target zone", async () => {
|
|
|
|
|
await manager.setupActiveMission();
|
|
|
|
|
manager.currentObjectives = [
|
|
|
|
|
{
|
|
|
|
|
type: "REACH_ZONE",
|
|
|
|
|
target_count: 1,
|
|
|
|
|
zone_coords: [{ x: 10, y: 1, z: 10 }],
|
|
|
|
|
current: 0,
|
|
|
|
|
complete: false,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Simulate teleport to zone (UNIT_MOVE event with position matching zone)
|
|
|
|
|
manager.onGameEvent("UNIT_MOVE", {
|
|
|
|
|
unitId: "UNIT_TEST",
|
|
|
|
|
position: { x: 10, y: 1, z: 10 },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
|
|
|
|
expect(manager.currentObjectives[0].current).to.equal(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 20: Should complete INTERACT objective when unit interacts with target object", async () => {
|
|
|
|
|
await manager.setupActiveMission();
|
2025-12-31 18:49:26 +00:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 21: Should complete ELIMINATE_ALL when all enemies are eliminated", async () => {
|
2025-12-31 18:49:26 +00:00
|
|
|
// 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);
|
2026-01-02 00:08:54 +00:00
|
|
|
await manager.setupActiveMission();
|
2025-12-31 18:49:26 +00:00
|
|
|
manager.currentObjectives = [
|
|
|
|
|
{
|
|
|
|
|
type: "ELIMINATE_ALL",
|
|
|
|
|
complete: false,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" });
|
|
|
|
|
|
|
|
|
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 22: Should complete SQUAD_SURVIVAL objective when minimum units survive", async () => {
|
2025-12-31 18:49:26 +00:00
|
|
|
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);
|
2026-01-02 00:08:54 +00:00
|
|
|
await manager.setupActiveMission();
|
2025-12-31 18:49:26 +00:00
|
|
|
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", () => {
|
2026-01-02 00:08:54 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 23: Should track secondary objectives separately from primary", async () => {
|
2025-12-31 18:49:26 +00:00
|
|
|
const mission = {
|
|
|
|
|
id: "MISSION_TEST",
|
|
|
|
|
config: { title: "Test" },
|
|
|
|
|
objectives: {
|
|
|
|
|
primary: [{ type: "ELIMINATE_ALL", id: "PRIMARY_1" }],
|
2026-01-02 23:25:26 +00:00
|
|
|
secondary: [{ type: "SURVIVE", turn_count: 10, id: "SECONDARY_1" }],
|
2025-12-31 18:49:26 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
manager.registerMission(mission);
|
|
|
|
|
manager.activeMissionId = "MISSION_TEST";
|
2026-01-02 00:08:54 +00:00
|
|
|
await manager.setupActiveMission();
|
2025-12-31 18:49:26 +00:00
|
|
|
|
|
|
|
|
expect(manager.currentObjectives).to.have.length(1);
|
|
|
|
|
expect(manager.secondaryObjectives).to.have.length(1);
|
|
|
|
|
expect(manager.secondaryObjectives[0].id).to.equal("SECONDARY_1");
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 24: Should update secondary objectives on game events", async () => {
|
|
|
|
|
await manager.setupActiveMission();
|
2025-12-31 18:49:26 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-01 04:11:00 +00:00
|
|
|
it("CoA 27: Should unlock classes and store in IndexedDB", async () => {
|
|
|
|
|
await manager.unlockClasses(["CLASS_TINKER", "CLASS_SAPPER"]);
|
2025-12-31 18:49:26 +00:00
|
|
|
|
2026-01-01 04:11:00 +00:00
|
|
|
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");
|
2025-12-31 18:49:26 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-01 04:11:00 +00:00
|
|
|
it("CoA 28: Should merge new unlocks with existing unlocks", async () => {
|
|
|
|
|
// Set up existing unlocks
|
|
|
|
|
mockPersistence.loadUnlocks.resolves(["CLASS_VANGUARD"]);
|
2025-12-31 18:49:26 +00:00
|
|
|
|
2026-01-01 04:11:00 +00:00
|
|
|
await manager.unlockClasses(["CLASS_TINKER"]);
|
2025-12-31 18:49:26 +00:00
|
|
|
|
2026-01-01 04:11:00 +00:00
|
|
|
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");
|
2025-12-31 18:49:26 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
it("CoA 37: setupActiveMission should initialize failure conditions", async () => {
|
|
|
|
|
await manager._ensureMissionsLoaded();
|
2025-12-31 18:49:26 +00:00
|
|
|
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";
|
2026-01-02 00:08:54 +00:00
|
|
|
await manager.setupActiveMission();
|
2025-12-31 18:49:26 +00:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-01-01 17:18:09 +00:00
|
|
|
|
|
|
|
|
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);
|
2026-01-02 23:25:26 +00:00
|
|
|
|
2026-01-01 17:18:09 +00:00
|
|
|
// 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);
|
2026-01-02 23:25:26 +00:00
|
|
|
expect(freshManager.missionRegistry.has("MISSION_ACT1_01")).to.be.true;
|
2026-01-01 17:18:09 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
2026-01-02 23:25:26 +00:00
|
|
|
|
2026-01-01 17:18:09 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-12-22 20:57:04 +00:00
|
|
|
|
2026-01-02 23:25:26 +00:00
|
|
|
describe("Dynamic Narrative Loading", () => {
|
|
|
|
|
it("CoA 35: playIntro should use dynamic narrative data if present", async () => {
|
|
|
|
|
const dynamicData = {
|
|
|
|
|
NARRATIVE_DYNAMIC_INTRO: {
|
|
|
|
|
id: "NARRATIVE_DYNAMIC_INTRO",
|
|
|
|
|
nodes: [{ id: "1", text: "Dynamic Text" }],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
manager.activeMissionId = "MISSION_DYNAMIC_TEST";
|
|
|
|
|
manager.currentMissionDef = {
|
|
|
|
|
id: "MISSION_DYNAMIC_TEST",
|
|
|
|
|
narrative: {
|
|
|
|
|
intro_sequence: "NARRATIVE_DYNAMIC_INTRO",
|
|
|
|
|
_dynamic_data: dynamicData,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Mock fetch to fail if called (should not be called)
|
|
|
|
|
const fetchStub = sinon
|
|
|
|
|
.stub(window, "fetch")
|
|
|
|
|
.rejects(new Error("Should not fetch"));
|
|
|
|
|
|
|
|
|
|
await manager.playIntro();
|
|
|
|
|
|
|
|
|
|
expect(mockNarrativeManager.startSequence.calledOnce).to.be.true;
|
|
|
|
|
expect(
|
|
|
|
|
mockNarrativeManager.startSequence.firstCall.args[0]
|
|
|
|
|
).to.deep.equal(dynamicData["NARRATIVE_DYNAMIC_INTRO"]);
|
|
|
|
|
expect(fetchStub.called).to.be.false;
|
|
|
|
|
|
|
|
|
|
fetchStub.restore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 36: playIntro should fallback to fetch if dynamic data matches but ID not found", async () => {
|
|
|
|
|
// This case checks if _dynamic_data exists but doesn't have the specific ID
|
|
|
|
|
manager.activeMissionId = "MISSION_FALLBACK_TEST";
|
|
|
|
|
manager.currentMissionDef = {
|
|
|
|
|
id: "MISSION_FALLBACK_TEST",
|
|
|
|
|
narrative: {
|
|
|
|
|
intro_sequence: "NARRATIVE_FILE_INTRO",
|
|
|
|
|
_dynamic_data: { OTHER_ID: {} },
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Mock fetch to succeed
|
|
|
|
|
const mockResponse = new Response(
|
|
|
|
|
JSON.stringify({ id: "NARRATIVE_FILE_INTRO" }),
|
|
|
|
|
{ status: 200 }
|
|
|
|
|
);
|
|
|
|
|
const fetchStub = sinon.stub(window, "fetch").resolves(mockResponse);
|
|
|
|
|
|
|
|
|
|
// Stub mapNarrativeIdToFileName to return simple name
|
|
|
|
|
manager._mapNarrativeIdToFileName = sinon
|
|
|
|
|
.stub()
|
|
|
|
|
.returns("narrative_file_intro");
|
|
|
|
|
|
|
|
|
|
await manager.playIntro();
|
|
|
|
|
|
|
|
|
|
expect(fetchStub.calledOnce).to.be.true;
|
|
|
|
|
expect(mockNarrativeManager.startSequence.calledOnce).to.be.true;
|
|
|
|
|
|
|
|
|
|
fetchStub.restore();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|