/** * MissionGenerator.js * Factory that produces temporary Mission objects for Side Ops (procedural missions). * * @typedef {import("../managers/types.js").MissionDefinition} MissionDefinition */ /** * MissionGenerator * Generates procedural side missions based on campaign tier and unlocked regions. */ export class MissionGenerator { /** * Adjectives for mission naming (general flavor) */ static ADJECTIVES = [ "Silent", "Broken", "Red", "Crimson", "Shattered", "Frozen", "Burning", "Dark", "Blind", "Hidden", "Lost", "Ancient", "Hollow", "Swift", ]; /** * Nouns for Skirmish (Combat) missions */ static NOUNS_SKIRMISH = [ "Thunder", "Storm", "Iron", "Fury", "Shield", "Hammer", "Wrath", "Wall", "Strike", "Anvil", ]; /** * Nouns for Salvage (Loot) missions */ static NOUNS_SALVAGE = [ "Cache", "Vault", "Echo", "Spark", "Core", "Grip", "Harvest", "Trove", "Fragment", "Salvage", ]; /** * Nouns for Assassination (Kill) missions */ static NOUNS_ASSASSINATION = [ "Viper", "Dagger", "Fang", "Night", "Shadow", "End", "Hunt", "Razor", "Ghost", "Sting", ]; /** * Nouns for Recon (Explore) missions */ static NOUNS_RECON = [ "Eye", "Watch", "Path", "Horizon", "Whisper", "Dawn", "Light", "Step", "Vision", "Scope", ]; /** * Tier configuration: [Name, Enemy Level Range, Reward Multiplier] */ static TIER_CONFIG = { 1: { name: "Recon", enemyLevel: [1, 2], multiplier: 1.0 }, 2: { name: "Patrol", enemyLevel: [3, 4], multiplier: 1.5 }, 3: { name: "Conflict", enemyLevel: [5, 6], multiplier: 2.5 }, 4: { name: "War", enemyLevel: [7, 8], multiplier: 4.0 }, 5: { name: "Suicide", enemyLevel: [9, 10], multiplier: 6.0 }, }; /** * Maps biome types to faction IDs for reputation rewards */ static BIOME_TO_FACTION = { BIOME_FUNGAL_CAVES: "ARCANE_DOMINION", BIOME_RUSTING_WASTES: "COGWORK_CONCORD", BIOME_CRYSTAL_SPIRES: "ARCANE_DOMINION", BIOME_VOID_SEEP: "SHADOW_COVENANT", BIOME_CONTESTED_FRONTIER: "IRON_LEGION", }; /** * Templates for dynamic narrative generation */ static NARRATIVE_TEMPLATES = { INTRO: { IRON_LEGION: { speaker: "General Kael", portrait: "assets/images/portraits/vanguard.png", text: "Commander, Iron Legion scouts report {enemy} activity in {biome}. We need you to {verb} them immediately.", }, ARCANE_DOMINION: { speaker: "Magister Varen", portrait: "assets/images/portraits/arcanist.png", text: "Readings indicate {enemy} disturbances in {biome}. Eliminate the threat before it destabilizes the region.", }, COGWORK_CONCORD: { speaker: "Chief Engineer Rix", portrait: "assets/images/portraits/engineer.png", text: "We've got a situation in {biome}. {enemy} units are interfering with our operations. Shut them down.", }, SHADOW_COVENANT: { speaker: "Whisper", portrait: "assets/images/portraits/rogue.png", text: "A target of interest has appeared in {biome}. {enemy} forces are guarding it. Remove them.", }, }, OUTRO: { IRON_LEGION: { speaker: "General Kael", portrait: "assets/images/portraits/vanguard.png", text: "Target neutralized. Funds transferred. Good hunting, Explorer.", }, ARCANE_DOMINION: { speaker: "Magister Varen", portrait: "assets/images/portraits/arcanist.png", text: "The anomaly has been corrected. Payment has been sent.", }, COGWORK_CONCORD: { speaker: "Chief Engineer Rix", portrait: "assets/images/portraits/engineer.png", text: "Efficiency restored. Your compensation is in the account.", }, SHADOW_COVENANT: { speaker: "Whisper", portrait: "assets/images/portraits/rogue.png", text: "Done and dusted. The Covenant remembers your service.", }, }, }; /** * Map of biomes to potential hazards */ static BIOME_HAZARDS = { BIOME_FUNGAL_CAVES: ["HAZARD_POISON_SPORES", "HAZARD_ACID_POOLS"], BIOME_RUSTING_WASTES: ["HAZARD_RADIATION_ZONES", "HAZARD_MAGNETIC_STORM"], BIOME_CRYSTAL_SPIRES: ["HAZARD_MANA_STORM", "HAZARD_CRYSTAL_RESONANCE"], BIOME_VOID_SEEP: ["HAZARD_VOID_POCKETS", "HAZARD_GRAVITY_FLUX"], BIOME_CONTESTED_FRONTIER: ["HAZARD_ARTILLERY_STRIKE", "HAZARD_MINEFIELD"], }; /** * Converts a number to Roman numeral * @param {number} num - Number to convert (2-4) * @returns {string} Roman numeral */ static toRomanNumeral(num) { const roman = ["", "I", "II", "III", "IV", "V"]; return roman[num] || ""; } /** * Extracts the base name (adjective + noun) from a mission title * @param {string} title - Mission title (e.g., "Operation: Silent Viper II") * @returns {string} Base name (e.g., "Silent Viper") */ static extractBaseName(title) { // Remove "Operation: " prefix and any Roman numeral suffix const match = title.match(/Operation:\s*(.+?)(?:\s+[IVX]+)?$/); if (match) { return match[1].trim(); } return title .replace(/Operation:\s*/, "") .replace(/\s+[IVX]+$/, "") .trim(); } /** * Finds the highest Roman numeral in history for a given base name * @param {string} baseName - Base name (e.g., "Silent Viper") * @param {Array} history - Array of completed mission titles or IDs * @returns {number} Highest numeral found (0 if none) */ static findHighestNumeral(baseName, history) { let highest = 0; const pattern = new RegExp( `Operation:\\s*${baseName.replace( /[.*+?^${}()|[\]\\]/g, "\\$&" )}\\s+([IVX]+)`, "i" ); for (const entry of history) { const match = entry.match(pattern); if (match) { const roman = match[1]; const num = this.romanToNumber(roman); if (num > highest) { highest = num; } } } return highest; } /** * Converts Roman numeral to number * @param {string} roman - Roman numeral string * @returns {number} Number value */ static romanToNumber(roman) { const map = { I: 1, II: 2, III: 3, IV: 4, V: 5 }; return map[roman] || 0; } /** * Selects a random element from an array * @param {Array} array - Array to select from * @returns {T} Random element * @template T */ static randomChoice(array) { return array[Math.floor(Math.random() * array.length)]; } /** * Generates a random number between min and max (inclusive) * @param {number} min - Minimum value * @param {number} max - Maximum value * @returns {number} Random number */ static randomRange(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } /** * Generates a random float between min and max * @param {number} min - Minimum value * @param {number} max - Maximum value * @returns {number} Random float */ static randomFloat(min, max) { return Math.random() * (max - min) + min; } /** * Selects a biome from unlocked regions with weighting * @param {Array} unlockedRegions - Array of biome type IDs * @returns {string} Selected biome type */ static selectBiome(unlockedRegions) { if (unlockedRegions.length === 0) { // Default fallback return "BIOME_RUSTING_WASTES"; } // 40% chance for the most recently unlocked region (last in array) if (Math.random() < 0.4 && unlockedRegions.length > 0) { return unlockedRegions[unlockedRegions.length - 1]; } // Otherwise random selection return this.randomChoice(unlockedRegions); } /** * Generates a Side Op mission * @param {number} tier - Campaign tier (1-5) * @param {Array} unlockedRegions - Array of biome type IDs * @param {Array} history - Array of completed mission titles or IDs * @returns {MissionDefinition} Generated mission object */ static generateSideOp(tier, unlockedRegions, history = []) { // Validate tier const validTier = Math.max(1, Math.min(5, tier)); const tierConfig = this.TIER_CONFIG[validTier]; // Select archetype const archetypes = ["SKIRMISH", "SALVAGE", "ASSASSINATION", "RECON"]; const archetype = this.randomChoice(archetypes); // Select noun based on archetype let noun; switch (archetype) { case "SKIRMISH": noun = this.randomChoice(this.NOUNS_SKIRMISH); break; case "SALVAGE": noun = this.randomChoice(this.NOUNS_SALVAGE); break; case "ASSASSINATION": noun = this.randomChoice(this.NOUNS_ASSASSINATION); break; case "RECON": noun = this.randomChoice(this.NOUNS_RECON); break; default: noun = this.randomChoice(this.NOUNS_SKIRMISH); } // Select adjective const adjective = this.randomChoice(this.ADJECTIVES); // Check history for series const baseName = `${adjective} ${noun}`; const highestNumeral = this.findHighestNumeral(baseName, history); const nextNumeral = highestNumeral + 1; const romanSuffix = nextNumeral > 1 ? ` ${this.toRomanNumeral(nextNumeral)}` : ""; // Build title const title = `Operation: ${baseName}${romanSuffix}`; // Select biome const biomeType = this.selectBiome(unlockedRegions); // Generate objectives based on archetype const objectives = this.generateObjectives(archetype, validTier); // Generate biome config based on archetype const biomeConfig = this.generateBiomeConfig(archetype, biomeType); // Generate enemy spawns based on archetype (especially for ASSASSINATION) const enemySpawns = this.generateEnemySpawns( archetype, objectives, validTier ); // Calculate rewards const rewards = this.calculateRewards(validTier, archetype, biomeType); // Determine Faction ID for narrative const factionId = this.BIOME_TO_FACTION[biomeType] || "IRON_LEGION"; // Generate unique ID (timestamp + random to ensure uniqueness) const missionId = `SIDE_OP_${Date.now()}_${Math.random() .toString(36) .substring(2, 9)}`; // Generate Narrative const narrative = this.generateNarrative( missionId, archetype, biomeType, factionId ); // Build mission object const mission = { id: missionId, type: "SIDE_QUEST", config: { title: title, description: this.generateDescription(archetype, biomeType), difficulty_tier: validTier, recommended_level: tierConfig.enemyLevel[1], // Use max enemy level as recommended }, biome: biomeConfig, deployment: { squad_size_limit: 4, }, narrative: narrative, objectives: objectives, enemy_spawns: enemySpawns, rewards: rewards, expiresIn: 3, // Expires in 3 campaign days }; // Add boss config for Assassination missions if (archetype === "ASSASSINATION") { // Find target ID from objectives const targetObj = objectives.primary.find( (o) => o.type === "ELIMINATE_UNIT" ); if (targetObj?.target_def_id) { mission.config.boss_config = { target_def_id: targetObj.target_def_id, name: `${this.randomChoice([ "Krag", "Vorak", "Xol", "Zea", ])} the ${this.randomChoice(["Breaker", "Rot", "Vile", "Ender"])}`, stats: { hp_multiplier: 2.0, attack_multiplier: 1.5, }, }; } } return mission; } /** * Generates dynamic narrative for the mission * @param {string} missionId - The unique ID of the mission * @param {string} archetype - Mission archetype * @param {string} biomeType - Biome type * @param {string} factionId - Faction ID requesting the mission * @returns {Object} Narrative object with intro/outro and dynamic data */ static generateNarrative(missionId, archetype, biomeType, factionId) { const introId = `NARRATIVE_${missionId}_INTRO`; const outroId = `NARRATIVE_${missionId}_OUTRO`; // Get templates (fallback to Iron Legion if missing) const introTemplate = this.NARRATIVE_TEMPLATES.INTRO[factionId] || this.NARRATIVE_TEMPLATES.INTRO["IRON_LEGION"]; const outroTemplate = this.NARRATIVE_TEMPLATES.OUTRO[factionId] || this.NARRATIVE_TEMPLATES.OUTRO["IRON_LEGION"]; // Format Strings const biomeName = biomeType.replace("BIOME_", "").replace("_", " "); let verb = "eliminate"; switch (archetype) { case "SALVAGE": verb = "recover"; break; case "RECON": verb = "scout"; break; case "ASSASSINATION": verb = "assassinate"; break; } const introText = introTemplate.text .replace("{enemy}", "hostile") .replace("{biome}", biomeName.toLowerCase()) .replace("{verb}", verb); return { intro_sequence: introId, outro_success: outroId, _dynamic_data: { [introId]: { id: introId, nodes: [ { id: "1", type: "DIALOGUE", speaker: introTemplate.speaker, portrait: introTemplate.portrait, text: introText, next: "END", }, ], }, [outroId]: { id: outroId, nodes: [ { id: "1", type: "DIALOGUE", speaker: outroTemplate.speaker, portrait: outroTemplate.portrait, text: outroTemplate.text, next: "END", }, ], }, }, }; } /** * Generates objectives based on archetype * @param {string} archetype - Mission archetype * @param {number} tier - Difficulty tier * @returns {Object} Objectives object */ static generateObjectives(archetype, tier) { switch (archetype) { case "SKIRMISH": return { primary: [ { id: "OBJ_ELIMINATE_ALL", type: "ELIMINATE_ALL", description: "Clear the sector of hostile forces.", }, ], failure_conditions: [{ type: "SQUAD_WIPE" }], }; case "SALVAGE": const crateCount = this.randomRange(3, 5); return { primary: [ { id: "OBJ_SALVAGE", type: "INTERACT", target_object_id: "OBJ_SUPPLY_CRATE", target_count: crateCount, description: `Recover ${crateCount} supply crates before the enemy secures them.`, }, ], failure_conditions: [{ type: "SQUAD_WIPE" }], }; case "ASSASSINATION": // Generate a random elite enemy ID const eliteEnemies = [ "ENEMY_ELITE_ECHO", "ENEMY_ELITE_BREAKER", "ENEMY_ELITE_STALKER", "ENEMY_ELITE_WARDEN", ]; const targetId = this.randomChoice(eliteEnemies); return { primary: [ { id: "OBJ_HUNT", type: "ELIMINATE_UNIT", target_def_id: targetId, target_count: 1, // Explicitly set to 1 for single target elimination description: "A High-Value Target has been spotted. Eliminate them.", }, ], failure_conditions: [{ type: "SQUAD_WIPE" }], }; case "RECON": // Generate 3 zone coordinates (simplified - actual zones would be set during mission generation) // Turn limit: 15 + (tier * 5) turns for RECON missions const turnLimit = 15 + tier * 5; return { primary: [ { id: "OBJ_RECON", type: "REACH_ZONE", target_count: 3, description: "Survey the designated coordinates.", }, ], failure_conditions: [ { type: "SQUAD_WIPE" }, { type: "TURN_LIMIT_EXCEEDED", turn_limit: turnLimit }, ], }; default: return { primary: [ { id: "OBJ_DEFAULT", type: "ELIMINATE_ALL", description: "Complete the mission objectives.", }, ], failure_conditions: [{ type: "SQUAD_WIPE" }], }; } } /** * Generates enemy spawns based on archetype and objectives * @param {string} archetype - Mission archetype * @param {Object} objectives - Generated objectives * @param {number} tier - Difficulty tier * @returns {Array} Array of enemy spawn definitions */ static generateEnemySpawns(archetype, objectives, tier) { const spawns = []; switch (archetype) { case "ASSASSINATION": // For ASSASSINATION, spawn the target enemy from the ELIMINATE_UNIT objective const eliminateUnitObj = objectives.primary?.find( (obj) => obj.type === "ELIMINATE_UNIT" ); if (eliminateUnitObj?.target_def_id) { spawns.push({ enemy_def_id: eliminateUnitObj.target_def_id, count: 1, }); } // Also spawn some regular enemies for support const regularEnemies = [ "ENEMY_SHARDBORN_SENTINEL", "ENEMY_GOBLIN_RAIDER", "ENEMY_CRYSTAL_SHARD", ]; const supportEnemy = this.randomChoice(regularEnemies); spawns.push({ enemy_def_id: supportEnemy, count: Math.max(1, Math.floor(tier / 2)), // 1-2 support enemies based on tier }); break; case "SKIRMISH": // Skirmish missions spawn a mix of enemies const skirmishEnemies = [ "ENEMY_SHARDBORN_SENTINEL", "ENEMY_GOBLIN_RAIDER", "ENEMY_CRYSTAL_SHARD", ]; const totalSkirmish = 3 + tier; // 4-8 enemies based on tier for (let i = 0; i < totalSkirmish; i++) { const enemyType = this.randomChoice(skirmishEnemies); const existingSpawn = spawns.find( (s) => s.enemy_def_id === enemyType ); if (existingSpawn) { existingSpawn.count++; } else { spawns.push({ enemy_def_id: enemyType, count: 1 }); } } break; case "SALVAGE": case "RECON": // These missions have fewer enemies (lower density) const lightEnemies = [ "ENEMY_SHARDBORN_SENTINEL", "ENEMY_GOBLIN_RAIDER", ]; const totalLight = Math.max(1, tier); // 1-5 enemies based on tier for (let i = 0; i < totalLight; i++) { const enemyType = this.randomChoice(lightEnemies); const existingSpawn = spawns.find( (s) => s.enemy_def_id === enemyType ); if (existingSpawn) { existingSpawn.count++; } else { spawns.push({ enemy_def_id: enemyType, count: 1 }); } } break; default: // Default: spawn a few basic enemies spawns.push({ enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", count: Math.max(1, tier), }); } return spawns; } /** * Generates biome configuration based on archetype * @param {string} archetype - Mission archetype * @param {string} biomeType - Biome type ID * @returns {Object} Biome configuration */ static generateBiomeConfig(archetype, biomeType) { let size, roomCount, density; switch (archetype) { case "SKIRMISH": size = { x: 20, y: 12, z: 20 }; roomCount = 6; density = "MEDIUM"; break; case "SALVAGE": size = { x: 18, y: 12, z: 18 }; roomCount = 5; density = "HIGH"; // High density for obstacles/cover break; case "ASSASSINATION": size = { x: 22, y: 12, z: 22 }; roomCount = 7; density = "MEDIUM"; break; case "RECON": size = { x: 24, y: 12, z: 24 }; // Large map roomCount = 8; density = "LOW"; // Low enemy density break; default: size = { x: 20, y: 12, z: 20 }; roomCount = 6; density = "MEDIUM"; } // Roll for Hazards (30% chance) const hazards = []; const allowedHazards = this.BIOME_HAZARDS[biomeType]; if (allowedHazards && Math.random() < 0.3) { hazards.push(this.randomChoice(allowedHazards)); } return { type: biomeType, hazards: hazards, generator_config: { seed_type: "RANDOM", size: size, room_count: roomCount, density: density, }, }; } /** * Generates mission description based on archetype and biome * @param {string} archetype - Mission archetype * @param {string} biomeType - Biome type ID * @returns {string} Description text */ static generateDescription(archetype, biomeType) { const biomeNames = { BIOME_FUNGAL_CAVES: "Fungal Caves", BIOME_RUSTING_WASTES: "Rusting Wastes", BIOME_CRYSTAL_SPIRES: "Crystal Spires", BIOME_VOID_SEEP: "Void Seep", BIOME_CONTESTED_FRONTIER: "Contested Frontier", }; const biomeName = biomeNames[biomeType] || "the region"; switch (archetype) { case "SKIRMISH": return `Clear the sector of hostile forces in ${biomeName}.`; case "SALVAGE": return `Recover lost supplies before the enemy secures them in ${biomeName}.`; case "ASSASSINATION": return `A High-Value Target has been spotted in ${biomeName}. Eliminate them.`; case "RECON": return `Survey the designated coordinates in ${biomeName}.`; default: return `Complete the mission objectives in ${biomeName}.`; } } /** * Calculates rewards based on tier and archetype * @param {number} tier - Difficulty tier * @param {string} archetype - Mission archetype * @param {string} biomeType - Biome type ID * @returns {Object} Rewards object */ static calculateRewards(tier, archetype, biomeType) { const tierConfig = this.TIER_CONFIG[tier]; const multiplier = tierConfig.multiplier; // Base currency calculation: Base (50) * TierMultiplier * Random(0.8, 1.2) const baseCurrency = 50; const randomFactor = this.randomFloat(0.8, 1.2); const currencyAmount = Math.round(baseCurrency * multiplier * randomFactor); // XP calculation (base 100 * multiplier) const baseXP = 100; const xpAmount = Math.round( baseXP * multiplier * this.randomFloat(0.9, 1.1) ); // Assassination missions get bonus currency let finalCurrency = currencyAmount; if (archetype === "ASSASSINATION") { finalCurrency = Math.round(finalCurrency * 1.5); } // Items: 20% chance per Tier to drop a Chest Key or Item const items = []; const itemChance = tier * 0.2; if (Math.random() < itemChance) { // Randomly choose between chest key or item if (Math.random() < 0.5) { items.push("ITEM_CHEST_KEY"); } else { // Generic item - would need item registry in real implementation items.push("ITEM_MATERIAL_SCRAP"); } } // Reputation: +10 with the Region's owner const factionId = this.BIOME_TO_FACTION[biomeType] || "IRON_LEGION"; const reputation = 10; const rewards = { guaranteed: { xp: xpAmount, currency: { aether_shards: finalCurrency, }, }, faction_reputation: { [factionId]: reputation, }, }; // Add items if any if (items.length > 0) { rewards.guaranteed.items = items; } return rewards; } /** * Refreshes the mission board, filling it up to 5 entries and removing expired missions * @param {Array} currentMissions - Current list of available missions * @param {number} tier - Current campaign tier * @param {Array} unlockedRegions - Array of unlocked biome type IDs * @param {Array} history - Array of completed mission titles or IDs * @param {boolean} isDailyReset - If true, decrements expiresIn for all missions * @returns {Array} Updated mission list */ static refreshBoard( currentMissions = [], tier, unlockedRegions, history = [], isDailyReset = false ) { // On daily reset, decrement expiresIn for all missions let validMissions = currentMissions; if (isDailyReset) { validMissions = currentMissions.map((mission) => { if (mission.expiresIn !== undefined) { const updated = { ...mission }; updated.expiresIn = (mission.expiresIn || 3) - 1; return updated; } return mission; }); } // Remove missions that have expired (expiresIn <= 0) validMissions = validMissions.filter((mission) => { if (mission.expiresIn !== undefined) { return mission.expiresIn > 0; } // Keep missions without expiration tracking return true; }); // Fill up to 5 missions const targetCount = 5; const needed = Math.max(0, targetCount - validMissions.length); const newMissions = []; for (let i = 0; i < needed; i++) { const mission = this.generateSideOp(tier, unlockedRegions, history); newMissions.push(mission); } // Combine and return return [...validMissions, ...newMissions]; } }