From 0f4210d5c4b3d459ffc6943dea5defdf826b46b4 Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Thu, 1 Jan 2026 21:02:37 -0800 Subject: [PATCH] Enhance mission management and introduce new mission features - Add support for regenerating procedural missions and generating specific mission types through the DebugCommands class. - Implement new methods in MissionManager for populating zone coordinates for REACH_ZONE objectives, improving mission complexity and tracking. - Update GameLoop to dispatch UNIT_MOVE events for better interaction tracking with mission objectives. - Introduce MissionReview component for reviewing completed missions, displaying rewards and narrative elements. - Enhance MissionBoard to support mission review functionality and improve UI for mission selection. - Add tests for new mission features and ensure integration with existing game systems. --- src/core/DebugCommands.js | 117 +++++ src/core/GameLoop.js | 482 +++++++++++++++++- src/grid/VoxelGrid.js | 15 + src/index.js | 3 + src/managers/MissionManager.js | 245 ++++++++- src/managers/UnitManager.js | 2 + src/systems/MissionGenerator.js | 93 +++- src/systems/TurnSystem.js | 23 +- src/ui/combat-hud.js | 161 +++++- src/ui/components/mission-board.js | 360 ++++++++++--- src/ui/components/mission-review.js | 444 ++++++++++++++++ src/ui/game-viewport.js | 29 +- src/ui/screens/MissionDebrief.js | 7 + .../GameLoop/combat-skill-targeting.test.js | 65 +++ test/managers/MissionManager.test.js | 81 +++ 15 files changed, 2003 insertions(+), 124 deletions(-) create mode 100644 src/ui/components/mission-review.js diff --git a/src/core/DebugCommands.js b/src/core/DebugCommands.js index f10bc94..0164687 100644 --- a/src/core/DebugCommands.js +++ b/src/core/DebugCommands.js @@ -6,6 +6,7 @@ import { gameStateManager } from "./GameStateManager.js"; import { itemRegistry } from "../managers/ItemRegistry.js"; +import { MissionGenerator } from "../systems/MissionGenerator.js"; /** * Debug command system for testing game mechanics. @@ -506,6 +507,112 @@ export class DebugCommands { } } + // ============================================ + // MISSION COMMANDS + // ============================================ + + /** + * Regenerates procedural missions on the mission board. + * @returns {string} Result message + */ + regenerateMissions() { + if (!this.gameStateManager?.missionManager) { + return "Error: MissionManager not available"; + } + + const missionManager = this.gameStateManager.missionManager; + missionManager.refreshProceduralMissions(false); + + const proceduralCount = Array.from(missionManager.missionRegistry.values()) + .filter((m) => m.type === "SIDE_QUEST" && m.id?.startsWith("SIDE_OP_")) + .length; + + return `Regenerated procedural missions. ${proceduralCount} missions available on board.`; + } + + /** + * Generates a specific mission type and adds it to the mission board. + * @param {string} archetype - Mission archetype: "SKIRMISH", "SALVAGE", "ASSASSINATION", or "RECON" + * @param {number} [tier=2] - Campaign tier (1-5), defaults to 2 + * @param {string} [biomeType] - Biome type ID (optional, will pick random if not specified) + * @returns {string} Result message + */ + generateMission(archetype = "RECON", tier = 2, biomeType = null) { + if (!this.gameStateManager?.missionManager) { + return "Error: MissionManager not available"; + } + + const validArchetypes = ["SKIRMISH", "SALVAGE", "ASSASSINATION", "RECON"]; + const upperArchetype = archetype.toUpperCase(); + + if (!validArchetypes.includes(upperArchetype)) { + return `Error: Invalid archetype "${archetype}". Valid types: ${validArchetypes.join(", ")}`; + } + + if (tier < 1 || tier > 5) { + return "Error: Tier must be between 1 and 5"; + } + + const missionManager = this.gameStateManager.missionManager; + + // Get unlocked regions and history + const unlockedRegions = missionManager._getUnlockedRegions + ? missionManager._getUnlockedRegions() + : ["BIOME_RUSTING_WASTES"]; + + const history = missionManager._getMissionHistory + ? missionManager._getMissionHistory() + : []; + + // Pick a biome if not specified + if (!biomeType) { + biomeType = unlockedRegions[Math.floor(Math.random() * unlockedRegions.length)]; + } + + // Generate mission with specific archetype + // We need to force the archetype - MissionGenerator.generateSideOp picks randomly + // So we'll generate multiple times until we get the right type, or modify the approach + let mission = null; + let attempts = 0; + const maxAttempts = 50; + + while (!mission && attempts < maxAttempts) { + const candidate = MissionGenerator.generateSideOp(tier, unlockedRegions, history); + // Check if this mission matches our desired archetype + const primaryObj = candidate.objectives?.primary?.[0]; + if (primaryObj) { + const archetypeMap = { + SKIRMISH: "ELIMINATE_ALL", + SALVAGE: "INTERACT", + ASSASSINATION: "ELIMINATE_UNIT", + RECON: "REACH_ZONE", + }; + + if (primaryObj.type === archetypeMap[upperArchetype]) { + mission = candidate; + // Override biome if specified + if (biomeType && candidate.biome) { + mission.biome = { + ...candidate.biome, + type: biomeType, + }; + } + break; + } + } + attempts++; + } + + if (!mission) { + return `Error: Failed to generate ${upperArchetype} mission after ${maxAttempts} attempts. Try again.`; + } + + // Register the mission + missionManager.registerMission(mission); + + return `Generated ${upperArchetype} mission: "${mission.config.title}" (Tier ${tier}, ${mission.biome?.type || biomeType}). Mission ID: ${mission.id}`; + } + // ============================================ // UTILITY COMMANDS // ============================================ @@ -561,6 +668,14 @@ export class DebugCommands { "%cMISSION & NARRATIVE:", "font-weight: bold; color: #2196F3;" ); + console.log(" %cregenerateMissions()%c - Regenerate all procedural missions on board", "color: #FF9800;", "color: inherit;"); + console.log(" %cgenerateMission(type, tier, biome)%c - Generate specific mission type", "color: #FF9800;", "color: inherit;"); + console.log(" type: \"SKIRMISH\", \"SALVAGE\", \"ASSASSINATION\", \"RECON\""); + console.log(" tier: 1-5 (default: 2)"); + console.log(" biome: optional biome ID (e.g., \"BIOME_RUSTING_WASTES\")"); + console.log(" Examples:"); + console.log(" %cgenerateMission(\"RECON\")%c - Generate RECON mission (for testing zone objectives)", "color: #4CAF50; font-family: monospace;", "color: inherit;"); + console.log(" %cgenerateMission(\"SKIRMISH\", 3)%c - Generate tier 3 SKIRMISH mission", "color: #4CAF50; font-family: monospace;", "color: inherit;"); console.log(" %ctriggerVictory()%c - Trigger mission victory", "color: #FF9800;", "color: inherit;"); console.log(" %ccompleteObjective(objectiveId)%c - Complete a specific objective", "color: #FF9800;", "color: inherit;"); console.log(" %ctriggerNarrative(narrativeId)%c - Trigger narrative sequence", "color: #FF9800;", "color: inherit;"); @@ -587,6 +702,8 @@ export class DebugCommands { console.log(" %cdebugCommands.addXP(\"first\", 500)", "color: #4CAF50; font-family: monospace;"); console.log(" %cdebugCommands.setLevel(\"all\", 10)", "color: #4CAF50; font-family: monospace;"); console.log(" %cdebugCommands.addItem(\"ITEM_SWORD_T1\", 1, \"hub\")", "color: #4CAF50; font-family: monospace;"); + console.log(" %cdebugCommands.generateMission(\"RECON\")", "color: #4CAF50; font-family: monospace;"); + console.log(" %cdebugCommands.regenerateMissions()", "color: #4CAF50; font-family: monospace;"); console.log(" %cdebugCommands.killEnemy(\"all\")", "color: #4CAF50; font-family: monospace;"); console.log(" %cdebugCommands.triggerVictory()", "color: #4CAF50; font-family: monospace;"); console.log(""); diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index ff5cf9c..83bcbbe 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -89,6 +89,8 @@ export class GameLoop { /** @type {Map} */ this.missionObjects = new Map(); // object_id -> position /** @type {Set} */ + this.zoneMarkers = new Set(); // Visual markers for REACH_ZONE objectives + /** @type {Set} */ this.movementHighlights = new Set(); /** @type {Set} */ this.spawnZoneHighlights = new Set(); @@ -107,6 +109,20 @@ export class GameLoop { /** @type {number} */ this.lastMoveTime = 0; /** @type {number} */ + + // Camera Animation State + /** @type {boolean} */ + this.isAnimatingCamera = false; + /** @type {THREE.Vector3} */ + this.cameraAnimationStart = new THREE.Vector3(); + /** @type {THREE.Vector3} */ + this.cameraAnimationTarget = new THREE.Vector3(); + /** @type {THREE.Vector3 | null} */ + this.cameraAnimationOffset = null; // Camera offset to maintain during animation + /** @type {number} */ + this.cameraAnimationStartTime = 0; + /** @type {number} */ + this.cameraAnimationDuration = 500; // milliseconds this.moveCooldown = 120; // ms between cursor moves /** @type {"MOVEMENT" | "TARGETING"} */ this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING @@ -516,6 +532,21 @@ export class GameLoop { `Moved ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}` ); + // Follow camera to the unit's new position + // Only follow if this is the active unit (whose turn it is) + const currentActiveUnit = this.turnSystem.getActiveUnit(); + if (currentActiveUnit && currentActiveUnit.id === activeUnit.id) { + this.centerCameraOnUnit(activeUnit); + } + + // Dispatch UNIT_MOVE event to MissionManager for objective tracking + if (this.missionManager) { + this.missionManager.onGameEvent("UNIT_MOVE", { + unitId: activeUnit.id, + position: activeUnit.position, + }); + } + // Check if unit moved to a mission object position (interaction) this.checkMissionObjectInteraction(activeUnit); @@ -818,6 +849,24 @@ export class GameLoop { console.log( `Teleported ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}` ); + + // Dispatch UNIT_MOVE event to MissionManager for objective tracking + if (this.missionManager) { + this.missionManager.onGameEvent("UNIT_MOVE", { + unitId: activeUnit.id, + position: activeUnit.position, + }); + } + + // Check if unit teleported to a mission object position (interaction) + this.checkMissionObjectInteraction(activeUnit); + + // Follow camera to the unit's new position after teleport + // Only follow if this is the active unit (whose turn it is) + const currentActiveUnit = this.turnSystem.getActiveUnit(); + if (currentActiveUnit && currentActiveUnit.id === activeUnit.id) { + this.centerCameraOnUnit(activeUnit); + } } else { console.warn(`Teleport failed: ${result.error || "Unknown error"}`); } @@ -1037,6 +1086,7 @@ export class GameLoop { this.clearMovementHighlights(); this.clearSpawnZoneHighlights(); this.clearMissionObjects(); + this.clearZoneMarkers(); this.clearRangeHighlights(); // Reset Deployment State @@ -1187,6 +1237,11 @@ export class GameLoop { this.turnSystemAbortController = new AbortController(); const signal = this.turnSystemAbortController.signal; + // Set up callbacks for TurnSystem + this.turnSystem.onUnitDeathCallback = (unit) => { + this.handleUnitDeath(unit); + }; + this.turnSystem.addEventListener( "turn-start", (e) => this._onTurnStart(e.detail), @@ -1563,7 +1618,12 @@ export class GameLoop { if (this.missionManager) { this.missionManager.setUnitManager(this.unitManager); this.missionManager.setTurnSystem(this.turnSystem); + this.missionManager.setGridContext(this.grid, this.movementSystem); await this.missionManager.setupActiveMission(); + // Populate zone coordinates for REACH_ZONE objectives + this.missionManager.populateZoneCoordinates(); + // Create visual markers for zones + this.createZoneMarkers(); } // WIRING: Listen for mission events @@ -1626,6 +1686,115 @@ export class GameLoop { this.missionObjects.clear(); } + /** + * Clears all zone marker meshes from the scene. + */ + clearZoneMarkers() { + this.zoneMarkers.forEach((mesh) => { + this.scene.remove(mesh); + if (mesh.geometry) mesh.geometry.dispose(); + if (mesh.material) { + if (Array.isArray(mesh.material)) { + mesh.material.forEach((mat) => mat.dispose()); + } else { + mesh.material.dispose(); + } + } + }); + this.zoneMarkers.clear(); + } + + /** + * Creates visual markers for REACH_ZONE objectives. + * Called after zone coordinates are populated. + */ + createZoneMarkers() { + if (!this.missionManager) return; + + // Find all REACH_ZONE objectives + const reachZoneObjectives = [ + ...(this.missionManager.currentObjectives || []), + ...(this.missionManager.secondaryObjectives || []), + ].filter((obj) => obj.type === "REACH_ZONE" && obj.zone_coords && obj.zone_coords.length > 0); + + for (const obj of reachZoneObjectives) { + for (const coord of obj.zone_coords) { + this.createZoneMarker(coord); + } + } + } + + /** + * Creates a visual marker for a single zone coordinate. + * @param {Position} pos - Zone position + */ + createZoneMarker(pos) { + // Create a glowing beacon/marker for the zone + // Use a cone or cylinder with pulsing glow effect + const geometry = new THREE.ConeGeometry(0.3, 1.2, 8); + + // Cyan/blue color to indicate recon zones + const material = new THREE.MeshStandardMaterial({ + color: 0x00ffff, // Cyan + emissive: 0x004444, // Glow + metalness: 0.5, + roughness: 0.3, + transparent: true, + opacity: 0.9, + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.position.set(pos.x, pos.y + 0.6, pos.z); + mesh.rotation.x = Math.PI; // Point upward + + // Add a pulsing animation + mesh.userData = { + originalY: pos.y + 0.6, + pulseSpeed: 0.02, + pulseAmount: 0.1, + time: Math.random() * Math.PI * 2, // Random phase + }; + + this.scene.add(mesh); + this.zoneMarkers.add(mesh); + + // Add a glowing ring on the ground + const ringGeometry = new THREE.RingGeometry(0.4, 0.5, 16); + const ringMaterial = new THREE.MeshStandardMaterial({ + color: 0x00ffff, + emissive: 0x004444, + transparent: true, + opacity: 0.6, + side: THREE.DoubleSide, + }); + const ring = new THREE.Mesh(ringGeometry, ringMaterial); + ring.rotation.x = -Math.PI / 2; + ring.position.set(pos.x, pos.y + 0.01, pos.z); + ring.userData = { pulseSpeed: 0.02, time: Math.random() * Math.PI * 2 }; + this.scene.add(ring); + this.zoneMarkers.add(ring); + } + + /** + * Updates zone marker animations (pulsing effect). + * Should be called in the animation loop. + */ + updateZoneMarkers() { + this.zoneMarkers.forEach((mesh) => { + if (mesh.userData && mesh.userData.originalY !== undefined) { + mesh.userData.time += mesh.userData.pulseSpeed; + const offset = Math.sin(mesh.userData.time) * mesh.userData.pulseAmount; + mesh.position.y = mesh.userData.originalY + offset; + + // Pulse emissive intensity + if (mesh.material && mesh.material.emissive) { + const intensity = 0.004444 + Math.sin(mesh.userData.time) * 0.002; + mesh.material.emissive.setHex(Math.floor(intensity * 0xffffff)); + } + } + }); + } + /** * Clears all movement highlight meshes from the scene. */ @@ -1766,6 +1935,66 @@ export class GameLoop { }); } + /** + * Centers the camera on a unit's position. + * Respects prefers-reduced-motion: jumps instantly if enabled, otherwise smoothly pans. + * @param {Unit} unit - The unit to center the camera on + */ + centerCameraOnUnit(unit) { + if (!unit || !unit.position || !this.controls) { + return; + } + + // Unit mesh is positioned at (pos.x, pos.y + 0.1, pos.z) + // Center the camera on the unit's position + const targetX = unit.position.x; + const targetY = unit.position.y + 0.1; // Match unit mesh height offset + const targetZ = unit.position.z; + + this.followCameraToPosition(targetX, targetY, targetZ); + } + + /** + * Moves the camera to follow a specific position. + * Respects prefers-reduced-motion: jumps instantly if enabled, otherwise smoothly pans. + * Maintains camera rotation by preserving the relative offset from target to camera. + * @param {number} x - Target X coordinate + * @param {number} y - Target Y coordinate + * @param {number} z - Target Z coordinate + * @private + */ + followCameraToPosition(x, y, z) { + if (!this.controls || !this.camera) { + return; + } + + // Calculate the offset from current target to camera position + // This preserves the camera's rotation/angle relative to the target + const cameraOffset = new THREE.Vector3(); + cameraOffset.subVectors(this.camera.position, this.controls.target); + + // Check for prefers-reduced-motion + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + + if (prefersReducedMotion) { + // Jump instantly to target position, maintaining camera offset + this.controls.target.set(x, y, z); + this.camera.position.copy(this.controls.target).add(cameraOffset); + this.controls.update(); + this.isAnimatingCamera = false; + } else { + // Start smooth animation + this.cameraAnimationStart.copy(this.controls.target); + this.cameraAnimationTarget.set(x, y, z); + this.cameraAnimationStartTime = Date.now(); + this.isAnimatingCamera = true; + // Store the offset to maintain during animation + this.cameraAnimationOffset = cameraOffset; + } + } + /** * Creates a visual mesh for a unit. * @param {Unit} unit - The unit instance @@ -2191,6 +2420,42 @@ export class GameLoop { requestAnimationFrame(this.animate); if (this.inputManager) this.inputManager.update(); + + // Update zone marker animations + this.updateZoneMarkers(); + + // Handle camera animation if active + if (this.isAnimatingCamera && this.controls && this.camera) { + const now = Date.now(); + const elapsed = now - this.cameraAnimationStartTime; + const progress = Math.min(elapsed / this.cameraAnimationDuration, 1.0); + + // Ease-out cubic for smooth deceleration + const eased = 1 - Math.pow(1 - progress, 3); + + // Interpolate between start and target + this.controls.target.lerpVectors( + this.cameraAnimationStart, + this.cameraAnimationTarget, + eased + ); + + // Maintain camera's relative offset to preserve rotation + if (this.cameraAnimationOffset) { + this.camera.position.copy(this.controls.target).add(this.cameraAnimationOffset); + } + + // If animation is complete, snap to final position and stop + if (progress >= 1.0) { + this.controls.target.copy(this.cameraAnimationTarget); + if (this.cameraAnimationOffset) { + this.camera.position.copy(this.controls.target).add(this.cameraAnimationOffset); + } + this.isAnimatingCamera = false; + this.cameraAnimationOffset = null; + } + } + if (this.controls) this.controls.update(); const now = Date.now(); @@ -2627,6 +2892,26 @@ export class GameLoop { }) .filter((entry) => entry !== null); + // Get mission objectives and turn limit from MissionManager + let missionObjectives = null; + let turnLimit = null; + if (this.missionManager) { + missionObjectives = { + primary: this.missionManager.currentObjectives || [], + secondary: this.missionManager.secondaryObjectives || [], + }; + // Find turn limit from failure conditions + const turnLimitCondition = (this.missionManager.failureConditions || []).find( + (fc) => fc.type === "TURN_LIMIT_EXCEEDED" && fc.turn_limit + ); + if (turnLimitCondition) { + turnLimit = { + limit: turnLimitCondition.turn_limit, + current: this.missionManager.currentTurn || 0, + }; + } + } + // Build combat state (enriched for UI, but includes spec fields) const combatState = { // Spec-compliant fields @@ -2642,6 +2927,8 @@ export class GameLoop { targetingMode: this.combatState === "TARGETING_SKILL", // True when player is targeting a skill activeSkillId: this.activeSkillId || null, // ID of the skill being targeted (for UI toggle state) roundNumber: turnSystemState.round, // Alias for UI + missionObjectives, // Mission objectives for UI + turnLimit, // Turn limit info for UI }; // Update GameStateManager @@ -2676,6 +2963,7 @@ export class GameLoop { this.clearMovementHighlights(); // DELEGATE to TurnSystem + // Note: Death from damage is handled in executeSkill, death from status effects is handled in startTurn this.turnSystem.endTurn(activeUnit); // Update combat state (TurnSystem will have advanced to next unit) @@ -2700,6 +2988,9 @@ export class GameLoop { */ _onTurnStart(detail) { const { unit } = detail; + // Center camera on the active unit + this.centerCameraOnUnit(unit); + // Update movement highlights if it's a player's turn if (unit.team === "PLAYER") { this.updateMovementHighlights(unit); @@ -2969,19 +3260,55 @@ export class GameLoop { * @param {Unit} unit - The unit that died */ handleUnitDeath(unit) { - if (!unit || !this.grid || !this.unitManager) return; + if (!unit) { + console.warn("[GameLoop] handleUnitDeath called with null/undefined unit"); + return; + } + if (!this.grid || !this.unitManager) { + console.warn("[GameLoop] handleUnitDeath called but grid or unitManager not available"); + return; + } + + console.log(`[GameLoop] handleUnitDeath called for ${unit.name} (${unit.id})`); // Remove unit from grid if (unit.position) { this.grid.removeUnit(unit.position); + console.log(`[GameLoop] Removed ${unit.name} from grid at (${unit.position.x}, ${unit.position.y}, ${unit.position.z})`); + } else { + console.warn(`[GameLoop] ${unit.name} has no position to remove from grid`); } - // Remove unit from UnitManager - this.unitManager.removeUnit(unit.id); + // Dispatch death event to MissionManager BEFORE removing from UnitManager + // This allows MissionManager to check if this was the last enemy + if (this.missionManager) { + const eventType = unit.team === "ENEMY" ? "ENEMY_DEATH" : "PLAYER_DEATH"; + // Get the definition ID - either from unit.defId or extract from instance ID + // Instance IDs are like "ENEMY_ELITE_BREAKER_1", we want "ENEMY_ELITE_BREAKER" + let unitDefId = unit.defId; + if (!unitDefId && unit.id) { + // Extract defId from instance ID by removing the trailing "_N" suffix + const match = unit.id.match(/^(.+)_\d+$/); + unitDefId = match ? match[1] : unit.id; + } + if (!unitDefId) { + unitDefId = unit.id; // Fallback to instance ID + } + console.log(`[GameLoop] Dispatching ${eventType} event for ${unit.name} (defId: ${unitDefId}) BEFORE removing from UnitManager`); + this.missionManager.onGameEvent(eventType, { + unitId: unit.id, + defId: unitDefId, + team: unit.team, + }); + } else { + console.warn(`[GameLoop] MissionManager not available, cannot dispatch death event`); + } - // Remove unit mesh from scene + // Remove unit mesh from scene FIRST (before removing from UnitManager) + // This ensures the visual removal happens const mesh = this.unitMeshes.get(unit.id); if (mesh) { + console.log(`[GameLoop] Removing mesh for ${unit.name} from scene`); this.scene.remove(mesh); this.unitMeshes.delete(unit.id); // Dispose geometry and material @@ -2997,20 +3324,12 @@ export class GameLoop { mesh.material.dispose(); } } + console.log(`[GameLoop] Mesh removed and disposed for ${unit.name}`); + } else { + console.warn(`[GameLoop] No mesh found for ${unit.name} (${unit.id}) in unitMeshes map. Available meshes:`, Array.from(this.unitMeshes.keys())); } - // Dispatch death event to MissionManager - if (this.missionManager) { - const eventType = unit.team === "ENEMY" ? "ENEMY_DEATH" : "PLAYER_DEATH"; - const unitDefId = unit.defId || unit.id; - this.missionManager.onGameEvent(eventType, { - unitId: unit.id, - defId: unitDefId, - team: unit.team, - }); - } - - console.log(`${unit.name} (${unit.team}) has been removed from combat.`); + console.log(`[GameLoop] ${unit.name} (${unit.team}) has been removed from combat.`); } /** @@ -3037,6 +3356,11 @@ export class GameLoop { _handleMissionVictory(detail) { console.log("Mission Victory!", detail); + // End combat first to properly clean up turn system state + if (this.turnSystem && this.turnSystem.phase !== "COMBAT_END") { + this.turnSystem.endCombat(); + } + // Save Explorer progression back to roster this._saveExplorerProgression(); @@ -3046,6 +3370,18 @@ export class GameLoop { // Stop the game loop this.stop(); + // Calculate MissionResult for debrief + const missionResult = this._calculateMissionResult(detail); + + // Dispatch show-debrief event (before outro narrative) + window.dispatchEvent( + new CustomEvent("show-debrief", { + detail: { result: missionResult }, + bubbles: true, + composed: true, + }) + ); + // Clear the active run from persistence since mission is complete if (this.gameStateManager) { this.gameStateManager.clearActiveRun(); @@ -3069,12 +3405,15 @@ export class GameLoop { handleNarrativeEnd ); - // Small delay after narrative ends to let user see the final message - setTimeout(() => { + // Wait for debrief to close before transitioning + // The debrief will dispatch debrief-closed when user clicks return + const handleDebriefClosed = () => { + window.removeEventListener("debrief-closed", handleDebriefClosed); if (this.gameStateManager) { this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } - }, 500); + }; + window.addEventListener("debrief-closed", handleDebriefClosed); }; narrativeManager.addEventListener("narrative-end", handleNarrativeEnd); @@ -3088,21 +3427,107 @@ export class GameLoop { "narrative-end", handleNarrativeEnd ); - if (this.gameStateManager) { - this.gameStateManager.transitionTo("STATE_MAIN_MENU"); - } + const handleDebriefClosed = () => { + window.removeEventListener("debrief-closed", handleDebriefClosed); + if (this.gameStateManager) { + this.gameStateManager.transitionTo("STATE_MAIN_MENU"); + } + }; + window.addEventListener("debrief-closed", handleDebriefClosed); }, 30000); } else { - // No outro, transition immediately after a short delay - console.log("GameLoop: No outro narrative, transitioning to hub"); - setTimeout(() => { + // No outro, wait for debrief to close before transitioning + console.log("GameLoop: No outro narrative, waiting for debrief to close"); + const handleDebriefClosed = () => { + window.removeEventListener("debrief-closed", handleDebriefClosed); if (this.gameStateManager) { this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } - }, 1000); + }; + window.addEventListener("debrief-closed", handleDebriefClosed); } } + /** + * Calculates MissionResult for the debrief screen. + * @param {Object} detail - Victory event detail + * @returns {Object} MissionResult object + * @private + */ + _calculateMissionResult(detail) { + const missionDef = this.gameStateManager?.missionManager?.currentMissionDef; + const rewards = missionDef?.rewards || {}; + const guaranteed = rewards.guaranteed || {}; + + // Get currency (handle both snake_case and camelCase) + const currency = { + shards: + guaranteed.currency?.aether_shards || + guaranteed.currency?.aetherShards || + 0, + cores: + guaranteed.currency?.ancient_cores || + guaranteed.currency?.ancientCores || + 0, + }; + + // Get XP + const xpEarned = guaranteed.xp || 0; + + // Get loot items (convert item IDs to item instances) + const loot = []; + if (guaranteed.items && Array.isArray(guaranteed.items)) { + guaranteed.items.forEach((itemDefId) => { + loot.push({ + defId: itemDefId, + name: itemDefId, // Will be resolved by item registry if available + quantity: 1, + }); + }); + } + + // Get reputation changes + const reputationChanges = []; + if (rewards.faction_reputation) { + Object.entries(rewards.faction_reputation).forEach(([factionId, amount]) => { + reputationChanges.push({ factionId, amount }); + }); + } + + // Get squad status + const squadUpdates = []; + if (this.unitManager) { + const playerUnits = Array.from( + this.unitManager.activeUnits.values() + ).filter((u) => u.team === "PLAYER"); + + playerUnits.forEach((unit) => { + const isDead = unit.currentHealth <= 0; + const maxHealth = unit.maxHealth || unit.health || 100; + const currentHealth = unit.currentHealth || 0; + const damageTaken = Math.max(0, maxHealth - currentHealth); + + squadUpdates.push({ + unitId: unit.id || unit.defId || "Unknown", + isDead, + leveledUp: false, // TODO: Track level ups + damageTaken, + }); + }); + } + + return { + outcome: "VICTORY", + missionTitle: missionDef?.config?.title || "Mission", + xpEarned, + currency, + loot, + reputationChanges, + squadUpdates, + turnsTaken: this.turnSystem?.currentTurn || 0, + }; + } + /** * Saves Explorer progression (classMastery, activeClassId) back to roster. * @private @@ -3165,6 +3590,11 @@ export class GameLoop { _handleMissionFailure(detail) { console.log("Mission Failed!", detail); + // End combat first to properly clean up turn system state + if (this.turnSystem && this.turnSystem.phase !== "COMBAT_END") { + this.turnSystem.endCombat(); + } + // Save Explorer progression back to roster (even on failure, progression should persist) this._saveExplorerProgression(); diff --git a/src/grid/VoxelGrid.js b/src/grid/VoxelGrid.js index 9e4e6ae..5af8736 100644 --- a/src/grid/VoxelGrid.js +++ b/src/grid/VoxelGrid.js @@ -263,6 +263,21 @@ export class VoxelGrid { return true; } + /** + * Removes a unit from the grid at the specified position. + * @param {Position} pos - Position to remove unit from + * @returns {boolean} - True if a unit was removed + */ + removeUnit(pos) { + if (!pos) return false; + const key = this._key(pos); + if (this.unitMap.has(key)) { + this.unitMap.delete(key); + return true; + } + return false; + } + // --- HAZARDS --- /** diff --git a/src/index.js b/src/index.js index 9aee431..27fc95c 100644 --- a/src/index.js +++ b/src/index.js @@ -252,6 +252,7 @@ window.addEventListener("gamestate-changed", async (e) => { // Load HubScreen dynamically await import("./ui/screens/hub-screen.js"); await import("./ui/components/mission-board.js"); + await import("./ui/components/mission-review.js"); const hub = document.querySelector("hub-screen"); if (hub) { hub.toggleAttribute("hidden", false); @@ -369,6 +370,8 @@ if (typeof window !== "undefined") { "addCurrency", "killEnemy", "healUnit", + "regenerateMissions", + "generateMission", "triggerVictory", "completeObjective", "triggerNarrative", diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js index 829699e..d1faf3f 100644 --- a/src/managers/MissionManager.js +++ b/src/managers/MissionManager.js @@ -42,6 +42,12 @@ export class MissionManager { this.unitManager = null; /** @type {TurnSystem | null} */ this.turnSystem = null; + /** @type {import("../grid/VoxelGrid.js").VoxelGrid | null} */ + this.grid = null; + /** @type {import("../systems/MovementSystem.js").MovementSystem | null} */ + this.movementSystem = null; + /** @type {boolean} */ + this._hadEnemies = false; // Track if we had enemies for ELIMINATE_ALL check /** @type {number} */ this.currentTurn = 0; @@ -101,6 +107,12 @@ export class MissionManager { */ registerMission(missionDef) { this.missionRegistry.set(missionDef.id, missionDef); + // Dispatch event to notify UI components (like MissionBoard) that missions have been updated + window.dispatchEvent( + new CustomEvent("missions-updated", { + detail: { missionId: missionDef.id, action: "registered" }, + }) + ); } /** @@ -158,14 +170,22 @@ export class MissionManager { this.missionRegistry.delete(mission.id); }); - // Register refreshed procedural missions + // Register refreshed procedural missions (batch register to avoid multiple events) refreshedProcedural.forEach((mission) => { - this.registerMission(mission); + // Register without dispatching event for each mission + this.missionRegistry.set(mission.id, mission); }); console.log( `Refreshed procedural missions: ${refreshedProcedural.length} available` ); + + // Dispatch single event after all missions are registered + window.dispatchEvent( + new CustomEvent("missions-updated", { + detail: { action: "refreshed", count: refreshedProcedural.length }, + }) + ); } /** @@ -277,6 +297,78 @@ export class MissionManager { this.turnSystem = turnSystem; } + /** + * Sets the grid and movement system references for zone coordinate generation. + * @param {import("../grid/VoxelGrid.js").VoxelGrid} grid - Voxel grid instance + * @param {import("../systems/MovementSystem.js").MovementSystem} movementSystem - Movement system instance + */ + setGridContext(grid, movementSystem) { + this.grid = grid; + this.movementSystem = movementSystem; + } + + /** + * Populates zone coordinates for REACH_ZONE objectives that don't have them. + * Generates random walkable positions on the map. + */ + populateZoneCoordinates() { + if (!this.grid || !this.movementSystem) { + console.warn( + "Cannot populate zone coordinates: grid or movementSystem not set" + ); + return; + } + + // Find all REACH_ZONE objectives that need zone_coords + const reachZoneObjectives = [ + ...this.currentObjectives, + ...this.secondaryObjectives, + ].filter( + (obj) => + obj.type === "REACH_ZONE" && + (!obj.zone_coords || obj.zone_coords.length === 0) + ); + + for (const obj of reachZoneObjectives) { + const targetCount = obj.target_count || 3; + const zones = []; + + // Generate random walkable positions + const attempts = 100; + for (let i = 0; i < attempts && zones.length < targetCount; i++) { + const x = Math.floor(Math.random() * this.grid.size.x); + const z = Math.floor(Math.random() * this.grid.size.z); + const y = Math.floor(this.grid.size.y / 2); // Start from middle height + + const walkableY = this.movementSystem.findWalkableY(x, z, y); + if ( + walkableY !== null && + !this.grid.isOccupied({ x, y: walkableY, z }) + ) { + // Check if we already have a zone at this position (avoid duplicates) + const isDuplicate = zones.some( + (zone) => zone.x === x && zone.y === walkableY && zone.z === z + ); + if (!isDuplicate) { + zones.push({ x, y: walkableY, z }); + } + } + } + + if (zones.length > 0) { + obj.zone_coords = zones; + console.log( + `Populated ${zones.length} zone coordinates for objective ${obj.id}:`, + zones + ); + } else { + console.warn( + `Failed to generate zone coordinates for objective ${obj.id}` + ); + } + } + } + /** * Prepares the manager for a new run. * Resets objectives and prepares narrative hooks. @@ -420,14 +512,27 @@ export class MissionManager { // Check for ELIMINATE_ALL objective completion (needs active check) // Check after enemy death or at turn end if (type === "ENEMY_DEATH") { + console.log( + `[MissionManager] ENEMY_DEATH event received, checking ELIMINATE_ALL objective` + ); statusChanged = this.checkEliminateAllObjective() || statusChanged; + console.log( + `[MissionManager] ELIMINATE_ALL check returned statusChanged: ${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) { + console.log( + `[MissionManager] Objective status changed, calling checkVictory()` + ); this.checkVictory(); + } else { + console.log( + `[MissionManager] No objective status change, not checking victory` + ); } } @@ -446,15 +551,27 @@ export class MissionManager { // 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 - ) { + // Check if the killed enemy matches the target (by defId, not unitId) + // unitId is the instance ID, defId is the definition ID we're looking for + if (data.defId === obj.target_def_id) { obj.current = (obj.current || 0) + 1; - if (obj.target_count && obj.current >= obj.target_count) { + const targetCount = + obj.target_count !== undefined ? obj.target_count : 1; // Default to 1 if not specified + if (obj.current >= targetCount) { obj.complete = true; statusChanged = true; + console.log( + `[MissionManager] ELIMINATE_UNIT objective completed! Killed ${obj.current}/${targetCount} of ${obj.target_def_id}` + ); + } else { + console.log( + `[MissionManager] ELIMINATE_UNIT progress: ${obj.current}/${targetCount} of ${obj.target_def_id}` + ); } + } else { + console.log( + `[MissionManager] Enemy killed (${data.defId}) does not match target (${obj.target_def_id})` + ); } } @@ -468,17 +585,66 @@ export class MissionManager { // 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( + if (data.position && obj.zone_coords && obj.zone_coords.length > 0) { + // Find which zone was reached (match X and Z, Y can vary slightly due to walkable level) + const reachedZoneIndex = obj.zone_coords.findIndex( (coord) => coord.x === data.position.x && - coord.y === data.position.y && - coord.z === data.position.z + coord.z === data.position.z && + Math.abs(coord.y - data.position.y) <= 1 // Allow Y to vary by 1 level ); - if (reached) { - obj.complete = true; - statusChanged = true; + if (reachedZoneIndex !== -1) { + // Mark this zone as reached (remove it from the list) + const reachedZone = obj.zone_coords[reachedZoneIndex]; + obj.zone_coords.splice(reachedZoneIndex, 1); + obj.current = (obj.current || 0) + 1; + + console.log( + `[MissionManager] Zone reached at (${data.position.x}, ${ + data.position.y + }, ${data.position.z})! Progress: ${obj.current}/${ + obj.target_count || obj.zone_coords.length + obj.current + }` + ); + + // Check if we've reached the target count, or if all zones are reached (no target_count) + if (obj.target_count) { + // Has target_count: complete when current >= target_count + if (obj.current >= obj.target_count) { + obj.complete = true; + statusChanged = true; + console.log(`[MissionManager] REACH_ZONE objective completed!`); + } else { + statusChanged = true; // Progress updated + } + } else { + // No target_count: complete when all zones are reached + if (obj.zone_coords.length === 0) { + obj.complete = true; + statusChanged = true; + console.log( + `[MissionManager] REACH_ZONE objective completed (all zones reached)!` + ); + } else { + statusChanged = true; // Progress updated + } + } + } else { + // Debug: log when we're checking but not matching + console.log( + `[MissionManager] Unit at (${data.position.x}, ${data.position.y}, ${data.position.z}), checking ${obj.zone_coords.length} zones:`, + obj.zone_coords.map((c) => `(${c.x}, ${c.y}, ${c.z})`) + ); } + } else if (obj.type === "REACH_ZONE") { + console.warn( + `[MissionManager] REACH_ZONE objective missing zone_coords or position data:`, + { + hasPosition: !!data.position, + hasZoneCoords: !!obj.zone_coords, + zoneCoordsLength: obj.zone_coords?.length, + } + ); } } @@ -518,14 +684,44 @@ export class MissionManager { 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); + const allUnits = Array.from(this.unitManager.activeUnits.values()); + const allEnemies = allUnits.filter((u) => u.team === "ENEMY"); + const aliveEnemies = allEnemies.filter((u) => u.currentHealth > 0); - if (enemies.length === 0) { - obj.complete = true; - statusChanged = true; + console.log( + `[MissionManager] ELIMINATE_ALL check: ${aliveEnemies.length} alive enemies remaining (out of ${allEnemies.length} total enemies, ${allUnits.length} total units)` + ); + + // If we have an ELIMINATE_ALL objective and no alive enemies, mission is complete + // We check aliveEnemies.length === 0 because: + // - If enemies exist but are all dead (health <= 0), aliveEnemies will be empty + // - If all enemies were removed, aliveEnemies will also be empty + // - We only want to complete if we actually had enemies (allEnemies.length > 0 at some point) + // But since we're checking after an ENEMY_DEATH event, we know there was at least one enemy + if (aliveEnemies.length === 0) { + // Check if we had enemies at some point (either still in list dead, or were just removed) + // If allEnemies.length > 0, we had enemies (some may be dead but still in list) + // If allEnemies.length === 0 but we got an ENEMY_DEATH event, the last enemy was just removed + if (allEnemies.length > 0 || this._hadEnemies) { + console.log( + "[MissionManager] ELIMINATE_ALL objective completed! (No alive enemies)" + ); + obj.complete = true; + statusChanged = true; + this._hadEnemies = false; // Reset flag + } else { + console.log( + "[MissionManager] ELIMINATE_ALL: No enemies found in unitManager (may not have spawned yet)" + ); + } + } else { + // Track that we had enemies + this._hadEnemies = true; } + } else { + console.warn( + "[MissionManager] checkEliminateAllObjective: unitManager not set" + ); } }); @@ -602,6 +798,15 @@ export class MissionManager { const allPrimaryComplete = this.currentObjectives.length > 0 && this.currentObjectives.every((o) => o.complete); + + console.log( + `[MissionManager] checkVictory: ${this.currentObjectives.length} objectives, all complete: ${allPrimaryComplete}`, + this.currentObjectives.map((o) => ({ + type: o.type, + complete: o.complete, + })) + ); + if (allPrimaryComplete) { console.log("VICTORY! Mission Objectives Complete."); this.completeActiveMission(); diff --git a/src/managers/UnitManager.js b/src/managers/UnitManager.js index 2ee5b28..a34e0b8 100644 --- a/src/managers/UnitManager.js +++ b/src/managers/UnitManager.js @@ -59,6 +59,8 @@ export class UnitManager { unit = new Explorer(instanceId, def.name, defId, def); } else if (def.type === "ENEMY" || defId.startsWith("ENEMY_")) { unit = new Enemy(instanceId, def.name, def); + // Store the definition ID so we can match it later for ELIMINATE_UNIT objectives + unit.defId = defId; } else { // Generic/Structure unit = new Unit(instanceId, def.name, "STRUCTURE", def.model); diff --git a/src/systems/MissionGenerator.js b/src/systems/MissionGenerator.js index 36035b1..c2ef0dc 100644 --- a/src/systems/MissionGenerator.js +++ b/src/systems/MissionGenerator.js @@ -236,6 +236,9 @@ export class MissionGenerator { // Generate biome config based on archetype const biomeConfig = this.generateBiomeConfig(archetype, biomeType); + // Generate enemy spawns based on archetype (especially for ASSASSINATION) + const enemySpawns = this.generateEnemySpawns(archetype, objectives, validTier); + // Calculate rewards const rewards = this.calculateRewards(validTier, archetype, biomeType); @@ -257,6 +260,7 @@ export class MissionGenerator { squad_size_limit: 4 }, objectives: objectives, + enemy_spawns: enemySpawns, rewards: rewards, expiresIn: 3 // Expires in 3 campaign days }; @@ -309,6 +313,7 @@ export class MissionGenerator { id: "OBJ_HUNT", type: "ELIMINATE_UNIT", target_def_id: targetId, + target_count: 1, // Explicitly set to 1 for single target elimination description: "A High-Value Target has been spotted. Eliminate them." }], failure_conditions: [{ type: "SQUAD_WIPE" }] @@ -316,6 +321,8 @@ export class MissionGenerator { case "RECON": // Generate 3 zone coordinates (simplified - actual zones would be set during mission generation) + // Turn limit: 15 + (tier * 5) turns for RECON missions + const turnLimit = 15 + (tier * 5); return { primary: [{ id: "OBJ_RECON", @@ -325,7 +332,7 @@ export class MissionGenerator { }], failure_conditions: [ { type: "SQUAD_WIPE" }, - { type: "TURN_LIMIT_EXCEEDED" } + { type: "TURN_LIMIT_EXCEEDED", turn_limit: turnLimit } ] }; @@ -341,6 +348,90 @@ export class MissionGenerator { } } + /** + * Generates enemy spawns based on archetype and objectives + * @param {string} archetype - Mission archetype + * @param {Object} objectives - Generated objectives + * @param {number} tier - Difficulty tier + * @returns {Array} Array of enemy spawn definitions + */ + static generateEnemySpawns(archetype, objectives, tier) { + const spawns = []; + + switch (archetype) { + case "ASSASSINATION": + // For ASSASSINATION, spawn the target enemy from the ELIMINATE_UNIT objective + const eliminateUnitObj = objectives.primary?.find( + (obj) => obj.type === "ELIMINATE_UNIT" + ); + if (eliminateUnitObj?.target_def_id) { + spawns.push({ + enemy_def_id: eliminateUnitObj.target_def_id, + count: 1 + }); + } + // Also spawn some regular enemies for support + const regularEnemies = [ + "ENEMY_SHARDBORN_SENTINEL", + "ENEMY_GOBLIN_RAIDER", + "ENEMY_CRYSTAL_SHARD" + ]; + const supportEnemy = this.randomChoice(regularEnemies); + spawns.push({ + enemy_def_id: supportEnemy, + count: Math.max(1, Math.floor(tier / 2)) // 1-2 support enemies based on tier + }); + break; + + case "SKIRMISH": + // Skirmish missions spawn a mix of enemies + const skirmishEnemies = [ + "ENEMY_SHARDBORN_SENTINEL", + "ENEMY_GOBLIN_RAIDER", + "ENEMY_CRYSTAL_SHARD" + ]; + const totalSkirmish = 3 + tier; // 4-8 enemies based on tier + for (let i = 0; i < totalSkirmish; i++) { + const enemyType = this.randomChoice(skirmishEnemies); + const existingSpawn = spawns.find((s) => s.enemy_def_id === enemyType); + if (existingSpawn) { + existingSpawn.count++; + } else { + spawns.push({ enemy_def_id: enemyType, count: 1 }); + } + } + break; + + case "SALVAGE": + case "RECON": + // These missions have fewer enemies (lower density) + const lightEnemies = [ + "ENEMY_SHARDBORN_SENTINEL", + "ENEMY_GOBLIN_RAIDER" + ]; + const totalLight = Math.max(1, tier); // 1-5 enemies based on tier + for (let i = 0; i < totalLight; i++) { + const enemyType = this.randomChoice(lightEnemies); + const existingSpawn = spawns.find((s) => s.enemy_def_id === enemyType); + if (existingSpawn) { + existingSpawn.count++; + } else { + spawns.push({ enemy_def_id: enemyType, count: 1 }); + } + } + break; + + default: + // Default: spawn a few basic enemies + spawns.push({ + enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", + count: Math.max(1, tier) + }); + } + + return spawns; + } + /** * Generates biome configuration based on archetype * @param {string} archetype - Mission archetype diff --git a/src/systems/TurnSystem.js b/src/systems/TurnSystem.js index c411d77..f60cec5 100644 --- a/src/systems/TurnSystem.js +++ b/src/systems/TurnSystem.js @@ -160,7 +160,11 @@ export class TurnSystem extends EventTarget { if (isStunned) { // Process hazards first, then status effects this.processEnvironmentalHazards(unit); - this.processStatusEffects(unit); + const unitDied = this.processStatusEffects(unit); + // Check if unit died from status effects + if (unitDied && this.onUnitDeathCallback) { + this.onUnitDeathCallback(unit); + } // Skip action phase, immediately end turn this.phase = "TURN_END"; this.endTurn(unit); @@ -172,7 +176,15 @@ export class TurnSystem extends EventTarget { this.processEnvironmentalHazards(unit); // D. Status Effect Tick (The "Upkeep" Step) - this.processStatusEffects(unit); + const unitDied = this.processStatusEffects(unit); + + // Check if unit died from status effects - if so, handle death and skip turn + if (unitDied && this.onUnitDeathCallback) { + this.onUnitDeathCallback(unit); + // Skip turn for dead unit + this.endTurn(unit); + return; + } // Dispatch turn-start event this.dispatchEvent( @@ -257,11 +269,13 @@ export class TurnSystem extends EventTarget { /** * Processes status effects for a unit (DoT/HoT, duration decrement, expiration). * @param {Unit} unit - The unit to process status effects for + * @returns {boolean} True if the unit died from status effects */ processStatusEffects(unit) { - if (!unit.statusEffects || unit.statusEffects.length === 0) return; + if (!unit.statusEffects || unit.statusEffects.length === 0) return false; const effectsToRemove = []; + const healthBefore = unit.currentHealth; unit.statusEffects.forEach((effect, index) => { // Apply DoT/HoT if applicable @@ -311,6 +325,9 @@ export class TurnSystem extends EventTarget { effectsToRemove.reverse().forEach((index) => { unit.statusEffects.splice(index, 1); }); + + // Return true if unit died from status effects + return healthBefore > 0 && unit.currentHealth <= 0; } /** diff --git a/src/ui/combat-hud.js b/src/ui/combat-hud.js index 2e9abf7..ba2b447 100644 --- a/src/ui/combat-hud.js +++ b/src/ui/combat-hud.js @@ -386,6 +386,82 @@ export class CombatHUD extends LitElement { transform: translateY(0); } + /* Objectives Panel */ + .objectives-panel { + position: absolute; + top: 130px; + left: var(--spacing-xl); + background: var(--color-bg-primary); + border: var(--border-width-medium) solid var(--color-border-default); + padding: var(--spacing-md); + min-width: 250px; + max-width: 350px; + pointer-events: auto; + max-height: calc(100vh - 200px); + overflow-y: auto; + } + + .objectives-panel h3 { + margin: 0 0 var(--spacing-sm) 0; + font-size: var(--font-size-base); + color: var(--color-accent-gold); + border-bottom: var(--border-width-thin) solid var(--color-border-default); + padding-bottom: var(--spacing-xs); + } + + .objective-item { + margin-bottom: var(--spacing-sm); + padding: var(--spacing-xs); + background: rgba(0, 0, 0, 0.3); + border-left: var(--border-width-thin) solid var(--color-border-default); + padding-left: var(--spacing-sm); + } + + .objective-item.complete { + opacity: 0.6; + border-left-color: var(--color-accent-green); + } + + .objective-description { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin-bottom: var(--spacing-xs); + } + + .objective-progress { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } + + .turn-limit-indicator { + position: absolute; + top: 130px; + right: var(--spacing-xl); + background: var(--color-bg-primary); + border: var(--border-width-medium) solid var(--color-border-default); + padding: var(--spacing-sm) var(--spacing-md); + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + } + + .turn-limit-label { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + } + + .turn-limit-value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-accent-orange); + } + + .turn-limit-value.warning { + color: var(--color-accent-red); + } + /* Responsive Design - Mobile (< 768px) */ @media (max-width: 767px) { .bottom-bar { @@ -569,12 +645,23 @@ export class CombatHUD extends LitElement { return html``; } - const { activeUnit, enrichedQueue, turnQueue, roundNumber, round } = - this.combatState; + const { + activeUnit, + enrichedQueue, + turnQueue, + roundNumber, + round, + missionObjectives, + turnLimit, + } = this.combatState; // Use enrichedQueue if available (for UI), otherwise fall back to turnQueue const displayQueue = enrichedQueue || turnQueue || []; const threatLevel = this._getThreatLevel(); + // Calculate turn limit warning (less than 25% remaining) + const turnLimitWarning = + turnLimit && turnLimit.current / turnLimit.limit > 0.75; + return html`
@@ -605,6 +692,76 @@ export class CombatHUD extends LitElement {
+ + ${missionObjectives && + (missionObjectives.primary?.length > 0 || + missionObjectives.secondary?.length > 0) + ? html` +
+

OBJECTIVES

+ ${missionObjectives.primary?.map( + (obj) => html` +
+
+ ${obj.complete ? "✓ " : ""}${obj.description} +
+ ${obj.target_count && obj.current !== undefined + ? html` +
+ ${obj.current}/${obj.target_count} +
+ ` + : ""} + ${obj.type === "REACH_ZONE" && obj.zone_coords + ? html` +
+ Zones: ${obj.zone_coords.length} target${obj.zone_coords.length !== 1 ? "s" : ""} +
+ ` + : ""} +
+ ` + )} + ${missionObjectives.secondary?.length > 0 + ? html` +

+ SECONDARY +

+ ${missionObjectives.secondary.map( + (obj) => html` +
+
+ ${obj.complete ? "✓ " : ""}${obj.description} +
+
+ ` + )} + ` + : ""} +
+ ` + : ""} + + + ${turnLimit + ? html` +
+
TURN LIMIT
+
+ ${turnLimit.current}/${turnLimit.limit} +
+
+ ` + : ""} +
diff --git a/src/ui/components/mission-board.js b/src/ui/components/mission-board.js index bf615a7..c5ea6ff 100644 --- a/src/ui/components/mission-board.js +++ b/src/ui/components/mission-board.js @@ -1,6 +1,6 @@ import { LitElement, html, css } from 'lit'; import { gameStateManager } from '../../core/GameStateManager.js'; -import { theme, buttonStyles, cardStyles, gridStyles, badgeStyles } from '../styles/theme.js'; +import { theme, buttonStyles, cardStyles, gridStyles, badgeStyles, tabStyles, overlayStyles } from '../styles/theme.js'; /** * MissionBoard.js @@ -15,6 +15,8 @@ export class MissionBoard extends LitElement { cardStyles, gridStyles, badgeStyles, + tabStyles, + overlayStyles, css` :host { display: block; @@ -70,6 +72,13 @@ export class MissionBoard extends LitElement { opacity: 0.7; } + .mission-card.completed:hover { + border-color: var(--color-accent-green); + box-shadow: var(--shadow-glow-green); + transform: translateY(-2px); + cursor: pointer; + } + .mission-card.locked { opacity: 0.5; cursor: not-allowed; @@ -188,6 +197,34 @@ export class MissionBoard extends LitElement { padding: var(--spacing-2xl); color: var(--color-text-muted); } + + .section-header { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-accent-cyan); + margin: var(--spacing-xl) 0 var(--spacing-md) 0; + padding-bottom: var(--spacing-sm); + border-bottom: var(--border-width-thin) solid var(--color-border-default); + } + + .section-header:first-of-type { + margin-top: 0; + } + + .close-button { + background: transparent; + border: none; + color: var(--color-text-primary); + font-size: var(--font-size-2xl); + cursor: pointer; + padding: var(--spacing-xs); + line-height: 1; + transition: color var(--transition-normal); + } + + .close-button:hover { + color: var(--color-accent-red); + } ` ]; } @@ -196,6 +233,8 @@ export class MissionBoard extends LitElement { return { missions: { type: Array }, completedMissions: { type: Set }, + activeTab: { type: String }, + reviewMission: { type: Object }, }; } @@ -203,16 +242,46 @@ export class MissionBoard extends LitElement { super(); this.missions = []; this.completedMissions = new Set(); + this.activeTab = 'active'; + this.reviewMission = null; + this._isLoading = false; // Guard to prevent infinite loops } connectedCallback() { super.connectedCallback(); - this._loadMissions(); + // Load missions and refresh procedural missions only on first open + this._initialLoad(); // Listen for campaign data changes to refresh completed missions this._boundHandleCampaignChange = this._handleCampaignChange.bind(this); + // Listen for mission updates (new missions added, missions refreshed) + this._boundHandleMissionsUpdate = this._handleMissionsUpdate.bind(this); + window.addEventListener('campaign-data-changed', this._boundHandleCampaignChange); window.addEventListener('gamestate-changed', this._boundHandleCampaignChange); + window.addEventListener('missions-updated', this._boundHandleMissionsUpdate); + } + + async _initialLoad() { + // Guard to prevent multiple initial loads + if (this._isLoading) { + return; + } + this._isLoading = true; + + try { + // Ensure missions are loaded before accessing registry + await gameStateManager.missionManager._ensureMissionsLoaded(); + // Refresh procedural missions if unlocked (to ensure board is populated on first open) + // This only happens once when the board is first opened + if (gameStateManager.missionManager.areProceduralMissionsUnlocked()) { + gameStateManager.missionManager.refreshProceduralMissions(); + } + // Then load the missions directly (bypass guard since we're already in a guarded context) + await this._loadMissionsInternal(); + } finally { + this._isLoading = false; + } } disconnectedCallback() { @@ -221,6 +290,9 @@ export class MissionBoard extends LitElement { window.removeEventListener('campaign-data-changed', this._boundHandleCampaignChange); window.removeEventListener('gamestate-changed', this._boundHandleCampaignChange); } + if (this._boundHandleMissionsUpdate) { + window.removeEventListener('missions-updated', this._boundHandleMissionsUpdate); + } } _handleCampaignChange() { @@ -228,13 +300,34 @@ export class MissionBoard extends LitElement { this._loadMissions(); } + _handleMissionsUpdate() { + // Reload missions when new missions are added or missions are refreshed + // Use guard to prevent infinite loops + if (!this._isLoading) { + this._loadMissions(); + } + } + async _loadMissions() { + // Guard to prevent infinite loops + if (this._isLoading) { + return; + } + this._isLoading = true; + + try { + await this._loadMissionsInternal(); + } finally { + this._isLoading = false; + } + } + + async _loadMissionsInternal() { + // Internal method that actually loads missions (no guard check) // Ensure missions are loaded before accessing registry await gameStateManager.missionManager._ensureMissionsLoaded(); - // Refresh procedural missions if unlocked (to ensure board is populated) - if (gameStateManager.missionManager.areProceduralMissionsUnlocked()) { - gameStateManager.missionManager.refreshProceduralMissions(); - } + // Don't automatically refresh procedural missions here - that causes infinite loops + // Missions should be refreshed when the board is first opened, not on every update // Get all registered missions from MissionManager const missionRegistry = gameStateManager.missionManager.missionRegistry; this.missions = Array.from(missionRegistry.values()); @@ -300,6 +393,19 @@ export class MissionBoard extends LitElement { ); } + _reviewMission(mission) { + this.reviewMission = mission; + this.requestUpdate(); + } + + _closeReview(e) { + if (e) { + e.stopPropagation(); + } + this.reviewMission = null; + this.requestUpdate(); + } + _formatRewards(rewards) { const rewardItems = []; @@ -336,12 +442,110 @@ export class MissionBoard extends LitElement { return 'Unknown'; } + _getActiveMissions() { + return this.missions.filter(mission => { + if (!this._shouldShowMission(mission)) { + return false; + } + return !this._isMissionCompleted(mission.id); + }); + } + + _getCompletedMissions() { + return this.missions.filter(mission => { + if (!this._shouldShowMission(mission)) { + return false; + } + return this._isMissionCompleted(mission.id); + }); + } + + _getCompletedMissionsByType() { + const completed = this._getCompletedMissions(); + return { + story: completed.filter(m => m.type === 'STORY'), + sideQuest: completed.filter(m => m.type === 'SIDE_QUEST'), + other: completed.filter(m => m.type !== 'STORY' && m.type !== 'SIDE_QUEST'), + }; + } + + _renderMissionCard(mission) { + const isCompleted = this._isMissionCompleted(mission.id); + const isAvailable = this._isMissionAvailable(mission); + const rewards = this._formatRewards(mission.rewards?.guaranteed || mission.rewards || {}); + + return html` +
{ + if (isCompleted) { + this._reviewMission(mission); + } else if (isAvailable) { + this._selectMission(mission); + } + }} + > +
+

${mission.config?.title || mission.id}

+ + ${mission.type || 'PROCEDURAL'} + +
+ +

+ ${mission.config?.description || 'No description available.'} +

+ + ${rewards.length > 0 ? html` +
+ ${rewards.map((reward) => html` +
+ ${reward.icon} + ${reward.text} +
+ `)} +
+ ` : ''} + + +
+ `; + } + render() { if (this.missions.length === 0) { return html`

MISSION BOARD

-
@@ -351,6 +555,9 @@ export class MissionBoard extends LitElement { `; } + const activeMissions = this._getActiveMissions(); + const completedByType = this._getCompletedMissionsByType(); + return html`

MISSION BOARD

@@ -359,70 +566,83 @@ export class MissionBoard extends LitElement {
-
- ${this.missions - .filter(mission => this._shouldShowMission(mission)) - .map((mission) => { - const isCompleted = this._isMissionCompleted(mission.id); - const isAvailable = this._isMissionAvailable(mission); - const rewards = this._formatRewards(mission.rewards?.guaranteed || mission.rewards || {}); - - return html` -
isAvailable && this._selectMission(mission)} - > -
-

${mission.config?.title || mission.id}

- - ${mission.type || 'PROCEDURAL'} - -
- -

- ${mission.config?.description || 'No description available.'} -

- - ${rewards.length > 0 ? html` -
- ${rewards.map((reward) => html` -
- ${reward.icon} - ${reward.text} -
- `)} -
- ` : ''} - - -
- `; - })} +
+ +
+ +
+ ${this.activeTab === 'active' ? html` + ${activeMissions.length === 0 ? html` +
+

No active missions available at this time.

+
+ ` : html` +
+ ${activeMissions.map(mission => this._renderMissionCard(mission))} +
+ `} + ` : html` + ${completedByType.story.length === 0 && completedByType.sideQuest.length === 0 && completedByType.other.length === 0 ? html` +
+

No completed missions yet.

+
+ ` : html` + ${completedByType.story.length > 0 ? html` +

Story Missions

+
+ ${completedByType.story.map(mission => this._renderMissionCard(mission))} +
+ ` : ''} + ${completedByType.sideQuest.length > 0 ? html` +

Side Quests

+
+ ${completedByType.sideQuest.map(mission => this._renderMissionCard(mission))} +
+ ` : ''} + ${completedByType.other.length > 0 ? html` +

Other Missions

+
+ ${completedByType.other.map(mission => this._renderMissionCard(mission))} +
+ ` : ''} + `} + `} +
+ + ${this.reviewMission ? html` +
+
{ + e.stopPropagation(); + this._closeReview(e); + }}>
+
e.stopPropagation()}> + { + e.stopPropagation(); + this._closeReview(e); + }} + > +
+
+ ` : ''} `; } } diff --git a/src/ui/components/mission-review.js b/src/ui/components/mission-review.js new file mode 100644 index 0000000..a626394 --- /dev/null +++ b/src/ui/components/mission-review.js @@ -0,0 +1,444 @@ +import { LitElement, html, css } from 'lit'; +import { theme, buttonStyles, cardStyles, overlayStyles } from '../styles/theme.js'; + +/** + * MissionReview.js + * Component for reviewing completed missions, showing rewards and narrative. + * @class + */ +export class MissionReview extends LitElement { + static get styles() { + return [ + theme, + buttonStyles, + cardStyles, + overlayStyles, + css` + :host { + display: block; + background: var(--color-bg-secondary); + border: var(--border-width-medium) solid var(--color-border-default); + padding: var(--spacing-xl); + max-width: 900px; + max-height: 85vh; + overflow-y: auto; + color: var(--color-text-primary); + font-family: var(--font-family); + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-xl); + border-bottom: var(--border-width-medium) solid var(--color-border-default); + padding-bottom: var(--spacing-md); + } + + .header h2 { + margin: 0; + color: var(--color-accent-gold); + font-size: var(--font-size-3xl); + } + + .close-button { + background: transparent; + border: none; + color: var(--color-text-primary); + font-size: var(--font-size-2xl); + cursor: pointer; + padding: var(--spacing-xs); + line-height: 1; + transition: color var(--transition-normal); + } + + .close-button:hover { + color: var(--color-accent-red); + } + + .mission-info { + margin-bottom: var(--spacing-xl); + } + + .mission-title { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-accent-cyan); + margin-bottom: var(--spacing-sm); + } + + .mission-description { + font-size: var(--font-size-md); + color: var(--color-text-secondary); + line-height: 1.6; + margin-bottom: var(--spacing-lg); + } + + .section { + margin-bottom: var(--spacing-xl); + } + + .section-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-accent-cyan); + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: var(--border-width-thin) solid var(--color-border-default); + } + + .rewards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-md); + } + + .reward-item { + background: var(--color-bg-card); + border: var(--border-width-thin) solid var(--color-border-default); + padding: var(--spacing-md); + border-radius: var(--border-radius-md); + display: flex; + align-items: center; + gap: var(--spacing-sm); + } + + .reward-icon { + font-size: var(--font-size-2xl); + } + + .reward-text { + display: flex; + flex-direction: column; + } + + .reward-label { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-transform: uppercase; + } + + .reward-value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-accent-gold); + } + + .narrative-content { + background: var(--color-bg-card); + border: var(--border-width-thin) solid var(--color-border-default); + padding: var(--spacing-lg); + border-radius: var(--border-radius-md); + max-height: 400px; + overflow-y: auto; + } + + .narrative-node { + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-lg); + border-bottom: var(--border-width-thin) solid var(--color-border-default); + } + + .narrative-node:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; + } + + .narrative-speaker { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-accent-cyan); + margin-bottom: var(--spacing-xs); + } + + .narrative-text { + font-size: var(--font-size-md); + color: var(--color-text-primary); + line-height: 1.6; + font-style: italic; + } + + .narrative-portrait { + width: 48px; + height: 48px; + border-radius: var(--border-radius-md); + border: var(--border-width-thin) solid var(--color-border-default); + margin-bottom: var(--spacing-sm); + object-fit: cover; + } + + .loading { + text-align: center; + padding: var(--spacing-2xl); + color: var(--color-text-muted); + } + + .empty-narrative { + text-align: center; + padding: var(--spacing-lg); + color: var(--color-text-muted); + font-style: italic; + } + + .narrative-section { + margin-bottom: var(--spacing-xl); + } + + .narrative-section:last-child { + margin-bottom: 0; + } + ` + ]; + } + + static get properties() { + return { + mission: { type: Object }, + introNarrative: { type: Object }, + outroNarrative: { type: Object }, + loading: { type: Boolean }, + }; + } + + constructor() { + super(); + this.mission = null; + this.introNarrative = null; + this.outroNarrative = null; + this.loading = false; + } + + async connectedCallback() { + super.connectedCallback(); + if (this.mission) { + await this._loadNarratives(); + } + } + + async updated(changedProperties) { + if (changedProperties.has('mission') && this.mission) { + await this._loadNarratives(); + } + } + + _mapNarrativeIdToFileName(narrativeId) { + // Convert NARRATIVE_STORY_03_INTRO -> narrative_story_03_intro + // Remove NARRATIVE_ prefix and convert to lowercase + 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 + return narrativeId.toLowerCase().replace("narrative_", ""); + } + + async _loadNarratives() { + this.loading = true; + this.introNarrative = null; + this.outroNarrative = null; + + const narrative = this.mission?.narrative; + if (!narrative) { + this.loading = false; + return; + } + + const loadPromises = []; + + if (narrative.intro_sequence) { + loadPromises.push( + this._loadNarrativeFile(narrative.intro_sequence).then(data => { + this.introNarrative = data; + }).catch(err => { + console.error('Failed to load intro narrative:', err); + }) + ); + } + + if (narrative.outro_success) { + loadPromises.push( + this._loadNarrativeFile(narrative.outro_success).then(data => { + this.outroNarrative = data; + }).catch(err => { + console.error('Failed to load outro narrative:', err); + }) + ); + } + + await Promise.all(loadPromises); + this.loading = false; + this.requestUpdate(); + } + + async _loadNarrativeFile(narrativeId) { + const fileName = this._mapNarrativeIdToFileName(narrativeId); + const response = await fetch(`assets/data/narrative/${fileName}.json`); + if (!response.ok) { + throw new Error(`Failed to load narrative: ${fileName}`); + } + return await response.json(); + } + + _formatRewards(rewards) { + const rewardItems = []; + + if (rewards?.currency) { + const shards = rewards.currency.aether_shards || rewards.currency.aetherShards || 0; + const cores = rewards.currency.ancient_cores || rewards.currency.ancientCores || 0; + + if (shards > 0) { + rewardItems.push({ + icon: '💎', + label: 'Aether Shards', + value: shards + }); + } + if (cores > 0) { + rewardItems.push({ + icon: '⚙️', + label: 'Ancient Cores', + value: cores + }); + } + } + + if (rewards?.xp) { + rewardItems.push({ + icon: '⭐', + label: 'Experience', + value: rewards.xp + }); + } + + if (rewards?.unlocks && Array.isArray(rewards.unlocks)) { + rewards.unlocks.forEach(unlock => { + rewardItems.push({ + icon: '🔓', + label: 'Unlock', + value: unlock + }); + }); + } + + return rewardItems; + } + + _renderNarrativeNodes(nodes) { + if (!nodes || nodes.length === 0) { + return html`
No narrative content available.
`; + } + + return html` + ${nodes.map(node => html` +
+ ${node.portrait ? html` + ${node.speaker || 'Speaker'} { e.target.style.display = 'none'; }} + /> + ` : ''} + ${node.speaker ? html` +
${node.speaker}
+ ` : ''} +
${node.text || ''}
+
+ `)} + `; + } + + render() { + if (!this.mission) { + return html` +
+

Mission Review

+ +
+
+

No mission data available.

+
+ `; + } + + const rewards = this._formatRewards(this.mission.rewards?.guaranteed || this.mission.rewards || {}); + const config = this.mission.config || {}; + + return html` +
+

Mission Review

+ +
+ +
+
${config.title || this.mission.id}
+
${config.description || 'No description available.'}
+
+ + ${rewards.length > 0 ? html` +
+
Rewards
+
+ ${rewards.map(reward => html` +
+ ${reward.icon} +
+ ${reward.label} + ${reward.value} +
+
+ `)} +
+
+ ` : ''} + + ${this.loading ? html` +
+
Loading narrative content...
+
+ ` : html` + ${this.introNarrative || this.outroNarrative ? html` +
+
Narrative
+
+ ${this.introNarrative ? html` +
+

+ Mission Briefing +

+ ${this._renderNarrativeNodes(this.introNarrative.nodes)} +
+ ` : ''} + ${this.outroNarrative ? html` +
+

+ Mission Conclusion +

+ ${this._renderNarrativeNodes(this.outroNarrative.nodes)} +
+ ` : ''} +
+
+ ` : ''} + `} + `; + } +} + +customElements.define('mission-review', MissionReview); + diff --git a/src/ui/game-viewport.js b/src/ui/game-viewport.js index ffc34f0..7ed5fd3 100644 --- a/src/ui/game-viewport.js +++ b/src/ui/game-viewport.js @@ -26,6 +26,7 @@ export class GameViewport extends LitElement { deployedIds: { type: Array }, combatState: { type: Object }, missionDef: { type: Object }, + debriefResult: { type: Object }, }; } @@ -35,6 +36,7 @@ export class GameViewport extends LitElement { this.deployedIds = []; this.combatState = null; this.missionDef = null; + this.debriefResult = null; // Set up event listeners early so we don't miss events this.#setupCombatStateUpdates(); @@ -126,12 +128,25 @@ export class GameViewport extends LitElement { // Listen for mission end events to clear state window.addEventListener("mission-victory", () => { - this.#clearState(); + // Don't clear state immediately - wait for debrief }); window.addEventListener("mission-failure", () => { this.#clearState(); }); + // Listen for show-debrief event + window.addEventListener("show-debrief", async (e) => { + // Dynamically import MissionDebrief when needed + await import("./screens/MissionDebrief.js"); + this.debriefResult = e.detail.result; + }); + + // Listen for debrief-closed event + window.addEventListener("debrief-closed", () => { + this.debriefResult = null; + this.#clearState(); + }); + // Initial updates this.#updateCombatState(); this.#updateSquad(); @@ -187,7 +202,17 @@ export class GameViewport extends LitElement { @skill-click=${this.#handleSkillClick} @movement-click=${this.#handleMovementClick} > - `; + + ${this.debriefResult + ? html` { + window.dispatchEvent( + new CustomEvent("debrief-closed", { bubbles: true, composed: true }) + ); + }} + >` + : html``}`; } } diff --git a/src/ui/screens/MissionDebrief.js b/src/ui/screens/MissionDebrief.js index ed7a629..f70c672 100644 --- a/src/ui/screens/MissionDebrief.js +++ b/src/ui/screens/MissionDebrief.js @@ -460,12 +460,19 @@ export class MissionDebrief extends LitElement { if (dialog) { dialog.close(); } + // Dispatch both events for compatibility this.dispatchEvent( new CustomEvent("return-to-hub", { bubbles: true, composed: true, }) ); + window.dispatchEvent( + new CustomEvent("debrief-closed", { + bubbles: true, + composed: true, + }) + ); } render() { diff --git a/test/core/GameLoop/combat-skill-targeting.test.js b/test/core/GameLoop/combat-skill-targeting.test.js index fa2e2a1..08a1dc7 100644 --- a/test/core/GameLoop/combat-skill-targeting.test.js +++ b/test/core/GameLoop/combat-skill-targeting.test.js @@ -406,6 +406,71 @@ describe("Core: GameLoop - Combat Skill Targeting and Execution", function () { } }); + it("should dispatch UNIT_MOVE event to MissionManager when teleporting", async () => { + const skillId = "SKILL_TELEPORT"; + const skillDef = { + id: skillId, + name: "Phase Shift", + costs: { ap: 2 }, + targeting: { + range: 5, + type: "EMPTY", + line_of_sight: false, + }, + effects: [{ type: "TELEPORT" }], + }; + + skillRegistry.skills.set(skillId, skillDef); + + if (!playerUnit.actions) { + playerUnit.actions = []; + } + playerUnit.actions.push({ + id: skillId, + name: "Phase Shift", + costAP: 2, + cooldown: 0, + }); + + const originalPos = { ...playerUnit.position }; + const targetPos = { + x: originalPos.x + 3, + y: originalPos.y, + z: originalPos.z + 3, + }; + + // Ensure target position is valid and empty + if (gameLoop.grid.isOccupied(targetPos)) { + const unitAtPos = gameLoop.grid.getUnitAt(targetPos); + if (unitAtPos) { + gameLoop.grid.removeUnit(unitAtPos); + } + } + + // Ensure target position is walkable + gameLoop.grid.setCell(targetPos.x, 0, targetPos.z, 1); // Floor + gameLoop.grid.setCell(targetPos.x, targetPos.y, targetPos.z, 0); // Air + gameLoop.grid.setCell(targetPos.x, targetPos.y + 1, targetPos.z, 0); // Air above + + // Set up MissionManager spy + const onGameEventSpy = sinon.spy(); + if (gameLoop.missionManager) { + gameLoop.missionManager.onGameEvent = onGameEventSpy; + } + + await gameLoop.executeSkill(skillId, targetPos); + + // Verify UNIT_MOVE event was dispatched + expect(onGameEventSpy.called).to.be.true; + const unitMoveCall = onGameEventSpy.getCalls().find( + (call) => call.args[0] === "UNIT_MOVE" + ); + expect(unitMoveCall).to.not.be.undefined; + expect(unitMoveCall.args[1].unitId).to.equal(playerUnit.id); + expect(unitMoveCall.args[1].position.x).to.equal(targetPos.x); + expect(unitMoveCall.args[1].position.z).to.equal(targetPos.z); + }); + it("should deduct AP when executing TELEPORT skill", async () => { const skillId = "SKILL_TELEPORT"; const skillDef = { diff --git a/test/managers/MissionManager.test.js b/test/managers/MissionManager.test.js index 4c1bd92..3643cf9 100644 --- a/test/managers/MissionManager.test.js +++ b/test/managers/MissionManager.test.js @@ -403,6 +403,87 @@ describe("Manager: MissionManager", () => { expect(manager.currentObjectives[0].complete).to.be.true; }); + it("CoA 19b: Should track progress for REACH_ZONE objective with multiple zones", async () => { + await manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "REACH_ZONE", + target_count: 3, + zone_coords: [ + { x: 5, y: 0, z: 5 }, + { x: 10, y: 0, z: 10 }, + { x: 15, y: 0, z: 15 }, + ], + current: 0, + complete: false, + }, + ]; + + // Reach first zone + manager.onGameEvent("UNIT_MOVE", { + position: { x: 5, y: 0, z: 5 }, + }); + expect(manager.currentObjectives[0].current).to.equal(1); + expect(manager.currentObjectives[0].complete).to.be.false; + expect(manager.currentObjectives[0].zone_coords.length).to.equal(2); + + // Reach second zone + manager.onGameEvent("UNIT_MOVE", { + position: { x: 10, y: 0, z: 10 }, + }); + expect(manager.currentObjectives[0].current).to.equal(2); + expect(manager.currentObjectives[0].complete).to.be.false; + expect(manager.currentObjectives[0].zone_coords.length).to.equal(1); + + // Reach third zone - should complete + manager.onGameEvent("UNIT_MOVE", { + position: { x: 15, y: 0, z: 15 }, + }); + expect(manager.currentObjectives[0].current).to.equal(3); + expect(manager.currentObjectives[0].complete).to.be.true; + expect(manager.currentObjectives[0].zone_coords.length).to.equal(0); + }); + + it("CoA 19c: Should handle Y-level variance when matching zones (for teleport)", async () => { + await manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "REACH_ZONE", + zone_coords: [{ x: 5, y: 1, z: 5 }], + complete: false, + }, + ]; + + // Teleport might place unit at slightly different Y level + manager.onGameEvent("UNIT_MOVE", { + position: { x: 5, y: 0, z: 5 }, // Y differs by 1 + }); + + expect(manager.currentObjectives[0].complete).to.be.true; + }); + + it("CoA 19d: Should complete REACH_ZONE objective when teleporting to target zone", async () => { + await manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "REACH_ZONE", + target_count: 1, + zone_coords: [{ x: 10, y: 1, z: 10 }], + current: 0, + complete: false, + }, + ]; + + // Simulate teleport to zone (UNIT_MOVE event with position matching zone) + manager.onGameEvent("UNIT_MOVE", { + unitId: "UNIT_TEST", + position: { x: 10, y: 1, z: 10 }, + }); + + expect(manager.currentObjectives[0].complete).to.be.true; + expect(manager.currentObjectives[0].current).to.equal(1); + }); + it("CoA 20: Should complete INTERACT objective when unit interacts with target object", async () => { await manager.setupActiveMission(); manager.currentObjectives = [