aether-shards/src/systems/MissionGenerator.js

535 lines
18 KiB
JavaScript
Raw Normal View History

/**
* 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);
// 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,
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,
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)
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" }
]
};
default:
return {
primary: [{
id: "OBJ_DEFAULT",
type: "ELIMINATE_ALL",
description: "Complete the mission objectives."
}],
failure_conditions: [{ type: "SQUAD_WIPE" }]
};
}
}
/**
* 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];
}
}