aether-shards/test/systems/MissionGenerator.test.js

680 lines
22 KiB
JavaScript
Raw Normal View History

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");
});
});
});