/** * @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} */ this.completedMissions = new Set(); /** @type {Map} */ 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 | null} */ this._missionsLoadPromise = null; } /** * Lazy-loads all mission definitions if not already loaded. * @returns {Promise} */ async _ensureMissionsLoaded() { if (this._missionsLoadPromise) { return this._missionsLoadPromise; } this._missionsLoadPromise = this._loadMissions(); return this._missionsLoadPromise; } /** * Loads all mission definitions. * @private * @returns {Promise} */ 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} - 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} */ 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} */ 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; } }