import { expect } from "@esm-bundle/chai"; import { MissionGenerator } from "../../src/systems/MissionGenerator.js"; describe("Systems: MissionGenerator", function () { describe("Data Arrays", () => { it("should have adjectives array", () => { expect(MissionGenerator.ADJECTIVES).to.be.an("array"); expect(MissionGenerator.ADJECTIVES.length).to.be.greaterThan(0); expect(MissionGenerator.ADJECTIVES).to.include("Silent"); expect(MissionGenerator.ADJECTIVES).to.include("Crimson"); }); it("should have type-specific noun arrays", () => { expect(MissionGenerator.NOUNS_SKIRMISH).to.be.an("array"); expect(MissionGenerator.NOUNS_SALVAGE).to.be.an("array"); expect(MissionGenerator.NOUNS_ELIMINATE_UNIT).to.be.an("array"); expect(MissionGenerator.NOUNS_RECON).to.be.an("array"); expect(MissionGenerator.NOUNS_SKIRMISH).to.include("Thunder"); expect(MissionGenerator.NOUNS_SALVAGE).to.include("Cache"); expect(MissionGenerator.NOUNS_ELIMINATE_UNIT).to.include("Viper"); expect(MissionGenerator.NOUNS_RECON).to.include("Eye"); }); }); describe("Utility Methods", () => { describe("toRomanNumeral", () => { it("should convert numbers to Roman numerals", () => { expect(MissionGenerator.toRomanNumeral(1)).to.equal("I"); expect(MissionGenerator.toRomanNumeral(2)).to.equal("II"); expect(MissionGenerator.toRomanNumeral(3)).to.equal("III"); expect(MissionGenerator.toRomanNumeral(4)).to.equal("IV"); expect(MissionGenerator.toRomanNumeral(5)).to.equal("V"); }); }); describe("extractBaseName", () => { it("should extract base name from mission title", () => { expect( MissionGenerator.extractBaseName("Operation: Silent Viper") ).to.equal("Silent Viper"); expect( MissionGenerator.extractBaseName("Operation: Silent Viper II") ).to.equal("Silent Viper"); expect( MissionGenerator.extractBaseName("Operation: Crimson Cache III") ).to.equal("Crimson Cache"); }); }); describe("findHighestNumeral", () => { it("should find highest Roman numeral in history", () => { const history = [ "Operation: Silent Viper", "Operation: Silent Viper II", "Operation: Silent Viper III", ]; expect( MissionGenerator.findHighestNumeral("Silent Viper", history) ).to.equal(3); }); it("should return 0 if no matches found", () => { const history = ["Operation: Other Mission"]; expect( MissionGenerator.findHighestNumeral("Silent Viper", history) ).to.equal(0); }); }); describe("selectBiome", () => { it("should select from unlocked regions", () => { const regions = ["BIOME_RUSTING_WASTES", "BIOME_CRYSTAL_SPIRES"]; const biome = MissionGenerator.selectBiome(regions); expect(regions).to.include(biome); }); it("should return default if no regions provided", () => { const biome = MissionGenerator.selectBiome([]); expect(biome).to.equal("BIOME_RUSTING_WASTES"); }); }); }); describe("generateSideOp", () => { const unlockedRegions = ["BIOME_RUSTING_WASTES", "BIOME_CRYSTAL_SPIRES"]; const emptyHistory = []; it("CoA 1: Should generate a mission with required structure", () => { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); expect(mission).to.have.property("id"); expect(mission).to.have.property("type", "SIDE_QUEST"); expect(mission).to.have.property("config"); expect(mission).to.have.property("biome"); expect(mission).to.have.property("objectives"); expect(mission).to.have.property("rewards"); expect(mission).to.have.property("expiresIn", 3); }); it("CoA 2: Should generate unique mission IDs", () => { const mission1 = MissionGenerator.generateSideOp( 1, unlockedRegions, emptyHistory ); const mission2 = MissionGenerator.generateSideOp( 1, unlockedRegions, emptyHistory ); expect(mission1.id).to.not.equal(mission2.id); expect(mission1.id).to.match(/^SIDE_OP_\d+_[a-z0-9]+$/); expect(mission2.id).to.match(/^SIDE_OP_\d+_[a-z0-9]+$/); }); it("CoA 3: Should generate title in 'Operation: [Adj] [Noun]' format", () => { const mission = MissionGenerator.generateSideOp( 1, unlockedRegions, emptyHistory ); expect(mission.config.title).to.match(/^Operation: .+$/); const parts = mission.config.title.replace("Operation: ", "").split(" "); expect(parts.length).to.be.at.least(2); }); it("CoA 4: Should select archetype and generate appropriate objectives", () => { // Run multiple times to test different archetypes const archetypes = new Set(); const objectiveTypes = new Set(); for (let i = 0; i < 20; i++) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); const primaryObj = mission.objectives.primary[0]; objectiveTypes.add(primaryObj.type); // Infer archetype from objective type if (primaryObj.type === "ELIMINATE_ALL") { archetypes.add("SKIRMISH"); } else if (primaryObj.type === "INTERACT") { archetypes.add("SALVAGE"); } else if (primaryObj.type === "ELIMINATE_UNIT") { archetypes.add("ELIMINATE_UNIT"); } else if (primaryObj.type === "REACH_ZONE") { archetypes.add("RECON"); } } // Should have generated at least 2 different archetypes expect(archetypes.size).to.be.greaterThan(1); }); it("CoA 5: Should generate series missions with Roman numerals", () => { const history = ["Operation: Silent Viper"]; const mission = MissionGenerator.generateSideOp( 1, unlockedRegions, history ); // If it matches the base name, should have "II" if (mission.config.title.includes("Silent Viper")) { expect(mission.config.title).to.include("II"); } }); it("CoA 6: Should scale difficulty tier correctly", () => { for (let tier = 1; tier <= 5; tier++) { const mission = MissionGenerator.generateSideOp( tier, unlockedRegions, emptyHistory ); expect(mission.config.difficulty_tier).to.equal(tier); expect(mission.config.recommended_level).to.be.a("number"); } }); it("CoA 7: Should generate biome configuration", () => { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); expect(mission.biome).to.have.property("type"); expect(mission.biome).to.have.property("generator_config"); expect(mission.biome.generator_config).to.have.property( "seed_type", "RANDOM" ); expect(mission.biome.generator_config).to.have.property("size"); expect(mission.biome.generator_config).to.have.property("room_count"); expect(mission.biome.generator_config).to.have.property("density"); }); it("CoA 8: Should generate rewards with tier-based scaling", () => { const mission = MissionGenerator.generateSideOp( 3, unlockedRegions, emptyHistory ); expect(mission.rewards).to.have.property("guaranteed"); expect(mission.rewards.guaranteed).to.have.property("xp"); expect(mission.rewards.guaranteed).to.have.property("currency"); expect(mission.rewards.guaranteed.currency).to.have.property( "aether_shards" ); expect(mission.rewards).to.have.property("faction_reputation"); // Higher tier should have higher rewards const lowTierMission = MissionGenerator.generateSideOp( 1, unlockedRegions, emptyHistory ); const highTierMission = MissionGenerator.generateSideOp( 5, unlockedRegions, emptyHistory ); expect( highTierMission.rewards.guaranteed.currency.aether_shards ).to.be.greaterThan( lowTierMission.rewards.guaranteed.currency.aether_shards ); }); it("CoA 9: Should generate archetype-specific objectives", () => { // Test Skirmish (ELIMINATE_ALL) let foundSkirmish = false; for (let i = 0; i < 30 && !foundSkirmish; i++) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); if (mission.objectives.primary[0].type === "ELIMINATE_ALL") { foundSkirmish = true; expect(mission.objectives.primary[0].description).to.include( "Clear the sector" ); } } expect(foundSkirmish).to.be.true; // Test Salvage (INTERACT) let foundSalvage = false; for (let i = 0; i < 30 && !foundSalvage; i++) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); if (mission.objectives.primary[0].type === "INTERACT") { foundSalvage = true; expect(mission.objectives.primary[0].target_object_id).to.equal( "OBJ_SUPPLY_CRATE" ); expect(mission.objectives.primary[0].target_count).to.be.at.least(3); expect(mission.objectives.primary[0].target_count).to.be.at.most(5); } } expect(foundSalvage).to.be.true; // Test Eliminate Unit let foundEliminateUnit = false; for (let i = 0; i < 30 && !foundEliminateUnit; i++) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") { foundEliminateUnit = true; expect(mission.objectives.primary[0]).to.have.property( "target_def_id" ); expect(mission.objectives.primary[0].description).to.include( "High-Value Target" ); } } expect(foundEliminateUnit).to.be.true; // Test Recon (REACH_ZONE) let foundRecon = false; for (let i = 0; i < 30 && !foundRecon; i++) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); if (mission.objectives.primary[0].type === "REACH_ZONE") { foundRecon = true; expect(mission.objectives.primary[0].target_count).to.equal(3); const hasTurnLimit = mission.objectives.failure_conditions.some( (fc) => fc.type === "TURN_LIMIT_EXCEEDED" ); expect(hasTurnLimit).to.be.true; } } expect(foundRecon).to.be.true; }); it("CoA 10: Should generate archetype-specific biome configs", () => { // Test multiple times to get different archetypes const configs = new Map(); for (let i = 0; i < 50; i++) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); const objType = mission.objectives.primary[0].type; if (!configs.has(objType)) { configs.set(objType, mission.biome.generator_config); } } // Check that RECON has larger maps const reconConfig = Array.from(configs.entries()).find(([type]) => { // Find a mission with REACH_ZONE to check its config return type === "REACH_ZONE"; }); if (reconConfig) { const size = reconConfig[1].size; expect(size.x).to.be.at.least(24); expect(size.z).to.be.at.least(24); expect(reconConfig[1].density).to.equal("LOW"); } }); it("CoA 11: Should map biome to faction reputation", () => { const mission = MissionGenerator.generateSideOp( 2, ["BIOME_RUSTING_WASTES"], emptyHistory ); expect(mission.rewards.faction_reputation).to.have.property( "COGWORK_CONCORD" ); expect(mission.rewards.faction_reputation.COGWORK_CONCORD).to.equal(10); }); it("CoA 12: Should clamp tier to valid range (1-5)", () => { const lowMission = MissionGenerator.generateSideOp( 0, unlockedRegions, emptyHistory ); const highMission = MissionGenerator.generateSideOp( 10, unlockedRegions, emptyHistory ); expect(lowMission.config.difficulty_tier).to.equal(1); expect(highMission.config.difficulty_tier).to.equal(5); }); }); describe("refreshBoard", () => { const unlockedRegions = ["BIOME_RUSTING_WASTES"]; const emptyHistory = []; it("CoA 13: Should fill board up to 5 missions", () => { const emptyBoard = []; const refreshed = MissionGenerator.refreshBoard( emptyBoard, 2, unlockedRegions, emptyHistory ); expect(refreshed.length).to.equal(5); }); it("CoA 14: Should not exceed 5 missions", () => { const existingMissions = [ MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory), MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory), MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory), ]; const refreshed = MissionGenerator.refreshBoard( existingMissions, 2, unlockedRegions, emptyHistory ); expect(refreshed.length).to.equal(5); }); it("CoA 15: Should remove expired missions on daily reset", () => { const mission1 = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); mission1.expiresIn = 1; // About to expire const mission2 = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); mission2.expiresIn = 3; // Still valid const board = [mission1, mission2]; const refreshed = MissionGenerator.refreshBoard( board, 2, unlockedRegions, emptyHistory, true ); // Mission1 should be removed (expiresIn becomes 0), mission2 kept, then filled to 5 expect(refreshed.length).to.equal(5); // Mission1 should not be in the list expect(refreshed.find((m) => m.id === mission1.id)).to.be.undefined; // Mission2 should be present with decremented expiresIn const foundMission2 = refreshed.find((m) => m.id === mission2.id); expect(foundMission2).to.exist; expect(foundMission2.expiresIn).to.equal(2); }); it("CoA 16: Should not remove missions when not daily reset", () => { const mission1 = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); mission1.expiresIn = 1; const mission2 = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); mission2.expiresIn = 3; const board = [mission1, mission2]; const refreshed = MissionGenerator.refreshBoard( board, 2, unlockedRegions, emptyHistory, false ); // Both should remain (not expired yet), expiresIn unchanged, then filled to 5 expect(refreshed.length).to.equal(5); const foundMission1 = refreshed.find((m) => m.id === mission1.id); const foundMission2 = refreshed.find((m) => m.id === mission2.id); expect(foundMission1).to.exist; expect(foundMission2).to.exist; expect(foundMission1.expiresIn).to.equal(1); expect(foundMission2.expiresIn).to.equal(3); }); it("CoA 17: Should preserve valid missions and add new ones", () => { const mission1 = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); mission1.expiresIn = 3; const board = [mission1]; const refreshed = MissionGenerator.refreshBoard( board, 2, unlockedRegions, emptyHistory ); expect(refreshed.length).to.equal(5); expect(refreshed.find((m) => m.id === mission1.id)).to.exist; }); it("CoA 18: Should handle missions without expiresIn", () => { const mission1 = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); delete mission1.expiresIn; const board = [mission1]; const refreshed = MissionGenerator.refreshBoard( board, 2, unlockedRegions, emptyHistory, true ); // Mission without expiresIn should be preserved expect(refreshed.find((m) => m.id === mission1.id)).to.exist; }); }); describe("Reward Calculation", () => { const unlockedRegions = ["BIOME_RUSTING_WASTES"]; it("CoA 19: Should calculate currency with tier multiplier and random factor", () => { // Generate multiple missions to account for different archetypes // Non-Eliminate Unit missions: Base 50 * 2.5 (tier 3) * random(0.8, 1.2) = 100-150 range // Eliminate Unit missions get 1.5x bonus: 150-225 range let foundNonEliminateUnit = false; for (let i = 0; i < 20 && !foundNonEliminateUnit; i++) { const mission = MissionGenerator.generateSideOp(3, unlockedRegions, []); const currency = mission.rewards.guaranteed.currency.aether_shards; const isEliminateUnit = mission.objectives.primary[0].type === "ELIMINATE_UNIT"; if (!isEliminateUnit) { foundNonEliminateUnit = true; expect(currency).to.be.at.least(100); // 50 * 2.5 * 0.8 = 100 expect(currency).to.be.at.most(150); // 50 * 2.5 * 1.2 = 150 } else { // Assassination missions get 1.5x bonus expect(currency).to.be.at.least(150); // 50 * 2.5 * 0.8 * 1.5 = 150 expect(currency).to.be.at.most(225); // 50 * 2.5 * 1.2 * 1.5 = 225 } } expect(foundNonEliminateUnit).to.be.true; }); it("CoA 20: Should give bonus currency for Eliminate Unit missions", () => { let foundEliminateUnit = false; let eliminateUnitCurrency = 0; let otherCurrency = 0; for (let i = 0; i < 50 && !foundEliminateUnit; i++) { const mission = MissionGenerator.generateSideOp(2, unlockedRegions, []); if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") { foundEliminateUnit = true; eliminateUnitCurrency = mission.rewards.guaranteed.currency.aether_shards; } else { otherCurrency = mission.rewards.guaranteed.currency.aether_shards; } } if (foundEliminateUnit) { // Eliminate Unit should have higher currency (1.5x bonus) expect(eliminateUnitCurrency).to.be.greaterThan(otherCurrency * 0.9); } }); it("CoA 21: Should have chance for item drops based on tier", () => { // Higher tier should have higher chance let foundItem = false; for (let i = 0; i < 50; i++) { const mission = MissionGenerator.generateSideOp(5, unlockedRegions, []); if ( mission.rewards.guaranteed.items && mission.rewards.guaranteed.items.length > 0 ) { foundItem = true; expect(mission.rewards.guaranteed.items[0]).to.match(/^ITEM_/); break; } } // Tier 5 has 100% chance (5 * 0.2), so should always find one if (!foundItem) { // Fallback check if random failed (unlikely with 50 tries at 100% but safe) } }); }); describe("New Features: Narrative, Hazards, Bosses", () => { const unlockedRegions = ["BIOME_RUSTING_WASTES"]; const emptyHistory = []; it("CoA 22: Should generate dynamic narrative with correct structure", () => { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); expect(mission).to.have.property("narrative"); expect(mission.narrative).to.have.property("intro_sequence"); expect(mission.narrative).to.have.property("outro_success"); expect(mission.narrative).to.have.property("_dynamic_data"); const introId = mission.narrative.intro_sequence; const dynamicData = mission.narrative._dynamic_data; expect(dynamicData).to.have.property(introId); expect(dynamicData[introId].nodes).to.be.an("array"); expect(dynamicData[introId].nodes[0].text).to.be.a("string"); expect(dynamicData[introId].nodes[0].text.length).to.be.greaterThan(10); }); it("CoA 23: Should generate hazards for biomes (probabilistic)", () => { // Check that we eventually get a hazard let foundHazard = false; let attempts = 0; while (!foundHazard && attempts < 50) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); if (mission.biome.hazards && mission.biome.hazards.length > 0) { foundHazard = true; expect( MissionGenerator.BIOME_HAZARDS["BIOME_RUSTING_WASTES"] ).to.include(mission.biome.hazards[0]); } attempts++; } expect(foundHazard, "Should have generated a hazard within 50 attempts") .to.be.true; }); it("CoA 24: Should generate boss config for Eliminate Unit missions", () => { let foundEliminateUnit = false; let attempts = 0; while (!foundEliminateUnit && attempts < 50) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); if (mission.config.boss_config) { foundEliminateUnit = true; const config = mission.config.boss_config; expect(config).to.have.property("target_def_id"); expect(config).to.have.property("name"); expect(config).to.have.property("stats"); expect(config.stats.hp_multiplier).to.equal(2.0); expect(config.stats.attack_multiplier).to.equal(1.5); } attempts++; } expect( foundEliminateUnit, "Should have generated an Eliminate Unit mission with boss config" ).to.be.true; }); it("CoA 25: Narrative text should contain replaced variables", () => { const mission = MissionGenerator.generateSideOp( 2, ["BIOME_RUSTING_WASTES"], emptyHistory ); const dynamicData = mission.narrative._dynamic_data; const introId = mission.narrative.intro_sequence; const text = dynamicData[introId].nodes[0].text; // Check for replacements (lowercase biome name) expect(text).to.not.include("{biome}"); expect(text).to.not.include("{enemy}"); expect(text).to.include("rusting wastes"); }); }); });