aether-shards/test/systems/MissionGenerator.test.js
Matthew Mone 2c86d674f4 Add mission debrief and procedural mission generation features
- Introduce the MissionDebrief component to display after-action reports, including XP, rewards, and squad status.
- Implement the MissionGenerator class to create procedural side missions, enhancing replayability and resource management.
- Update mission schema to include mission objects for INTERACT objectives, improving mission complexity.
- Enhance GameLoop and MissionManager to support new mission features and interactions.
- Add tests for MissionDebrief and MissionGenerator to ensure functionality and integration within the game architecture.
2026-01-01 16:08:54 -08:00

403 lines
17 KiB
JavaScript

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_ASSASSINATION).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_ASSASSINATION).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("ASSASSINATION");
} 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 Assassination (ELIMINATE_UNIT)
let foundAssassination = false;
for (let i = 0; i < 30 && !foundAssassination; i++) {
const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") {
foundAssassination = true;
expect(mission.objectives.primary[0]).to.have.property("target_def_id");
expect(mission.objectives.primary[0].description).to.include("High-Value Target");
}
}
expect(foundAssassination).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);
expect(mission.objectives.failure_conditions).to.deep.include({ type: "TURN_LIMIT_EXCEEDED" });
}
}
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", () => {
const mission = MissionGenerator.generateSideOp(3, unlockedRegions, []);
// Base 50 * 2.5 (tier 3) * random(0.8, 1.2) = 100-150 range
const currency = mission.rewards.guaranteed.currency.aether_shards;
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
});
it("CoA 20: Should give bonus currency for Assassination missions", () => {
let foundAssassination = false;
let assassinationCurrency = 0;
let otherCurrency = 0;
for (let i = 0; i < 50 && !foundAssassination; i++) {
const mission = MissionGenerator.generateSideOp(2, unlockedRegions, []);
if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") {
foundAssassination = true;
assassinationCurrency = mission.rewards.guaranteed.currency.aether_shards;
} else {
otherCurrency = mission.rewards.guaranteed.currency.aether_shards;
}
}
if (foundAssassination) {
// Assassination should have higher currency (1.5x bonus)
expect(assassinationCurrency).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
// But randomness, so we'll just check structure if found
});
});
});