- Add support for regenerating procedural missions and generating specific mission types through the DebugCommands class. - Implement new methods in MissionManager for populating zone coordinates for REACH_ZONE objectives, improving mission complexity and tracking. - Update GameLoop to dispatch UNIT_MOVE events for better interaction tracking with mission objectives. - Introduce MissionReview component for reviewing completed missions, displaying rewards and narrative elements. - Enhance MissionBoard to support mission review functionality and improve UI for mission selection. - Add tests for new mission features and ensure integration with existing game systems.
625 lines
22 KiB
JavaScript
625 lines
22 KiB
JavaScript
/**
|
|
* 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"
|
|
};
|
|
|
|
/**
|
|
* 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<string>} 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<T>} 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<string>} 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<string>} unlockedRegions - Array of biome type IDs
|
|
* @param {Array<string>} 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);
|
|
|
|
// Generate unique ID (timestamp + random to ensure uniqueness)
|
|
const missionId = `SIDE_OP_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
// 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
|
|
},
|
|
objectives: objectives,
|
|
enemy_spawns: enemySpawns,
|
|
rewards: rewards,
|
|
expiresIn: 3 // Expires in 3 campaign days
|
|
};
|
|
|
|
return mission;
|
|
}
|
|
|
|
/**
|
|
* 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<EnemySpawn>} 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";
|
|
}
|
|
|
|
return {
|
|
type: biomeType,
|
|
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<MissionDefinition>} currentMissions - Current list of available missions
|
|
* @param {number} tier - Current campaign tier
|
|
* @param {Array<string>} unlockedRegions - Array of unlocked biome type IDs
|
|
* @param {Array<string>} history - Array of completed mission titles or IDs
|
|
* @param {boolean} isDailyReset - If true, decrements expiresIn for all missions
|
|
* @returns {Array<MissionDefinition>} 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];
|
|
}
|
|
}
|
|
|