aether-shards/src/systems/MissionGenerator.js

886 lines
25 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 Eliminate Unit missions
*/
static NOUNS_ELIMINATE_UNIT = [
"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<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", "ELIMINATE_UNIT", "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 "ELIMINATE_UNIT":
noun = this.randomChoice(this.NOUNS_ELIMINATE_UNIT);
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 Eliminate Unit missions
if (archetype === "ELIMINATE_UNIT") {
// 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 "ELIMINATE_UNIT":
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 "ELIMINATE_UNIT":
// 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 "ELIMINATE_UNIT":
// For ELIMINATE_UNIT, 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 "ELIMINATE_UNIT":
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 "ELIMINATE_UNIT":
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)
);
// Eliminate Unit missions get bonus currency
let finalCurrency = currencyAmount;
if (archetype === "ELIMINATE_UNIT") {
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];
}
}