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.
This commit is contained in:
Matthew Mone 2026-01-01 21:02:37 -08:00
parent 63bfb7da31
commit 0f4210d5c4
15 changed files with 2003 additions and 124 deletions

View file

@ -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("");

View file

@ -89,6 +89,8 @@ export class GameLoop {
/** @type {Map<string, Position>} */
this.missionObjects = new Map(); // object_id -> position
/** @type {Set<THREE.Mesh>} */
this.zoneMarkers = new Set(); // Visual markers for REACH_ZONE objectives
/** @type {Set<THREE.Mesh>} */
this.movementHighlights = new Set();
/** @type {Set<THREE.Mesh>} */
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
);
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();

View file

@ -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 ---
/**

View file

@ -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",

View file

@ -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) {
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) {
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();

View file

@ -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);

View file

@ -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<EnemySpawn>} 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

View file

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

View file

@ -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`
<!-- Top Bar -->
<div class="top-bar">
@ -605,6 +692,76 @@ export class CombatHUD extends LitElement {
</div>
</div>
<!-- Objectives Panel -->
${missionObjectives &&
(missionObjectives.primary?.length > 0 ||
missionObjectives.secondary?.length > 0)
? html`
<div class="objectives-panel">
<h3>OBJECTIVES</h3>
${missionObjectives.primary?.map(
(obj) => html`
<div
class="objective-item ${obj.complete ? "complete" : ""}"
>
<div class="objective-description">
${obj.complete ? "✓ " : ""}${obj.description}
</div>
${obj.target_count && obj.current !== undefined
? html`
<div class="objective-progress">
${obj.current}/${obj.target_count}
</div>
`
: ""}
${obj.type === "REACH_ZONE" && obj.zone_coords
? html`
<div class="objective-progress">
Zones: ${obj.zone_coords.length} target${obj.zone_coords.length !== 1 ? "s" : ""}
</div>
`
: ""}
</div>
`
)}
${missionObjectives.secondary?.length > 0
? html`
<h3 style="margin-top: var(--spacing-md);">
SECONDARY
</h3>
${missionObjectives.secondary.map(
(obj) => html`
<div
class="objective-item ${obj.complete
? "complete"
: ""}"
>
<div class="objective-description">
${obj.complete ? "✓ " : ""}${obj.description}
</div>
</div>
`
)}
`
: ""}
</div>
`
: ""}
<!-- Turn Limit Indicator -->
${turnLimit
? html`
<div class="turn-limit-indicator">
<div class="turn-limit-label">TURN LIMIT</div>
<div
class="turn-limit-value ${turnLimitWarning ? "warning" : ""}"
>
${turnLimit.current}/${turnLimit.limit}
</div>
</div>
`
: ""}
<!-- Bottom Bar -->
<div class="bottom-bar">
<!-- Unit Status (Bottom-Left) -->

View file

@ -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,33 +442,34 @@ export class MissionBoard extends LitElement {
return 'Unknown';
}
render() {
if (this.missions.length === 0) {
return html`
<div class="header">
<h2>MISSION BOARD</h2>
<button class="btn btn-close" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
×
</button>
</div>
<div class="empty-state">
<p>No missions available at this time.</p>
</div>
`;
_getActiveMissions() {
return this.missions.filter(mission => {
if (!this._shouldShowMission(mission)) {
return false;
}
return !this._isMissionCompleted(mission.id);
});
}
return html`
<div class="header">
<h2>MISSION BOARD</h2>
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
×
</button>
</div>
_getCompletedMissions() {
return this.missions.filter(mission => {
if (!this._shouldShowMission(mission)) {
return false;
}
return this._isMissionCompleted(mission.id);
});
}
<div class="missions-grid">
${this.missions
.filter(mission => this._shouldShowMission(mission))
.map((mission) => {
_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 || {});
@ -370,7 +477,13 @@ export class MissionBoard extends LitElement {
return html`
<div
class="mission-card ${isCompleted ? 'completed' : ''} ${!isAvailable ? 'locked' : ''}"
@click=${() => isAvailable && this._selectMission(mission)}
@click=${() => {
if (isCompleted) {
this._reviewMission(mission);
} else if (isAvailable) {
this._selectMission(mission);
}
}}
>
<div class="mission-header">
<h3 class="mission-title">${mission.config?.title || mission.id}</h3>
@ -398,7 +511,11 @@ export class MissionBoard extends LitElement {
<span class="difficulty">
Difficulty: ${this._getDifficultyLabel(mission.config)}
</span>
${isCompleted ? html`<span style="color: var(--color-accent-green);">✓ Completed</span>` : ''}
${isCompleted ? html`
<span style="color: var(--color-accent-green); cursor: pointer;" title="Click to review mission">
Completed - Click to Review
</span>
` : ''}
${!isAvailable && !isCompleted ? html`
<span style="color: var(--color-text-muted); font-size: var(--font-size-xs);">
🔒 Requires: ${mission.config?.prerequisites?.map(id => {
@ -421,8 +538,111 @@ export class MissionBoard extends LitElement {
</div>
</div>
`;
})}
}
render() {
if (this.missions.length === 0) {
return html`
<div class="header">
<h2>MISSION BOARD</h2>
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
×
</button>
</div>
<div class="empty-state">
<p>No missions available at this time.</p>
</div>
`;
}
const activeMissions = this._getActiveMissions();
const completedByType = this._getCompletedMissionsByType();
return html`
<div class="header">
<h2>MISSION BOARD</h2>
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
×
</button>
</div>
<div class="tabs">
<button
class="tab-button ${this.activeTab === 'active' ? 'active' : ''}"
@click=${() => {
this.activeTab = 'active';
this.requestUpdate();
}}
>
Active
</button>
<button
class="tab-button ${this.activeTab === 'completed' ? 'active' : ''}"
@click=${() => {
this.activeTab = 'completed';
this.requestUpdate();
}}
>
Completed
</button>
</div>
<div class="tab-content">
${this.activeTab === 'active' ? html`
${activeMissions.length === 0 ? html`
<div class="empty-state">
<p>No active missions available at this time.</p>
</div>
` : html`
<div class="missions-grid">
${activeMissions.map(mission => this._renderMissionCard(mission))}
</div>
`}
` : html`
${completedByType.story.length === 0 && completedByType.sideQuest.length === 0 && completedByType.other.length === 0 ? html`
<div class="empty-state">
<p>No completed missions yet.</p>
</div>
` : html`
${completedByType.story.length > 0 ? html`
<h3 class="section-header">Story Missions</h3>
<div class="missions-grid">
${completedByType.story.map(mission => this._renderMissionCard(mission))}
</div>
` : ''}
${completedByType.sideQuest.length > 0 ? html`
<h3 class="section-header">Side Quests</h3>
<div class="missions-grid">
${completedByType.sideQuest.map(mission => this._renderMissionCard(mission))}
</div>
` : ''}
${completedByType.other.length > 0 ? html`
<h3 class="section-header">Other Missions</h3>
<div class="missions-grid">
${completedByType.other.map(mission => this._renderMissionCard(mission))}
</div>
` : ''}
`}
`}
</div>
${this.reviewMission ? html`
<div class="overlay-container active">
<div class="overlay-backdrop" @click=${(e) => {
e.stopPropagation();
this._closeReview(e);
}}></div>
<div class="overlay-content" @click=${(e) => e.stopPropagation()}>
<mission-review
.mission=${this.reviewMission}
@review-close=${(e) => {
e.stopPropagation();
this._closeReview(e);
}}
></mission-review>
</div>
</div>
` : ''}
`;
}
}

View file

@ -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`<div class="empty-narrative">No narrative content available.</div>`;
}
return html`
${nodes.map(node => html`
<div class="narrative-node">
${node.portrait ? html`
<img
src="${node.portrait}"
alt="${node.speaker || 'Speaker'}"
class="narrative-portrait"
@error=${(e) => { e.target.style.display = 'none'; }}
/>
` : ''}
${node.speaker ? html`
<div class="narrative-speaker">${node.speaker}</div>
` : ''}
<div class="narrative-text">${node.text || ''}</div>
</div>
`)}
`;
}
render() {
if (!this.mission) {
return html`
<div class="header">
<h2>Mission Review</h2>
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
×
</button>
</div>
<div class="loading">
<p>No mission data available.</p>
</div>
`;
}
const rewards = this._formatRewards(this.mission.rewards?.guaranteed || this.mission.rewards || {});
const config = this.mission.config || {};
return html`
<div class="header">
<h2>Mission Review</h2>
<button class="close-button" @click=${(e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent('review-close', { bubbles: false, composed: true }));
}}>
×
</button>
</div>
<div class="mission-info">
<div class="mission-title">${config.title || this.mission.id}</div>
<div class="mission-description">${config.description || 'No description available.'}</div>
</div>
${rewards.length > 0 ? html`
<div class="section">
<div class="section-title">Rewards</div>
<div class="rewards-grid">
${rewards.map(reward => html`
<div class="reward-item">
<span class="reward-icon">${reward.icon}</span>
<div class="reward-text">
<span class="reward-label">${reward.label}</span>
<span class="reward-value">${reward.value}</span>
</div>
</div>
`)}
</div>
</div>
` : ''}
${this.loading ? html`
<div class="section">
<div class="loading">Loading narrative content...</div>
</div>
` : html`
${this.introNarrative || this.outroNarrative ? html`
<div class="section">
<div class="section-title">Narrative</div>
<div class="narrative-content">
${this.introNarrative ? html`
<div class="narrative-section">
<h4 style="color: var(--color-accent-cyan); margin-bottom: var(--spacing-md); font-size: var(--font-size-lg);">
Mission Briefing
</h4>
${this._renderNarrativeNodes(this.introNarrative.nodes)}
</div>
` : ''}
${this.outroNarrative ? html`
<div class="narrative-section">
<h4 style="color: var(--color-accent-cyan); margin-bottom: var(--spacing-md); font-size: var(--font-size-lg);">
Mission Conclusion
</h4>
${this._renderNarrativeNodes(this.outroNarrative.nodes)}
</div>
` : ''}
</div>
</div>
` : ''}
`}
`;
}
}
customElements.define('mission-review', MissionReview);

View file

@ -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}
></combat-hud>
<dialogue-overlay></dialogue-overlay>`;
<dialogue-overlay></dialogue-overlay>
${this.debriefResult
? html`<mission-debrief
.result=${this.debriefResult}
@return-to-hub=${() => {
window.dispatchEvent(
new CustomEvent("debrief-closed", { bubbles: true, composed: true })
);
}}
></mission-debrief>`
: html``}`;
}
}

View file

@ -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() {

View file

@ -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 = {

View file

@ -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 = [