/** * @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 tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' }; import { narrativeManager } from './NarrativeManager.js'; /** * MissionManager.js * Manages campaign progression, mission selection, narrative triggers, and objective tracking. * @class */ export class MissionManager { constructor() { // 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; // Register default missions this.registerMission(tutorialMission); } /** * 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. * @returns {MissionDefinition | undefined} - Active mission definition */ getActiveMission() { 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. */ setupActiveMission() { const mission = 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' }; return mapping[narrativeId] || 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); // Distribute rewards this.distributeRewards(); // Play outro narrative if available 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 localStorage. * @param {string[]} classIds - Array of class IDs to unlock */ unlockClasses(classIds) { const storageKey = 'aether_shards_unlocks'; let unlocks = []; try { const stored = localStorage.getItem(storageKey); 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 storage try { localStorage.setItem(storageKey, JSON.stringify(unlocks)); console.log('Unlocked classes:', classIds); } 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; } }