aether-shards/src/managers/MissionManager.js

647 lines
23 KiB
JavaScript
Raw Normal View History

/**
* @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'
};
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);
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;
}
}