2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* @typedef {import("./types.js").MissionDefinition} MissionDefinition
|
|
|
|
|
* @typedef {import("./types.js").MissionSaveData} MissionSaveData
|
|
|
|
|
* @typedef {import("./types.js").Objective} Objective
|
|
|
|
|
* @typedef {import("./types.js").GameEventData} GameEventData
|
|
|
|
|
*/
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
import { narrativeManager } from "./NarrativeManager.js";
|
2025-12-22 04:40:48 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* MissionManager.js
|
|
|
|
|
* Manages campaign progression, mission selection, narrative triggers, and objective tracking.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @class
|
2025-12-22 04:40:48 +00:00
|
|
|
*/
|
|
|
|
|
export class MissionManager {
|
2026-01-02 00:08:54 +00:00
|
|
|
/**
|
|
|
|
|
* @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;
|
2026-01-01 17:18:09 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
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;
|
2026-01-01 17:18:09 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
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);
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
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;
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
const narrativeData = await response.json();
|
2025-12-22 04:40:48 +00:00
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// 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];
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// 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;
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
if (statusChanged) {
|
|
|
|
|
this.checkVictory();
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// 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;
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
2025-12-22 04:40:48 +00:00
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// 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;
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
});
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// Faction reputation
|
|
|
|
|
if (rewards.faction_reputation) {
|
|
|
|
|
Object.assign(rewardData.factionReputation, rewards.faction_reputation);
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// Dispatch reward event
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
new CustomEvent("mission-rewards", {
|
|
|
|
|
detail: rewardData,
|
|
|
|
|
})
|
|
|
|
|
);
|
2025-12-31 04:56:41 +00:00
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// Handle unlocks (store in localStorage)
|
|
|
|
|
if (rewardData.unlocks.length > 0) {
|
|
|
|
|
this.unlockClasses(rewardData.unlocks);
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
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);
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("Failed to load unlocks from storage:", e);
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
// 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");
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
} 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);
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
2026-01-02 00:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
const narrativeData = await response.json();
|
2025-12-31 04:56:41 +00:00
|
|
|
|
2026-01-02 00:08:54 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|