aether-shards/src/managers/MissionManager.js
Matthew Mone 2c86d674f4 Add mission debrief and procedural mission generation features
- Introduce the MissionDebrief component to display after-action reports, including XP, rewards, and squad status.
- Implement the MissionGenerator class to create procedural side missions, enhancing replayability and resource management.
- Update mission schema to include mission objects for INTERACT objectives, improving mission complexity.
- Enhance GameLoop and MissionManager to support new mission features and interactions.
- Add tests for MissionDebrief and MissionGenerator to ensure functionality and integration within the game architecture.
2026-01-01 16:08:54 -08:00

710 lines
21 KiB
JavaScript

/**
* @typedef {import("./types.js").MissionDefinition} MissionDefinition
* @typedef {import("./types.js").MissionSaveData} MissionSaveData
* @typedef {import("./types.js").Objective} Objective
* @typedef {import("./types.js").GameEventData} GameEventData
*/
import { narrativeManager } from "./NarrativeManager.js";
/**
* MissionManager.js
* Manages campaign progression, mission selection, narrative triggers, and objective tracking.
* @class
*/
export class MissionManager {
/**
* @param {import("../core/Persistence.js").Persistence} [persistence] - Persistence manager (optional)
*/
constructor(persistence = null) {
/** @type {import("../core/Persistence.js").Persistence | null} */
this.persistence = persistence;
// Campaign State
/** @type {string | null} */
this.activeMissionId = null;
/** @type {Set<string>} */
this.completedMissions = new Set();
/** @type {Map<string, MissionDefinition>} */
this.missionRegistry = new Map();
// Active Run State
/** @type {MissionDefinition | null} */
this.currentMissionDef = null;
/** @type {Objective[]} */
this.currentObjectives = [];
/** @type {Objective[]} */
this.secondaryObjectives = [];
/** @type {Array<{type: string; [key: string]: unknown}>} */
this.failureConditions = [];
/** @type {UnitManager | null} */
this.unitManager = null;
/** @type {TurnSystem | null} */
this.turnSystem = null;
/** @type {number} */
this.currentTurn = 0;
/** @type {Promise<void> | null} */
this._missionsLoadPromise = null;
}
/**
* Lazy-loads all mission definitions if not already loaded.
* @returns {Promise<void>}
*/
async _ensureMissionsLoaded() {
if (this._missionsLoadPromise) {
return this._missionsLoadPromise;
}
this._missionsLoadPromise = this._loadMissions();
return this._missionsLoadPromise;
}
/**
* Loads all mission definitions.
* @private
* @returns {Promise<void>}
*/
async _loadMissions() {
// Only load if registry is empty (first time)
if (this.missionRegistry.size > 0) {
return;
}
try {
const [tutorialMission, story02Mission, story03Mission] =
await Promise.all([
import("../assets/data/missions/mission_tutorial_01.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/missions/mission_story_02.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/missions/mission_story_03.json", {
with: { type: "json" },
}).then((m) => m.default),
]);
this.registerMission(tutorialMission);
this.registerMission(story02Mission);
this.registerMission(story03Mission);
} catch (error) {
console.error("Failed to load missions:", error);
}
}
/**
* Registers a mission definition.
* @param {MissionDefinition} missionDef - Mission definition to register
*/
registerMission(missionDef) {
this.missionRegistry.set(missionDef.id, missionDef);
}
// --- PERSISTENCE (Campaign) ---
/**
* Loads campaign save data.
* @param {MissionSaveData} saveData - Save data to load
*/
load(saveData) {
this.completedMissions = new Set(saveData.completedMissions || []);
// Default to Tutorial if history is empty
if (this.completedMissions.size === 0) {
this.activeMissionId = "MISSION_TUTORIAL_01";
}
}
/**
* Saves campaign data.
* @returns {MissionSaveData} - Serialized campaign data
*/
save() {
return {
completedMissions: Array.from(this.completedMissions),
};
}
// --- MISSION SETUP & NARRATIVE ---
/**
* Gets the configuration for the currently selected mission.
* Ensures missions are loaded before accessing.
* @returns {Promise<MissionDefinition | undefined>} - Active mission definition
*/
async getActiveMission() {
await this._ensureMissionsLoaded();
if (!this.activeMissionId)
return this.missionRegistry.get("MISSION_TUTORIAL_01");
return this.missionRegistry.get(this.activeMissionId);
}
/**
* Sets the unit manager reference for objective checking.
* @param {UnitManager} unitManager - Unit manager instance
*/
setUnitManager(unitManager) {
this.unitManager = unitManager;
}
/**
* Sets the turn system reference for turn-based objectives.
* @param {TurnSystem} turnSystem - Turn system instance
*/
setTurnSystem(turnSystem) {
this.turnSystem = turnSystem;
}
/**
* Prepares the manager for a new run.
* Resets objectives and prepares narrative hooks.
* @returns {Promise<void>}
*/
async setupActiveMission() {
await this._ensureMissionsLoaded();
const mission = await this.getActiveMission();
this.currentMissionDef = mission;
this.currentTurn = 0;
// Hydrate primary objectives state
this.currentObjectives = (mission.objectives.primary || []).map((obj) => ({
...obj,
current: 0,
complete: false,
}));
// Hydrate secondary objectives state
this.secondaryObjectives = (mission.objectives.secondary || []).map(
(obj) => ({
...obj,
current: 0,
complete: false,
})
);
// Store failure conditions
this.failureConditions = mission.objectives.failure_conditions || [];
console.log(
`Mission Setup: ${mission.config.title} - Primary Objectives:`,
this.currentObjectives
);
if (this.secondaryObjectives.length > 0) {
console.log("Secondary Objectives:", this.secondaryObjectives);
}
if (this.failureConditions.length > 0) {
console.log("Failure Conditions:", this.failureConditions);
}
}
/**
* Plays the intro narrative if one exists.
* Returns a Promise that resolves when the game should start.
*/
async playIntro() {
if (
!this.currentMissionDef ||
!this.currentMissionDef.narrative ||
!this.currentMissionDef.narrative.intro_sequence
) {
return Promise.resolve();
}
return new Promise(async (resolve) => {
const introId = this.currentMissionDef.narrative.intro_sequence;
// Map narrative ID to filename
// NARRATIVE_TUTORIAL_INTRO -> tutorial_intro.json
const narrativeFileName = this._mapNarrativeIdToFileName(introId);
try {
// Load the narrative JSON file
const response = await fetch(
`assets/data/narrative/${narrativeFileName}.json`
);
if (!response.ok) {
console.error(`Failed to load narrative: ${narrativeFileName}`);
resolve();
return;
}
const narrativeData = await response.json();
// Set up listener for narrative end
const onEnd = () => {
narrativeManager.removeEventListener("narrative-end", onEnd);
resolve();
};
narrativeManager.addEventListener("narrative-end", onEnd);
// Start the narrative sequence
console.log(`Playing Narrative Intro: ${introId}`);
narrativeManager.startSequence(narrativeData);
} catch (error) {
console.error(`Error loading narrative ${narrativeFileName}:`, error);
resolve(); // Resolve anyway to not block game start
}
});
}
/**
* Maps narrative sequence ID to filename.
* @param {string} narrativeId - The narrative ID from mission config
* @returns {string} The filename (without .json extension)
*/
_mapNarrativeIdToFileName(narrativeId) {
// Convert NARRATIVE_TUTORIAL_INTRO -> tutorial_intro
// Remove NARRATIVE_ prefix and convert to lowercase with underscores
const mapping = {
NARRATIVE_TUTORIAL_INTRO: "tutorial_intro",
NARRATIVE_TUTORIAL_SUCCESS: "tutorial_success",
NARRATIVE_ACT1_FINAL_WIN: "act1_final_win",
NARRATIVE_ACT1_FINAL_LOSE: "act1_final_lose",
};
if (mapping[narrativeId]) {
return mapping[narrativeId];
}
// For unmapped IDs, convert NARRATIVE_XYZ -> narrative_xyz
// Keep the "narrative_" prefix but lowercase everything
return narrativeId.toLowerCase().replace("narrative_", "");
}
// --- GAMEPLAY LOGIC (Objectives) ---
/**
* Called by GameLoop whenever a relevant event occurs.
* @param {string} type - 'ENEMY_DEATH', 'TURN_END', 'PLAYER_DEATH', etc.
* @param {GameEventData} data - Context data
*/
onGameEvent(type, data) {
if (!this.currentMissionDef) return;
// Check failure conditions first
this.checkFailureConditions(type, data);
// Update objectives
let statusChanged = false;
// Process primary objectives
statusChanged =
this.updateObjectives(this.currentObjectives, type, data) ||
statusChanged;
// Process secondary objectives
this.updateObjectives(this.secondaryObjectives, type, data);
// Check for ELIMINATE_ALL objective completion (needs active check)
// Check after enemy death or at turn end
if (type === "ENEMY_DEATH") {
statusChanged = this.checkEliminateAllObjective() || statusChanged;
} else if (type === "TURN_END") {
// Also check on turn end in case all enemies died from status effects
statusChanged = this.checkEliminateAllObjective() || statusChanged;
}
if (statusChanged) {
this.checkVictory();
}
}
/**
* Updates objectives based on game events.
* @param {Objective[]} objectives - Objectives to update
* @param {string} eventType - Event type
* @param {GameEventData} data - Event data
* @returns {boolean} True if any objective status changed
*/
updateObjectives(objectives, eventType, data) {
let statusChanged = false;
objectives.forEach((obj) => {
if (obj.complete) return;
// ELIMINATE_UNIT: Track specific enemy deaths
if (eventType === "ENEMY_DEATH" && obj.type === "ELIMINATE_UNIT") {
if (
data.unitId === obj.target_def_id ||
data.defId === obj.target_def_id
) {
obj.current = (obj.current || 0) + 1;
if (obj.target_count && obj.current >= obj.target_count) {
obj.complete = true;
statusChanged = true;
}
}
}
// SURVIVE: Check turn count
if (eventType === "TURN_END" && obj.type === "SURVIVE") {
if (obj.turn_count && this.currentTurn >= obj.turn_count) {
obj.complete = true;
statusChanged = true;
}
}
// REACH_ZONE: Check if unit reached target zone
if (eventType === "UNIT_MOVE" && obj.type === "REACH_ZONE") {
if (data.position && obj.zone_coords) {
const reached = obj.zone_coords.some(
(coord) =>
coord.x === data.position.x &&
coord.y === data.position.y &&
coord.z === data.position.z
);
if (reached) {
obj.complete = true;
statusChanged = true;
}
}
}
// INTERACT: Check if unit interacted with target object
if (eventType === "INTERACT" && obj.type === "INTERACT") {
if (data.objectId === obj.target_object_id) {
obj.complete = true;
statusChanged = true;
}
}
// SQUAD_SURVIVAL: Check if minimum units are alive
if (eventType === "PLAYER_DEATH" && obj.type === "SQUAD_SURVIVAL") {
if (this.unitManager) {
const playerUnits = Array.from(
this.unitManager.activeUnits.values()
).filter((u) => u.team === "PLAYER" && u.currentHealth > 0);
if (obj.min_alive && playerUnits.length >= obj.min_alive) {
obj.complete = true;
statusChanged = true;
}
}
}
});
return statusChanged;
}
/**
* Checks if ELIMINATE_ALL objective is complete.
* @returns {boolean} True if status changed
*/
checkEliminateAllObjective() {
let statusChanged = false;
[...this.currentObjectives, ...this.secondaryObjectives].forEach((obj) => {
if (obj.complete || obj.type !== "ELIMINATE_ALL") return;
if (this.unitManager) {
const enemies = Array.from(
this.unitManager.activeUnits.values()
).filter((u) => u.team === "ENEMY" && u.currentHealth > 0);
if (enemies.length === 0) {
obj.complete = true;
statusChanged = true;
}
}
});
return statusChanged;
}
/**
* Checks failure conditions and triggers failure if met.
* @param {string} eventType - Event type
* @param {GameEventData} data - Event data
*/
checkFailureConditions(eventType, data) {
if (!this.failureConditions.length) return;
for (const condition of this.failureConditions) {
// SQUAD_WIPE: All player units are dead
if (condition.type === "SQUAD_WIPE") {
if (this.unitManager) {
const playerUnits = Array.from(
this.unitManager.activeUnits.values()
).filter((u) => u.team === "PLAYER" && u.currentHealth > 0);
if (playerUnits.length === 0) {
this.triggerFailure("SQUAD_WIPE");
return;
}
}
}
// VIP_DEATH: VIP unit died
if (condition.type === "VIP_DEATH" && eventType === "PLAYER_DEATH") {
if (data.unitId && condition.target_tag) {
const unit = this.unitManager?.getUnitById(data.unitId);
if (unit && unit.tags && unit.tags.includes(condition.target_tag)) {
this.triggerFailure("VIP_DEATH", { unitId: data.unitId });
return;
}
}
}
// TURN_LIMIT_EXCEEDED: Mission took too long
if (
condition.type === "TURN_LIMIT_EXCEEDED" &&
eventType === "TURN_END"
) {
if (condition.turn_limit && this.currentTurn > condition.turn_limit) {
this.triggerFailure("TURN_LIMIT_EXCEEDED", {
turn: this.currentTurn,
});
return;
}
}
}
}
/**
* Triggers mission failure.
* @param {string} reason - Failure reason
* @param {GameEventData} [data] - Additional failure data
*/
triggerFailure(reason, data = {}) {
console.log(`MISSION FAILED: ${reason}`, data);
window.dispatchEvent(
new CustomEvent("mission-failure", {
detail: {
missionId: this.activeMissionId,
reason: reason,
...data,
},
})
);
}
checkVictory() {
const allPrimaryComplete =
this.currentObjectives.length > 0 &&
this.currentObjectives.every((o) => o.complete);
if (allPrimaryComplete) {
console.log("VICTORY! Mission Objectives Complete.");
this.completeActiveMission();
// Dispatch event for GameLoop to handle Victory Screen
window.dispatchEvent(
new CustomEvent("mission-victory", {
detail: {
missionId: this.activeMissionId,
primaryObjectives: this.currentObjectives,
secondaryObjectives: this.secondaryObjectives,
},
})
);
}
}
/**
* Completes the active mission and distributes rewards.
*/
async completeActiveMission() {
if (!this.activeMissionId || !this.currentMissionDef) return;
// Mark mission as completed
this.completedMissions.add(this.activeMissionId);
console.log(
"MissionManager: Mission completed. Active mission ID:",
this.activeMissionId
);
console.log(
"MissionManager: Completed missions now:",
Array.from(this.completedMissions)
);
// Dispatch event to save campaign data IMMEDIATELY (before outro)
// This ensures the save happens even if the outro doesn't complete
console.log("MissionManager: Dispatching campaign-data-changed event");
window.dispatchEvent(
new CustomEvent("campaign-data-changed", {
detail: { missionCompleted: this.activeMissionId },
})
);
console.log("MissionManager: campaign-data-changed event dispatched");
// Distribute rewards
this.distributeRewards();
// Play outro narrative if available (after saving)
if (this.currentMissionDef.narrative?.outro_success) {
await this.playOutro(this.currentMissionDef.narrative.outro_success);
}
}
/**
* Distributes mission rewards (XP, currency, items, unlocks).
*/
distributeRewards() {
if (!this.currentMissionDef || !this.currentMissionDef.rewards) return;
const rewards = this.currentMissionDef.rewards;
const rewardData = {
xp: 0,
currency: {},
items: [],
unlocks: [],
factionReputation: {},
};
// Guaranteed rewards
if (rewards.guaranteed) {
if (rewards.guaranteed.xp) {
rewardData.xp += rewards.guaranteed.xp;
}
if (rewards.guaranteed.currency) {
Object.assign(rewardData.currency, rewards.guaranteed.currency);
}
if (rewards.guaranteed.items) {
rewardData.items.push(...rewards.guaranteed.items);
}
if (rewards.guaranteed.unlocks) {
rewardData.unlocks.push(...rewards.guaranteed.unlocks);
}
}
// Conditional rewards (based on secondary objectives)
if (rewards.conditional) {
rewards.conditional.forEach((conditional) => {
const objective = this.secondaryObjectives.find(
(obj) => obj.id === conditional.objective_id
);
if (objective && objective.complete && conditional.reward) {
if (conditional.reward.xp) {
rewardData.xp += conditional.reward.xp;
}
if (conditional.reward.currency) {
Object.assign(rewardData.currency, conditional.reward.currency);
}
if (conditional.reward.items) {
rewardData.items.push(...conditional.reward.items);
}
}
});
}
// Faction reputation
if (rewards.faction_reputation) {
Object.assign(rewardData.factionReputation, rewards.faction_reputation);
}
// Dispatch reward event
window.dispatchEvent(
new CustomEvent("mission-rewards", {
detail: rewardData,
})
);
// Handle unlocks (store in localStorage)
if (rewardData.unlocks.length > 0) {
this.unlockClasses(rewardData.unlocks);
}
console.log("Mission Rewards Distributed:", rewardData);
}
/**
* Unlocks classes and stores them in IndexedDB via Persistence.
* @param {string[]} classIds - Array of class IDs to unlock
*/
async unlockClasses(classIds) {
let unlocks = [];
try {
// Load from IndexedDB
if (this.persistence) {
unlocks = await this.persistence.loadUnlocks();
} else {
// Fallback: try localStorage migration
const stored = localStorage.getItem("aether_shards_unlocks");
if (stored) {
unlocks = JSON.parse(stored);
}
}
} catch (e) {
console.error("Failed to load unlocks from storage:", e);
}
// Add new unlocks
classIds.forEach((classId) => {
if (!unlocks.includes(classId)) {
unlocks.push(classId);
}
});
// Save back to IndexedDB
try {
if (this.persistence) {
await this.persistence.saveUnlocks(unlocks);
console.log("Unlocked classes:", classIds);
// Migrate from localStorage if it exists
if (localStorage.getItem("aether_shards_unlocks")) {
localStorage.removeItem("aether_shards_unlocks");
console.log("Migrated unlocks from localStorage to IndexedDB");
}
} else {
// Fallback to localStorage if persistence not available
localStorage.setItem("aether_shards_unlocks", JSON.stringify(unlocks));
console.log("Unlocked classes (localStorage fallback):", classIds);
}
// Dispatch event so UI components can refresh
window.dispatchEvent(
new CustomEvent("classes-unlocked", {
detail: { unlockedClasses: classIds, allUnlocks: unlocks },
})
);
} catch (e) {
console.error("Failed to save unlocks to storage:", e);
}
}
/**
* Plays the outro narrative if one exists.
* @param {string} outroId - Narrative sequence ID
* @returns {Promise<void>}
*/
async playOutro(outroId) {
return new Promise(async (resolve) => {
const narrativeFileName = this._mapNarrativeIdToFileName(outroId);
try {
const response = await fetch(
`assets/data/narrative/${narrativeFileName}.json`
);
if (!response.ok) {
console.error(`Failed to load outro narrative: ${narrativeFileName}`);
resolve();
return;
}
const narrativeData = await response.json();
const onEnd = () => {
narrativeManager.removeEventListener("narrative-end", onEnd);
resolve();
};
narrativeManager.addEventListener("narrative-end", onEnd);
console.log(`Playing Narrative Outro: ${outroId}`);
narrativeManager.startSequence(narrativeData);
} catch (error) {
console.error(
`Error loading outro narrative ${narrativeFileName}:`,
error
);
resolve();
}
});
}
/**
* Updates the current turn count.
* @param {number} turn - Current turn number
*/
updateTurn(turn) {
this.currentTurn = turn;
}
}