From a7c60ac56d7032724132df20abc02a9059a35eac Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Thu, 1 Jan 2026 09:18:09 -0800 Subject: [PATCH] Implement Research system and enhance mission management - Introduce the ResearchManager to manage tech trees, node unlocking, and passive effects, enhancing gameplay depth. - Update GameStateManager to integrate the ResearchManager, ensuring seamless data handling for research states. - Implement lazy loading for mission definitions and class data to improve performance and resource management. - Enhance UI components, including the ResearchScreen and MissionBoard, to support new research features and mission prerequisites. - Add comprehensive tests for the ResearchManager and related UI components to validate functionality and integration within the game architecture. --- .cursor/rules/RULE.md | 1 + .cursor/rules/ui/RULE.md | 1 + specs/Research.spec.md | 105 ++ src/assets/data/missions/mission.d.ts | 8 + .../data/missions/mission_story_02.json | 55 ++ .../data/missions/mission_story_03.json | 59 ++ src/assets/data/narrative/story_02_intro.json | 29 + src/assets/data/narrative/story_02_outro.json | 22 + src/assets/data/narrative/story_03_intro.json | 21 + src/assets/data/narrative/story_03_mid.json | 13 + src/assets/data/narrative/story_03_outro.json | 21 + src/core/DebugCommands.js | 911 ++++++++++++++++++ src/core/GameLoop.js | 508 ++++++---- src/core/GameStateManager.js | 24 +- src/core/Persistence.js | 30 +- src/index.js | 90 ++ src/managers/ItemRegistry.js | 4 +- src/managers/MissionManager.js | 61 +- src/managers/ResearchManager.js | 397 ++++++++ src/ui/components/mission-board.js | 53 +- src/ui/game-viewport.js | 16 +- src/ui/screens/ResearchScreen.js | 712 ++++++++++++++ src/ui/screens/hub-screen.js | 17 +- src/ui/team-builder.js | 79 +- test/managers/MissionManager.test.js | 63 +- test/ui/mission-board.test.js | 243 +++++ 26 files changed, 3312 insertions(+), 231 deletions(-) create mode 100644 specs/Research.spec.md create mode 100644 src/assets/data/missions/mission_story_02.json create mode 100644 src/assets/data/missions/mission_story_03.json create mode 100644 src/assets/data/narrative/story_02_intro.json create mode 100644 src/assets/data/narrative/story_02_outro.json create mode 100644 src/assets/data/narrative/story_03_intro.json create mode 100644 src/assets/data/narrative/story_03_mid.json create mode 100644 src/assets/data/narrative/story_03_outro.json create mode 100644 src/core/DebugCommands.js create mode 100644 src/managers/ResearchManager.js create mode 100644 src/ui/screens/ResearchScreen.js diff --git a/.cursor/rules/RULE.md b/.cursor/rules/RULE.md index c4a8719..2acd6c7 100644 --- a/.cursor/rules/RULE.md +++ b/.cursor/rules/RULE.md @@ -35,6 +35,7 @@ alwaysApply: true ## **Coding Style** - Use ES6 Modules (import/export). +- Lazy load at need, only staticly import if something is needed at load, prior to user interaction. - Prefer const over let. No var. - Use JSDoc for all public methods and complex algorithms. - **No Circular Dependencies:** Managers should not import GameLoop. GameLoop acts as the orchestrator. diff --git a/.cursor/rules/ui/RULE.md b/.cursor/rules/ui/RULE.md index 029012b..e10d3e7 100644 --- a/.cursor/rules/ui/RULE.md +++ b/.cursor/rules/ui/RULE.md @@ -11,6 +11,7 @@ alwaysApply: false - Use **LitElement** for all UI components. - Filename should match the component name (kebab-case) - Styles must be scoped within static get styles(). +- Use theme styles where applicable ## **Integration Logic** diff --git a/specs/Research.spec.md b/specs/Research.spec.md new file mode 100644 index 0000000..7ee43fe --- /dev/null +++ b/specs/Research.spec.md @@ -0,0 +1,105 @@ +# **Research Specification: The Ancient Archive** + +This document defines the UI and Logic for the Research Facility. This is the primary sink for **Ancient Cores** (Rare Currency) and provides global, permanent buffs to the campaign. + +## **1. Context & Unlock** + +What is it? +A facility where the player studies recovered technology and magical theory to improve the efficiency of their operation. Unlike Class Mastery (which buffs specific units), Research buffs the player's infrastructure. +**How is it unlocked?** + +- **Requirement:** Complete Story Mission 3 ("The Buried Library"). +- **Narrative Event:** The player recovers a damaged "Archive Core" AI. Installing it in the Hub opens the Research tab. + +## **2. Visual Design & Layout** + +**Setting:** A cluttered corner of the camp filled with glowing blue hologram projectors and piles of scrolls. + +- **Vibe:** Intellectual, mystical, high-tech. + +**Layout:** + +- **Header:** "Ancient Archive". Displays **Ancient Cores** count (Large). +- **Main View (The Tree):** A horizontal scrolling view of 3 distinct "Tech Trees". + 1. **Logistics (Green):** Economic and Roster upgrades. + 2. **Intelligence (Blue):** Map and Enemy info upgrades. + 3. **Field Ops (Red):** Combat preparation and starting bonuses. +- **Inspector Panel (Right):** + - Selected Node Name & Icon. + - Description ("Increases Roster Size by 2"). + - Cost ("2 Ancient Cores"). + - Status ("Locked", "Available", "Researched"). + - Button: "RESEARCH". + +## **3. The Tech Trees** + +### **A. Logistics (Economy & Management)** + +1. **Expanded Barracks I:** Roster Limit +2. +2. **Bulk Contracts:** Recruiting cost -10%. +3. **Expanded Barracks II:** Roster Limit +4. +4. **Deep Pockets:** Market Buyback slots +2. +5. **Salvage Protocol:** Sell prices at Market +10%. + +### **B. Intelligence (Information Warfare)** + +1. **Scout Drone:** Reveals the biome type of Side Missions before accepting them. +2. **Vital Sensors:** Enemy Health Bars show exact numbers instead of just bars. +3. **Threat Analysis:** Reveals Enemy Types (e.g. "Mechanical") on the Mission Board dossier. +4. **Map Data:** Reveals the location of the Boss Room on the minimap at start of run. + +### **C. Field Ops (Combat Prep)** + +1. **Supply Drop:** Start every run with 1 Free Potion in the shared stash. +2. **Fast Deploy:** First turn of combat grants +1 AP to all units. +3. **Emergency Beacon:** "Retreat" option becomes available (Escape with 50% loot retention instead of 0%). +4. **Hardened Steel:** All crafted/bought weapons start with +1 Damage. + +## **4. TypeScript Interfaces** + +```ts +// src/types/Research.ts + +export type ResearchTreeType = "LOGISTICS" | "INTEL" | "FIELD_OPS"; + +export interface ResearchNode { + id: string; // e.g. "RES_LOGISTICS_01" + name: string; + description: string; + tree: ResearchTreeType; + tier: number; // Depth in the tree (1-5) + + cost: number; // Ancient Cores + + /** IDs of parent nodes that must be unlocked first */ + prerequisites: string[]; + + /** The actual effect applied to the game state */ + effect: { + type: "ROSTER_LIMIT" | "MARKET_DISCOUNT" | "STARTING_ITEM" | "UI_UNLOCK"; + value: number | string; + }; +} + +export interface ResearchState { + unlockedNodeIds: string[]; // List of IDs player has bought + availableCores: number; +} +``` + +## **5. Conditions of Acceptance (CoA)** + +**CoA 1: Dependency Logic** + +- A node cannot be researched unless _all_ its prerequisites are in the unlockedNodeIds list. +- The UI must visually distinguish between Locked (Grey), Available (Lit), and Completed (Gold). + +**CoA 2: Currency Consumption** + +- Researching a node must deduct the exact Ancient Core cost from the global persistence. +- The action must fail if availableCores < cost. + +**CoA** 3: Effect **Application** + +- **Passive:** "Expanded Barracks" must immediately update the RosterManager.rosterLimit. +- **Runtime:** "Supply Drop" must trigger a check in GameLoop.startLevel to insert the item. diff --git a/src/assets/data/missions/mission.d.ts b/src/assets/data/missions/mission.d.ts index 7dab702..1c815eb 100644 --- a/src/assets/data/missions/mission.d.ts +++ b/src/assets/data/missions/mission.d.ts @@ -41,6 +41,14 @@ export interface MissionConfig { recommended_level?: number; /** Path to icon image */ icon?: string; + /** List of mission IDs that must be completed before this mission is available */ + prerequisites?: string[]; + /** + * Controls visibility when prerequisites are not met. + * - "hidden": Mission is completely hidden until prerequisites are met (default for STORY) + * - "locked": Mission shows as locked with requirements visible (default for SIDE_QUEST, PROCEDURAL) + */ + visibility_when_locked?: "hidden" | "locked"; } // --- BIOME / WORLD GEN --- diff --git a/src/assets/data/missions/mission_story_02.json b/src/assets/data/missions/mission_story_02.json new file mode 100644 index 0000000..8840f04 --- /dev/null +++ b/src/assets/data/missions/mission_story_02.json @@ -0,0 +1,55 @@ +{ + "id": "MISSION_STORY_02", + "type": "STORY", + "config": { + "title": "The Signal", + "description": "A subspace relay in the Fungal Caves is jamming trade routes. Clear the interference.", + "difficulty_tier": 1, + "recommended_level": 2, + "icon": "assets/icons/mission_signal.png" + }, + "biome": { + "type": "BIOME_FUNGAL_CAVES", + "generator_config": { + "seed_type": "RANDOM", + "size": { "x": 22, "y": 8, "z": 22 }, + "room_count": 5, + "density": "MEDIUM" + }, + "hazards": ["HAZARD_POISON_SPORES"] + }, + "deployment": { + "squad_size_limit": 4 + }, + "narrative": { + "intro_sequence": "NARRATIVE_STORY_02_INTRO", + "outro_success": "NARRATIVE_STORY_02_OUTRO" + }, + "objectives": { + "primary": [ + { + "id": "OBJ_FIX_RELAY", + "type": "INTERACT", + "target_object_id": "OBJ_SIGNAL_RELAY", + "description": "Reboot the Ancient Signal Relay." + } + ], + "secondary": [ + { + "id": "OBJ_CLEAR_INFESTATION", + "type": "ELIMINATE_ALL", + "description": "Clear all Shardborn from the relay chamber." + } + ] + }, + "rewards": { + "guaranteed": { + "xp": 200, + "currency": { "aether_shards": 150 }, + "unlocks": ["CLASS_SCAVENGER"] + }, + "faction_reputation": { + "GOLDEN_EXCHANGE": 25 + } + } +} diff --git a/src/assets/data/missions/mission_story_03.json b/src/assets/data/missions/mission_story_03.json new file mode 100644 index 0000000..3d8333c --- /dev/null +++ b/src/assets/data/missions/mission_story_03.json @@ -0,0 +1,59 @@ +{ + "id": "MISSION_STORY_03", + "type": "STORY", + "config": { + "title": "The Buried Library", + "description": "Recover ancient data from the Crystal Spires. Beware of unstable platforms.", + "difficulty_tier": 2, + "recommended_level": 3, + "icon": "assets/icons/mission_library.png", + "prerequisites": ["MISSION_STORY_02"] + }, + "biome": { + "type": "BIOME_CRYSTAL_SPIRES", + "generator_config": { + "seed_type": "RANDOM", + "size": { "x": 16, "y": 12, "z": 16 }, + "room_count": 0, + "density": "LOW" + }, + "hazards": ["HAZARD_GRAVITY_FLUX"] + }, + "deployment": { + "squad_size_limit": 4 + }, + "narrative": { + "intro_sequence": "NARRATIVE_STORY_03_INTRO", + "outro_success": "NARRATIVE_STORY_03_OUTRO", + "scripted_events": [ + { + "trigger": "ON_TURN_START", + "turn_index": 2, + "action": "PLAY_SEQUENCE", + "sequence_id": "NARRATIVE_STORY_03_MID" + } + ] + }, + "objectives": { + "primary": [ + { + "id": "OBJ_RECOVER_DATA", + "type": "INTERACT", + "target_object_id": "OBJ_DATA_TERMINAL", + "target_count": 3, + "description": "Recover 3 Data Fragments from the floating islands." + } + ], + "failure_conditions": [{ "type": "SQUAD_WIPE" }] + }, + "rewards": { + "guaranteed": { + "xp": 350, + "currency": { "aether_shards": 200, "ancient_cores": 2 }, + "unlocks": ["CLASS_CUSTODIAN"] + }, + "faction_reputation": { + "ARCANE_DOMINION": 30 + } + } +} diff --git a/src/assets/data/narrative/story_02_intro.json b/src/assets/data/narrative/story_02_intro.json new file mode 100644 index 0000000..0a4acd7 --- /dev/null +++ b/src/assets/data/narrative/story_02_intro.json @@ -0,0 +1,29 @@ +{ + "id": "NARRATIVE_STORY_02_INTRO", + "nodes": [ + { + "id": "1", + "type": "DIALOGUE", + "speaker": "Baroness Seraphina", + "portrait": "assets/images/portraits/scavenger.png", + "text": "Ah, the 'heroes' Vorn speaks so highly of. I am Baroness Seraphina, representing the Golden Exchange.", + "next": "2" + }, + { + "id": "2", + "type": "DIALOGUE", + "speaker": "Baroness Seraphina", + "portrait": "assets/images/portraits/scavenger.png", + "text": "My airships are navigating blind. Something in the Fungal Caves is scattering our comms signals. It's bad for business.", + "next": "3" + }, + { + "id": "3", + "type": "DIALOGUE", + "speaker": "Baroness Seraphina", + "portrait": "assets/images/portraits/scavenger.png", + "text": "Locate the relay node and reboot it. Do that, and I'll open my personal stocks to you.", + "next": "END" + } + ] +} diff --git a/src/assets/data/narrative/story_02_outro.json b/src/assets/data/narrative/story_02_outro.json new file mode 100644 index 0000000..f44a282 --- /dev/null +++ b/src/assets/data/narrative/story_02_outro.json @@ -0,0 +1,22 @@ +{ + "id": "NARRATIVE_STORY_02_OUTRO", + "nodes": [ + { + "id": "1", + "type": "DIALOGUE", + "speaker": "Baroness Seraphina", + "portrait": "assets/images/portraits/scavenger.png", + "text": "Signal is green. My ships are flying again. You're useful... for mercenaries.", + "next": "2" + }, + { + "id": "2", + "type": "DIALOGUE", + "speaker": "Baroness Seraphina", + "portrait": "assets/images/portraits/scavenger.png", + "text": "I've unlocked the Marketplace terminal for you. Don't expect a discount.", + "trigger": { "type": "UNLOCK_CLASS", "class_id": "CLASS_SCAVENGER" }, + "next": "END" + } + ] +} diff --git a/src/assets/data/narrative/story_03_intro.json b/src/assets/data/narrative/story_03_intro.json new file mode 100644 index 0000000..b003f2b --- /dev/null +++ b/src/assets/data/narrative/story_03_intro.json @@ -0,0 +1,21 @@ +{ + "id": "NARRATIVE_STORY_03_INTRO", + "nodes": [ + { + "id": "1", + "type": "DIALOGUE", + "speaker": "Arch-Librarian Elara", + "portrait": "assets/images/portraits/weaver.png", + "text": "The signal you cleared revealed coordinates to a pre-collapse data center. We call it 'The Library'.", + "next": "2" + }, + { + "id": "2", + "type": "DIALOGUE", + "speaker": "Arch-Librarian Elara", + "portrait": "assets/images/portraits/weaver.png", + "text": "It is located in the Crystal Spires. The gravity there is... fluid. Watch your step.", + "next": "END" + } + ] +} diff --git a/src/assets/data/narrative/story_03_mid.json b/src/assets/data/narrative/story_03_mid.json new file mode 100644 index 0000000..b249ed0 --- /dev/null +++ b/src/assets/data/narrative/story_03_mid.json @@ -0,0 +1,13 @@ +{ + "id": "NARRATIVE_STORY_03_MID", + "nodes": [ + { + "id": "1", + "type": "TUTORIAL", + "speaker": "Tactical OS", + "text": "Verticality Alert: Ranged units (Weavers) gain +20% accuracy when firing from higher ground.", + "block_input": false, + "next": "END" + } + ] +} diff --git a/src/assets/data/narrative/story_03_outro.json b/src/assets/data/narrative/story_03_outro.json new file mode 100644 index 0000000..84404e3 --- /dev/null +++ b/src/assets/data/narrative/story_03_outro.json @@ -0,0 +1,21 @@ +{ + "id": "NARRATIVE_STORY_03_OUTRO", + "nodes": [ + { + "id": "1", + "type": "DIALOGUE", + "speaker": "Arch-Librarian Elara", + "portrait": "assets/images/portraits/weaver.png", + "text": "This data is incredible. It outlines a method for refining Ancient Cores.", + "next": "2" + }, + { + "id": "2", + "type": "DIALOGUE", + "speaker": "System", + "text": "Research Facility Unlocked. You can now spend Ancient Cores on permanent upgrades.", + "trigger": { "type": "UNLOCK_FACILITY", "facility_id": "RESEARCH" }, + "next": "END" + } + ] +} diff --git a/src/core/DebugCommands.js b/src/core/DebugCommands.js new file mode 100644 index 0000000..f10bc94 --- /dev/null +++ b/src/core/DebugCommands.js @@ -0,0 +1,911 @@ +/** + * DebugCommands.js + * Global command system for testing game functionality. + * Access via window.debugCommands in the browser console. + */ + +import { gameStateManager } from "./GameStateManager.js"; +import { itemRegistry } from "../managers/ItemRegistry.js"; + +/** + * Debug command system for testing game mechanics. + * @class + */ +export class DebugCommands { + constructor() { + this.gameStateManager = null; + this.gameLoop = null; + } + + /** + * Initializes the debug commands system. + * Should be called after gameStateManager is initialized. + */ + init() { + this.gameStateManager = gameStateManager; + // GameLoop will be set when it's available + if (gameStateManager.gameLoop) { + this.gameLoop = gameStateManager.gameLoop; + } + } + + /** + * Updates the game loop reference. + * @param {import("./GameLoop.js").GameLoop} loop - Game loop instance + */ + setGameLoop(loop) { + this.gameLoop = loop; + } + + // ============================================ + // EXPLORER & LEVELING COMMANDS + // ============================================ + + /** + * Adds experience to an explorer and handles leveling. + * @param {string} unitId - Unit ID (or "first" for first explorer, "all" for all) + * @param {number} amount - XP amount to add + * @returns {string} Result message + */ + addXP(unitId = "first", amount = 100) { + const units = this._getExplorers(unitId); + if (units.length === 0) { + return `No explorers found with ID: ${unitId}`; + } + + const results = []; + for (const unit of units) { + const oldLevel = unit.getLevel(); + unit.gainExperience(amount); + + // Check for level up (simple XP curve: 100 * level^2) + const mastery = unit.classMastery[unit.activeClassId]; + const xpForNextLevel = 100 * (mastery.level ** 2); + + while (mastery.xp >= xpForNextLevel && mastery.level < 30) { + mastery.level += 1; + mastery.skillPoints += 1; // Award skill point on level up + mastery.xp -= xpForNextLevel; + + // Recalculate stats if class definition is available + if (this.gameLoop?.classRegistry) { + const classDef = this.gameLoop.classRegistry.get(unit.activeClassId); + if (classDef) { + unit.recalculateBaseStats(classDef); + // Recalculate effective stats with equipment + if (unit.recalculateStats) { + unit.recalculateStats( + this.gameLoop.inventoryManager?.itemRegistry || itemRegistry + ); + } + } + } + } + + const newLevel = unit.getLevel(); + const leveledUp = newLevel > oldLevel; + results.push( + `${unit.name} (${unit.id}): +${amount} XP. Level: ${oldLevel} → ${newLevel}. ` + + `XP: ${mastery.xp}/${100 * (newLevel ** 2)}. ` + + `Skill Points: ${mastery.skillPoints}. ` + + (leveledUp ? "✨ LEVELED UP!" : "") + ); + } + + return results.join("\n"); + } + + /** + * Sets an explorer's level directly. + * @param {string} unitId - Unit ID (or "first" for first explorer) + * @param {number} level - Target level (1-30) + * @returns {string} Result message + */ + setLevel(unitId = "first", level = 5) { + const units = this._getExplorers(unitId); + if (units.length === 0) { + return `No explorers found with ID: ${unitId}`; + } + + level = Math.max(1, Math.min(30, level)); + + const results = []; + for (const unit of units) { + const mastery = unit.classMastery[unit.activeClassId]; + const oldLevel = mastery.level; + mastery.level = level; + + // Calculate XP for this level (simple curve: sum of 100 * level^2 for all previous levels) + let totalXP = 0; + for (let i = 1; i < level; i++) { + totalXP += 100 * (i ** 2); + } + mastery.xp = 0; // Reset XP to 0 for current level + + // Award skill points (1 per level, starting from level 2) + mastery.skillPoints = Math.max(0, level - 1); + + // Recalculate stats + if (this.gameLoop?.classRegistry) { + const classDef = this.gameLoop.classRegistry.get(unit.activeClassId); + if (classDef) { + unit.recalculateBaseStats(classDef); + if (unit.recalculateStats) { + unit.recalculateStats( + this.gameLoop.inventoryManager?.itemRegistry || itemRegistry + ); + } + } + } + + results.push( + `${unit.name} (${unit.id}): Level set to ${level} (was ${oldLevel}). ` + + `Skill Points: ${mastery.skillPoints}` + ); + } + + return results.join("\n"); + } + + /** + * Adds skill points to an explorer. + * @param {string} unitId - Unit ID (or "first" for first explorer) + * @param {number} amount - Skill points to add + * @returns {string} Result message + */ + addSkillPoints(unitId = "first", amount = 5) { + const units = this._getExplorers(unitId); + if (units.length === 0) { + return `No explorers found with ID: ${unitId}`; + } + + const results = []; + for (const unit of units) { + const mastery = unit.classMastery[unit.activeClassId]; + mastery.skillPoints = (mastery.skillPoints || 0) + amount; + results.push( + `${unit.name} (${unit.id}): +${amount} skill points. Total: ${mastery.skillPoints}` + ); + } + + return results.join("\n"); + } + + // ============================================ + // SKILL TREE COMMANDS + // ============================================ + + /** + * Unlocks a skill node for an explorer. + * @param {string} unitId - Unit ID (or "first" for first explorer) + * @param {string} nodeId - Skill node ID to unlock + * @returns {string} Result message + */ + unlockSkill(unitId = "first", nodeId) { + if (!nodeId) { + return "Error: nodeId is required. Usage: unlockSkill(unitId, nodeId)"; + } + + const units = this._getExplorers(unitId); + if (units.length === 0) { + return `No explorers found with ID: ${unitId}`; + } + + const results = []; + for (const unit of units) { + const mastery = unit.classMastery[unit.activeClassId]; + if (!mastery.unlockedNodes) { + mastery.unlockedNodes = []; + } + + if (mastery.unlockedNodes.includes(nodeId)) { + results.push(`${unit.name} (${unit.id}): Node ${nodeId} already unlocked`); + continue; + } + + // Get node cost (default to 1 if we can't determine) + let cost = 1; + if (this.gameLoop?.classRegistry) { + const classDef = this.gameLoop.classRegistry.get(unit.activeClassId); + // Skill trees are generated dynamically, so we can't get node cost from classDef + // Default to 1 for now + } + + // Unlock the node (bypass skill point check for debug) + mastery.unlockedNodes.push(nodeId); + + // Recalculate stats if unit has the method + if (unit.recalculateStats && this.gameLoop?.classRegistry) { + const classDef = this.gameLoop.classRegistry.get(unit.activeClassId); + if (classDef?.skillTreeData) { + unit.recalculateStats( + this.gameLoop.inventoryManager?.itemRegistry || itemRegistry, + classDef.skillTreeData + ); + } + } + + results.push( + `${unit.name} (${unit.id}): Unlocked skill node ${nodeId} (cost: ${cost})` + ); + } + + return results.join("\n"); + } + + /** + * Unlocks all skill nodes for an explorer. + * @param {string} unitId - Unit ID (or "first" for first explorer) + * @returns {string} Result message + */ + unlockAllSkills(unitId = "first") { + const units = this._getExplorers(unitId); + if (units.length === 0) { + return `No explorers found with ID: ${unitId}`; + } + + const results = []; + for (const unit of units) { + if (!this.gameLoop?.classRegistry) { + results.push(`${unit.name}: Cannot unlock all skills - class registry not available`); + continue; + } + + const classDef = this.gameLoop.classRegistry.get(unit.activeClassId); + if (!classDef) { + results.push(`${unit.name}: Class definition not found`); + continue; + } + + // Skill trees are generated dynamically, so we can't easily get all node IDs + // For now, return a helpful message + results.push( + `${unit.name} (${unit.id}): Note - Skill trees are generated dynamically. ` + + `Use unlockSkill(unitId, nodeId) with specific node IDs. ` + + `To see available nodes, open the character sheet for this unit.` + ); + } + + return results.join("\n"); + } + + // ============================================ + // INVENTORY COMMANDS + // ============================================ + + /** + * Adds an item to inventory (hub stash or run stash). + * @param {string} itemDefId - Item definition ID + * @param {number} quantity - Quantity to add (default: 1) + * @param {string} target - "hub" or "run" (default: "hub") + * @returns {string} Result message + */ + addItem(itemDefId, quantity = 1, target = "hub") { + if (!itemDefId) { + return "Error: itemDefId is required. Usage: addItem(itemDefId, quantity, target)"; + } + + const itemDef = itemRegistry.get(itemDefId); + if (!itemDef) { + return `Error: Item definition not found: ${itemDefId}`; + } + + const itemInstance = { + uid: `ITEM_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + defId: itemDefId, + isNew: true, + quantity: quantity, + }; + + if (target === "run" && this.gameLoop?.inventoryManager?.runStash) { + this.gameLoop.inventoryManager.runStash.addItem(itemInstance); + return `Added ${quantity}x ${itemDefId} to run stash`; + } else if (this.gameStateManager?.hubStash) { + this.gameStateManager.hubStash.addItem(itemInstance); + // Save hub stash + if (this.gameStateManager._saveHubStash) { + this.gameStateManager._saveHubStash(); + } + return `Added ${quantity}x ${itemDefId} to hub stash`; + } else { + return "Error: No stash available"; + } + } + + /** + * Adds currency to hub stash. + * @param {number} shards - Aether shards to add + * @param {number} cores - Ancient cores to add (optional) + * @returns {string} Result message + */ + addCurrency(shards = 1000, cores = 0) { + if (!this.gameStateManager?.hubStash) { + return "Error: Hub stash not available"; + } + + this.gameStateManager.hubStash.currency.aetherShards = + (this.gameStateManager.hubStash.currency.aetherShards || 0) + shards; + if (cores > 0) { + this.gameStateManager.hubStash.currency.ancientCores = + (this.gameStateManager.hubStash.currency.ancientCores || 0) + cores; + } + + // Save hub stash + if (this.gameStateManager._saveHubStash) { + this.gameStateManager._saveHubStash(); + } + + return `Added ${shards} Aether Shards${cores > 0 ? ` and ${cores} Ancient Cores` : ""} to hub stash`; + } + + // ============================================ + // COMBAT COMMANDS + // ============================================ + + /** + * Kills an enemy (or all enemies). + * @param {string} enemyId - Enemy unit ID (or "all" for all enemies) + * @returns {string} Result message + */ + killEnemy(enemyId = "all") { + if (!this.gameLoop?.unitManager) { + return "Error: Game loop or unit manager not available"; + } + + const enemies = this._getEnemies(enemyId); + if (enemies.length === 0) { + return `No enemies found${enemyId !== "all" ? ` with ID: ${enemyId}` : ""}`; + } + + const results = []; + for (const enemy of enemies) { + if (this.gameLoop.handleUnitDeath) { + this.gameLoop.handleUnitDeath(enemy); + } else { + // Fallback: set health to 0 and remove from unit manager + enemy.currentHealth = 0; + if (this.gameLoop.unitManager.removeUnit) { + this.gameLoop.unitManager.removeUnit(enemy.id); + } + } + results.push(`Killed ${enemy.name} (${enemy.id})`); + } + + return results.join("\n"); + } + + /** + * Heals a unit to full health. + * @param {string} unitId - Unit ID (or "all" for all player units) + * @returns {string} Result message + */ + healUnit(unitId = "all") { + if (!this.gameLoop?.unitManager) { + return "Error: Game loop or unit manager not available"; + } + + const units = unitId === "all" + ? this.gameLoop.unitManager.getUnitsByTeam("PLAYER") + : [this.gameLoop.unitManager.getUnitById(unitId)].filter(Boolean); + + if (units.length === 0) { + return `No units found${unitId !== "all" ? ` with ID: ${unitId}` : ""}`; + } + + const results = []; + for (const unit of units) { + const oldHP = unit.currentHealth; + unit.currentHealth = unit.maxHealth; + results.push(`${unit.name} (${unit.id}): Healed ${unit.maxHealth - oldHP} HP (${oldHP} → ${unit.maxHealth})`); + } + + return results.join("\n"); + } + + // ============================================ + // MISSION & NARRATIVE COMMANDS + // ============================================ + + /** + * Triggers mission victory. + * @returns {string} Result message + */ + triggerVictory() { + if (!this.gameStateManager?.missionManager) { + return "Error: Mission manager not available"; + } + + // Complete all primary objectives + if (this.gameStateManager.missionManager.currentObjectives) { + this.gameStateManager.missionManager.currentObjectives.forEach((obj) => { + obj.complete = true; + obj.current = obj.target_count || 1; + }); + } + + // Trigger victory check + this.gameStateManager.missionManager.checkVictory(); + + return "Mission victory triggered!"; + } + + /** + * Completes a specific objective. + * @param {string} objectiveId - Objective ID (or index as number) + * @returns {string} Result message + */ + completeObjective(objectiveId) { + if (!this.gameStateManager?.missionManager) { + return "Error: Mission manager not available"; + } + + const objectives = this.gameStateManager.missionManager.currentObjectives || []; + let objective = null; + + if (typeof objectiveId === "number") { + objective = objectives[objectiveId]; + } else { + objective = objectives.find((obj) => obj.id === objectiveId); + } + + if (!objective) { + return `Error: Objective not found: ${objectiveId}`; + } + + objective.complete = true; + objective.current = objective.target_count || 1; + + // Check victory + this.gameStateManager.missionManager.checkVictory(); + + return `Objective completed: ${objective.type} (${objective.id || "unnamed"})`; + } + + /** + * Triggers the next narrative sequence. + * @param {string} narrativeId - Narrative ID (optional, uses current mission's narrative if not provided) + * @returns {string} Result message + */ + triggerNarrative(narrativeId = null) { + if (!this.gameStateManager?.narrativeManager) { + return "Error: Narrative manager not available"; + } + + if (narrativeId) { + // Load and start specific narrative + this.gameStateManager.narrativeManager + .loadSequence(narrativeId) + .then(() => { + this.gameStateManager.narrativeManager.startSequence(narrativeId); + }) + .catch((error) => { + console.error("Error loading narrative:", error); + }); + return `Triggered narrative: ${narrativeId}`; + } else { + // Try to trigger current mission's intro/outro + const missionDef = this.gameStateManager.missionManager?.currentMissionDef; + if (missionDef?.narrative) { + const narrativeToUse = + missionDef.narrative.intro_success || + missionDef.narrative.intro || + missionDef.narrative.outro_success; + if (narrativeToUse) { + this.gameStateManager.narrativeManager + .loadSequence(narrativeToUse) + .then(() => { + this.gameStateManager.narrativeManager.startSequence(narrativeToUse); + }) + .catch((error) => { + console.error("Error loading narrative:", error); + }); + return `Triggered narrative: ${narrativeToUse}`; + } + } + return "Error: No narrative specified and no mission narrative found"; + } + } + + // ============================================ + // UTILITY COMMANDS + // ============================================ + + /** + * Lists all available commands in a readable format. + * Uses console formatting for better readability. + */ + help() { + console.log( + "%c=== DEBUG COMMANDS HELP ===\n", + "font-weight: bold; font-size: 16px; color: #4CAF50;" + ); + + console.log( + "%cEXPLORER & LEVELING:", + "font-weight: bold; color: #2196F3;" + ); + console.log(" %caddXP(unitId, amount)%c - Add XP to explorer(s)", "color: #FF9800;", "color: inherit;"); + console.log(" unitId: \"first\", \"all\", or specific ID"); + console.log(" %csetLevel(unitId, level)%c - Set explorer level directly (1-30)", "color: #FF9800;", "color: inherit;"); + console.log(" %caddSkillPoints(unitId, amount)%c - Add skill points to explorer", "color: #FF9800;", "color: inherit;"); + console.log(""); + + console.log( + "%cSKILL TREE:", + "font-weight: bold; color: #2196F3;" + ); + console.log(" %cunlockSkill(unitId, nodeId)%c - Unlock a specific skill node", "color: #FF9800;", "color: inherit;"); + console.log(" %cunlockAllSkills(unitId)%c - Unlock all skill nodes for explorer", "color: #FF9800;", "color: inherit;"); + console.log(""); + + console.log( + "%cINVENTORY:", + "font-weight: bold; color: #2196F3;" + ); + console.log(" %caddItem(itemDefId, quantity, target)%c - Add item", "color: #FF9800;", "color: inherit;"); + console.log(" target: \"hub\" or \"run\""); + console.log(" %caddCurrency(shards, cores)%c - Add currency to hub stash", "color: #FF9800;", "color: inherit;"); + console.log(""); + + console.log( + "%cCOMBAT:", + "font-weight: bold; color: #2196F3;" + ); + console.log(" %ckillEnemy(enemyId)%c - Kill enemy", "color: #FF9800;", "color: inherit;"); + console.log(" enemyId: \"all\" or specific ID"); + console.log(" %chealUnit(unitId)%c - Heal unit to full", "color: #FF9800;", "color: inherit;"); + console.log(" unitId: \"all\" or specific ID"); + console.log(""); + + console.log( + "%cMISSION & NARRATIVE:", + "font-weight: bold; color: #2196F3;" + ); + 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;"); + console.log(""); + + console.log( + "%cUTILITY:", + "font-weight: bold; color: #2196F3;" + ); + console.log(" %chelp()%c - Show this help", "color: #FF9800;", "color: inherit;"); + console.log(" %clistIds()%c - List all available IDs", "color: #FF9800;", "color: inherit;"); + console.log(" %clistPlayerIds()%c - List player unit IDs only", "color: #FF9800;", "color: inherit;"); + console.log(" %clistEnemyIds()%c - List enemy unit IDs only", "color: #FF9800;", "color: inherit;"); + console.log(" %clistItemIds(limit)%c - List item IDs only (default limit: 100)", "color: #FF9800;", "color: inherit;"); + console.log(" %clistUnits()%c - List all units with details", "color: #FF9800;", "color: inherit;"); + console.log(" %clistItems(limit)%c - List available items with details (default limit: 50)", "color: #FF9800;", "color: inherit;"); + console.log(" %cgetState()%c - Get current game state info", "color: #FF9800;", "color: inherit;"); + console.log(""); + + console.log( + "%cQUICK EXAMPLES:", + "font-weight: bold; color: #9C27B0;" + ); + 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.killEnemy(\"all\")", "color: #4CAF50; font-family: monospace;"); + console.log(" %cdebugCommands.triggerVictory()", "color: #4CAF50; font-family: monospace;"); + console.log(""); + + console.log( + "%cTIP: Use listIds() to see all available IDs for commands!", + "font-style: italic; color: #757575;" + ); + + return "Help displayed above. Check the console for formatted output."; + } + + /** + * Lists all units in the game. + * @returns {string} Unit list + */ + listUnits() { + if (!this.gameLoop?.unitManager) { + return "Error: Game loop or unit manager not available"; + } + + const allUnits = this.gameLoop.unitManager.getAllUnits(); + if (allUnits.length === 0) { + return "No units found"; + } + + const playerUnits = allUnits.filter((u) => u.team === "PLAYER"); + const enemyUnits = allUnits.filter((u) => u.team === "ENEMY"); + + let result = `Total Units: ${allUnits.length}\n\n`; + + if (playerUnits.length > 0) { + result += "PLAYER UNITS:\n"; + playerUnits.forEach((unit) => { + const level = unit.getLevel ? unit.getLevel() : "?"; + result += ` - ${unit.name} (${unit.id}) - Level ${level} - HP: ${unit.currentHealth}/${unit.maxHealth}\n`; + }); + } + + if (enemyUnits.length > 0) { + result += "\nENEMY UNITS:\n"; + enemyUnits.forEach((unit) => { + result += ` - ${unit.name} (${unit.id}) - HP: ${unit.currentHealth}/${unit.maxHealth}\n`; + }); + } + + return result; + } + + /** + * Lists player unit IDs only (for use with commands). + * @returns {string} Player IDs list + */ + listPlayerIds() { + if (!this.gameLoop?.unitManager) { + return "Error: Game loop or unit manager not available"; + } + + const playerUnits = this.gameLoop.unitManager.getUnitsByTeam("PLAYER"); + if (playerUnits.length === 0) { + return "No player units found"; + } + + let result = "PLAYER UNIT IDs:\n"; + playerUnits.forEach((unit) => { + const level = unit.getLevel ? unit.getLevel() : "?"; + result += ` "${unit.id}" - ${unit.name} (Level ${level})\n`; + }); + result += `\nUse with: addXP("${playerUnits[0]?.id}", 100) or addXP("first", 100) or addXP("all", 100)`; + + return result; + } + + /** + * Lists enemy unit IDs only (for use with commands). + * @returns {string} Enemy IDs list + */ + listEnemyIds() { + if (!this.gameLoop?.unitManager) { + return "Error: Game loop or unit manager not available"; + } + + const enemyUnits = this.gameLoop.unitManager.getUnitsByTeam("ENEMY"); + if (enemyUnits.length === 0) { + return "No enemy units found"; + } + + let result = "ENEMY UNIT IDs:\n"; + enemyUnits.forEach((unit) => { + result += ` "${unit.id}" - ${unit.name} (HP: ${unit.currentHealth}/${unit.maxHealth})\n`; + }); + result += `\nUse with: killEnemy("${enemyUnits[0]?.id}") or killEnemy("all")`; + + return result; + } + + /** + * Lists available items in the registry. + * @param {number} limit - Maximum number of items to show (default: 50) + * @returns {string} Item list + */ + listItems(limit = 50) { + const items = Array.from(itemRegistry.getAll ? itemRegistry.getAll() : []); + if (items.length === 0) { + return "No items found in registry"; + } + + let result = `Available Items (showing ${Math.min(limit, items.length)} of ${items.length}):\n\n`; + items.slice(0, limit).forEach((item) => { + result += ` - ${item.id} (${item.type || "UNKNOWN"})`; + if (item.name) { + result += `: ${item.name}`; + } + result += "\n"; + }); + + if (items.length > limit) { + result += `\n... and ${items.length - limit} more items`; + } + + return result; + } + + /** + * Lists item IDs only (for use with addItem command). + * @param {number} limit - Maximum number of items to show (default: 100) + * @returns {string} Item IDs list + */ + listItemIds(limit = 100) { + const items = Array.from(itemRegistry.getAll ? itemRegistry.getAll() : []); + if (items.length === 0) { + return "No items found in registry"; + } + + let result = "ITEM IDs:\n"; + items.slice(0, limit).forEach((item) => { + result += ` "${item.id}"`; + if (item.name) { + result += ` - ${item.name}`; + } + if (item.type) { + result += ` (${item.type})`; + } + result += "\n"; + }); + + if (items.length > limit) { + result += `\n... and ${items.length - limit} more items (use listItems() to see all)`; + } + + result += `\nUse with: addItem("${items[0]?.id}", 1, "hub")`; + + return result; + } + + /** + * Lists all available IDs (players, enemies, items, objectives, narratives). + * @returns {string} Comprehensive ID list + */ + listIds() { + let result = "=== AVAILABLE IDs ===\n\n"; + + // Player IDs + if (this.gameLoop?.unitManager) { + const playerUnits = this.gameLoop.unitManager.getUnitsByTeam("PLAYER"); + if (playerUnits.length > 0) { + result += "PLAYER UNIT IDs:\n"; + playerUnits.forEach((unit) => { + result += ` "${unit.id}" - ${unit.name}\n`; + }); + result += "\n"; + } + } + + // Enemy IDs + if (this.gameLoop?.unitManager) { + const enemyUnits = this.gameLoop.unitManager.getUnitsByTeam("ENEMY"); + if (enemyUnits.length > 0) { + result += "ENEMY UNIT IDs:\n"; + enemyUnits.forEach((unit) => { + result += ` "${unit.id}" - ${unit.name}\n`; + }); + result += "\n"; + } + } + + // Item IDs + const items = Array.from(itemRegistry.getAll ? itemRegistry.getAll() : []); + if (items.length > 0) { + result += `ITEM IDs (showing first 20 of ${items.length}):\n`; + items.slice(0, 20).forEach((item) => { + result += ` "${item.id}"`; + if (item.name) { + result += ` - ${item.name}`; + } + result += "\n"; + }); + if (items.length > 20) { + result += ` ... and ${items.length - 20} more (use listItemIds() to see all)\n`; + } + result += "\n"; + } + + // Objective IDs + if (this.gameStateManager?.missionManager?.currentObjectives) { + const objectives = this.gameStateManager.missionManager.currentObjectives; + if (objectives.length > 0) { + result += "OBJECTIVE IDs:\n"; + objectives.forEach((obj, idx) => { + result += ` ${idx} or "${obj.id || `objective_${idx}`}" - ${obj.type}\n`; + }); + result += "\n"; + } + } + + // Mission ID + if (this.gameStateManager?.missionManager?.activeMissionId) { + result += `ACTIVE MISSION ID:\n "${this.gameStateManager.missionManager.activeMissionId}"\n\n`; + } + + // Narrative IDs (from current mission) + if (this.gameStateManager?.missionManager?.currentMissionDef?.narrative) { + const narrative = this.gameStateManager.missionManager.currentMissionDef.narrative; + result += "NARRATIVE IDs (from current mission):\n"; + if (narrative.intro) result += ` "${narrative.intro}" - Intro\n`; + if (narrative.intro_success) result += ` "${narrative.intro_success}" - Intro Success\n`; + if (narrative.outro_success) result += ` "${narrative.outro_success}" - Outro Success\n`; + if (narrative.outro_failure) result += ` "${narrative.outro_failure}" - Outro Failure\n`; + result += "\n"; + } + + result += "Use listPlayerIds(), listEnemyIds(), or listItemIds() for detailed lists."; + + return result; + } + + /** + * Gets current game state information. + * @returns {string} State info + */ + getState() { + let result = "Game State:\n\n"; + + result += `Current State: ${this.gameStateManager?.currentState || "UNKNOWN"}\n`; + result += `Game Running: ${this.gameLoop?.isRunning || false}\n`; + result += `Game Paused: ${this.gameLoop?.isPaused || false}\n`; + + if (this.gameStateManager?.missionManager) { + const mm = this.gameStateManager.missionManager; + result += `\nMission: ${mm.activeMissionId || "None"}\n`; + if (mm.currentObjectives) { + result += `Objectives: ${mm.currentObjectives.length}\n`; + mm.currentObjectives.forEach((obj, idx) => { + result += ` ${idx + 1}. ${obj.type} - ${obj.complete ? "✓" : "✗"} (${obj.current || 0}/${obj.target_count || 0})\n`; + }); + } + } + + if (this.gameLoop?.turnSystem) { + const activeUnit = this.gameLoop.turnSystem.getActiveUnit(); + if (activeUnit) { + result += `\nActive Unit: ${activeUnit.name} (${activeUnit.id})\n`; + } + } + + return result; + } + + // ============================================ + // HELPER METHODS + // ============================================ + + /** + * Gets explorer units by ID or special keywords. + * @param {string} unitId - Unit ID, "first", or "all" + * @returns {Array} Array of explorer units + * @private + */ + _getExplorers(unitId) { + if (!this.gameLoop?.unitManager) { + return []; + } + + if (unitId === "all") { + return this.gameLoop.unitManager.getUnitsByTeam("PLAYER"); + } else if (unitId === "first") { + const units = this.gameLoop.unitManager.getUnitsByTeam("PLAYER"); + return units.length > 0 ? [units[0]] : []; + } else { + const unit = this.gameLoop.unitManager.getUnitById(unitId); + return unit && unit.team === "PLAYER" ? [unit] : []; + } + } + + /** + * Gets enemy units by ID or special keywords. + * @param {string} enemyId - Enemy ID or "all" + * @returns {Array} Array of enemy units + * @private + */ + _getEnemies(enemyId) { + if (!this.gameLoop?.unitManager) { + return []; + } + + if (enemyId === "all") { + return this.gameLoop.unitManager.getUnitsByTeam("ENEMY"); + } else { + const unit = this.gameLoop.unitManager.getUnitById(enemyId); + return unit && unit.team === "ENEMY" ? [unit] : []; + } + } +} + +// Create singleton instance +export const debugCommands = new DebugCommands(); + +// Make it globally available +if (typeof window !== "undefined") { + window.debugCommands = debugCommands; +} + diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index aaa99ac..8011b02 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -27,12 +27,7 @@ import { InventoryContainer } from "../models/InventoryContainer.js"; import { itemRegistry } from "../managers/ItemRegistry.js"; import { narrativeManager } from "../managers/NarrativeManager.js"; -// Import class definitions -import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" }; -import weaverDef from "../assets/data/classes/aether_weaver.json" with { type: "json" }; -import scavengerDef from "../assets/data/classes/scavenger.json" with { type: "json" }; -import tinkerDef from "../assets/data/classes/tinker.json" with { type: "json" }; -import custodianDef from "../assets/data/classes/custodian.json" with { type: "json" }; +// Class definitions will be lazy-loaded when startLevel is called /** * Main game loop managing rendering, input, and game state. @@ -42,7 +37,7 @@ export class GameLoop { constructor() { /** @type {boolean} */ this.isRunning = false; - + /** @type {Object|null} Cached skill tree template */ this._skillTreeTemplate = null; /** @type {number | null} */ @@ -78,11 +73,11 @@ export class GameLoop { this.skillTargetingSystem = null; /** @type {EffectProcessor | null} */ this.effectProcessor = null; - + // Inventory System /** @type {InventoryManager | null} */ this.inventoryManager = null; - + // AbortController for cleaning up event listeners /** @type {AbortController | null} */ this.turnSystemAbortController = null; @@ -165,7 +160,11 @@ export class GameLoop { const runStash = new InventoryContainer("RUN_LOOT"); const hubStash = new InventoryContainer("HUB_VAULT"); // Initialize InventoryManager with itemRegistry (will load items in startLevel) - this.inventoryManager = new InventoryManager(itemRegistry, runStash, hubStash); + this.inventoryManager = new InventoryManager( + itemRegistry, + runStash, + hubStash + ); // --- SETUP INPUT MANAGER --- this.inputManager = new InputManager( @@ -332,7 +331,7 @@ export class GameLoop { if (activeUnit && activeUnit.team === "PLAYER") { const skills = activeUnit.actions || []; let skillIndex = -1; - + // Map key codes to skill indices (1-5) if (code === "Digit1" || code === "Numpad1") { skillIndex = 0; @@ -345,7 +344,7 @@ export class GameLoop { } else if (code === "Digit5" || code === "Numpad5") { skillIndex = 4; } - + if (skillIndex >= 0 && skillIndex < skills.length) { const skill = skills[skillIndex]; if (skill && skill.id) { @@ -361,19 +360,21 @@ export class GameLoop { */ openCharacterSheet() { if (!this.turnSystem) return; - + const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit || activeUnit.team !== "PLAYER") { // If no active unit or not player unit, try to get first player unit if (this.unitManager) { - const playerUnits = this.unitManager.getAllUnits().filter((u) => u.team === "PLAYER"); + const playerUnits = this.unitManager + .getAllUnits() + .filter((u) => u.team === "PLAYER"); if (playerUnits.length > 0) { this._dispatchOpenCharacterSheet(playerUnits[0]); } } return; } - + this._dispatchOpenCharacterSheet(activeUnit); } @@ -388,20 +389,21 @@ export class GameLoop { if (typeof unitOrId === "string" && this.unitManager) { unit = this.unitManager.getUnitById(unitOrId); } - + if (!unit) { console.warn("Cannot open character sheet: unit not found"); return; } - + // Get inventory from runData or empty array const inventory = this.runData?.inventory || []; - + // Determine if read-only (enemy turn or restricted) const activeUnit = this.turnSystem?.getActiveUnit(); - const isReadOnly = this.combatState === "TARGETING_SKILL" || - (activeUnit && activeUnit.team !== "PLAYER"); - + const isReadOnly = + this.combatState === "TARGETING_SKILL" || + (activeUnit && activeUnit.team !== "PLAYER"); + window.dispatchEvent( new CustomEvent("open-character-sheet", { detail: { @@ -512,7 +514,7 @@ export class GameLoop { // Update combat state and movement highlights this.updateCombatState().catch(console.error); - + // NOTE: Do NOT auto-end turn when AP reaches 0 after movement. // The player should explicitly click "End Turn" to end their turn. // Even if the unit has no AP left, they may want to use skills or wait. @@ -531,7 +533,10 @@ export class GameLoop { if (!activeUnit || activeUnit.team !== "PLAYER") return; // If clicking the same skill that's already active, cancel targeting - if (this.combatState === "TARGETING_SKILL" && this.activeSkillId === skillId) { + if ( + this.combatState === "TARGETING_SKILL" && + this.activeSkillId === skillId + ) { this.cancelSkillTargeting(); return; } @@ -558,11 +563,13 @@ export class GameLoop { const skillDef = this.skillTargetingSystem.getSkillDef(skillId); if (skillDef && this.voxelManager && this.skillTargetingSystem) { // Check if this is a teleport skill with unlimited range (range = -1) - const isTeleportSkill = skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT"); - const hasUnlimitedRange = skillDef.range === -1 || skillDef.range === Infinity; - + const isTeleportSkill = + skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT"); + const hasUnlimitedRange = + skillDef.range === -1 || skillDef.range === Infinity; + let allTilesInRange = []; - + if (isTeleportSkill && hasUnlimitedRange) { // For teleport with unlimited range, scan all valid tiles in the grid // Range is limited only by line of sight @@ -579,9 +586,21 @@ export class GameLoop { } } else { // For normal skills, get tiles within range - for (let x = activeUnit.position.x - skillDef.range; x <= activeUnit.position.x + skillDef.range; x++) { - for (let y = activeUnit.position.y - skillDef.range; y <= activeUnit.position.y + skillDef.range; y++) { - for (let z = activeUnit.position.z - skillDef.range; z <= activeUnit.position.z + skillDef.range; z++) { + for ( + let x = activeUnit.position.x - skillDef.range; + x <= activeUnit.position.x + skillDef.range; + x++ + ) { + for ( + let y = activeUnit.position.y - skillDef.range; + y <= activeUnit.position.y + skillDef.range; + y++ + ) { + for ( + let z = activeUnit.position.z - skillDef.range; + z <= activeUnit.position.z + skillDef.range; + z++ + ) { const dist = Math.abs(x - activeUnit.position.x) + Math.abs(y - activeUnit.position.y) + @@ -608,13 +627,16 @@ export class GameLoop { if (validation.valid) { validTilesWithObstruction.push({ pos: tilePos, - obstruction: validation.obstruction || 0 + obstruction: validation.obstruction || 0, }); } }); // Highlight only valid targets with obstruction-based dimming - this.voxelManager.highlightTilesWithObstruction(validTilesWithObstruction, "RED_OUTLINE"); + this.voxelManager.highlightTilesWithObstruction( + validTilesWithObstruction, + "RED_OUTLINE" + ); } // Update combat state to refresh UI (show cancel button) @@ -682,27 +704,33 @@ export class GameLoop { targetPos, skillId ); - - console.log(`AoE found ${targets.length} targets at ${targetPos.x},${targetPos.y},${targetPos.z}`); + + console.log( + `AoE found ${targets.length} targets at ${targetPos.x},${targetPos.y},${targetPos.z}` + ); if (targets.length > 0) { targets.forEach((t) => { - console.log(` - Target: ${t.name} at ${t.position.x},${t.position.y},${t.position.z}`); + console.log( + ` - Target: ${t.name} at ${t.position.x},${t.position.y},${t.position.z}` + ); }); } - + // Fallback: If no targets found but there's a unit at the target position, include it // This handles cases where the AoE calculation might miss the exact target if (targets.length === 0 && this.grid) { const unitAtTarget = this.grid.getUnitAt(targetPos); if (unitAtTarget) { targets = [unitAtTarget]; - console.log(`Fallback: Added unit at target position: ${unitAtTarget.name}`); + console.log( + `Fallback: Added unit at target position: ${unitAtTarget.name}` + ); } } // 3. Process Effects using EffectProcessor const skillDef = this.skillTargetingSystem.getSkillDef(skillId); - + // Process ON_SKILL_CAST passive effects if (skillDef) { this.processPassiveItemEffects(activeUnit, "ON_SKILL_CAST", { @@ -710,7 +738,7 @@ export class GameLoop { skillDef: skillDef, }); } - + if (skillDef && skillDef.effects && this.effectProcessor) { for (const effect of skillDef.effects) { // Special handling for TELEPORT - teleports the source unit, not targets @@ -721,13 +749,17 @@ export class GameLoop { targetPos, skillDef.ignore_cover || false ); - + // Calculate failure chance based on obstruction level // Obstruction of 0 = 0% failure, obstruction of 1.0 = 100% failure const failureChance = losResult.obstruction || 0; if (Math.random() < failureChance) { console.warn( - `${activeUnit.name}'s teleport failed due to obstructed line of sight! (${Math.round(failureChance * 100)}% obstruction)` + `${ + activeUnit.name + }'s teleport failed due to obstructed line of sight! (${Math.round( + failureChance * 100 + )}% obstruction)` ); // Teleport failed - unit stays in place, but AP was already deducted // Could optionally refund AP here, but for now we'll just log the failure @@ -752,10 +784,18 @@ export class GameLoop { continue; // Skip teleport execution } } - const teleportDestination = { x: targetPos.x, y: walkableY, z: targetPos.z }; + const teleportDestination = { + x: targetPos.x, + y: walkableY, + z: targetPos.z, + }; // Process teleport effect - source unit is teleported to destination - const result = this.effectProcessor.process(effect, activeUnit, teleportDestination); + const result = this.effectProcessor.process( + effect, + activeUnit, + teleportDestination + ); if (result.success && result.data) { // Update unit mesh position after teleport @@ -781,7 +821,8 @@ export class GameLoop { for (const target of targets) { if (!target) continue; // Check if unit is alive - if (typeof target.isAlive === "function" && !target.isAlive()) continue; + if (typeof target.isAlive === "function" && !target.isAlive()) + continue; if (target.currentHealth <= 0) continue; // Process ON_SKILL_HIT passive effects (before processing effect) @@ -793,7 +834,11 @@ export class GameLoop { }); // Process effect through EffectProcessor - const result = this.effectProcessor.process(effect, activeUnit, target); + const result = this.effectProcessor.process( + effect, + activeUnit, + target + ); if (result.success) { // Log success messages based on effect type @@ -844,7 +889,10 @@ export class GameLoop { console.log( `${activeUnit.name} dealt ${primaryResult.amount} damage to ${target.name} (chain lightning)` ); - if (result.data.chainTargets && result.data.chainTargets.length > 0) { + if ( + result.data.chainTargets && + result.data.chainTargets.length > 0 + ) { console.log( `Chain lightning bounced to ${result.data.chainTargets.length} additional targets` ); @@ -1013,7 +1061,27 @@ export class GameLoop { // Create a proper registry with actual class definitions const classRegistry = new Map(); - + + // Lazy-load class definitions + const [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef] = + await Promise.all([ + import("../assets/data/classes/vanguard.json", { + with: { type: "json" }, + }).then((m) => m.default), + import("../assets/data/classes/aether_weaver.json", { + with: { type: "json" }, + }).then((m) => m.default), + import("../assets/data/classes/scavenger.json", { + with: { type: "json" }, + }).then((m) => m.default), + import("../assets/data/classes/tinker.json", { + with: { type: "json" }, + }).then((m) => m.default), + import("../assets/data/classes/custodian.json", { + with: { type: "json" }, + }).then((m) => m.default), + ]); + // Register all class definitions const classDefs = [ vanguardDef, @@ -1022,7 +1090,7 @@ export class GameLoop { tinkerDef, custodianDef, ]; - + for (const classDef of classDefs) { if (classDef && classDef.id) { // Add type field for compatibility @@ -1032,7 +1100,7 @@ export class GameLoop { }); } } - + // Create registry object with get method for UnitManager const unitRegistry = { get: (id) => { @@ -1040,7 +1108,7 @@ export class GameLoop { if (classRegistry.has(id)) { return classRegistry.get(id); } - + // Fallback for enemy units if (id.startsWith("ENEMY_")) { return { @@ -1050,12 +1118,12 @@ export class GameLoop { ai_archetype: "BRUISER", }; } - + console.warn(`Unit definition not found: ${id}`); return null; }, }; - + this.unitManager = new UnitManager(unitRegistry); // Store classRegistry reference for accessing class definitions later this.classRegistry = classRegistry; @@ -1092,11 +1160,25 @@ export class GameLoop { // Create new AbortController for this level - when aborted, listeners are automatically removed this.turnSystemAbortController = new AbortController(); const signal = this.turnSystemAbortController.signal; - - this.turnSystem.addEventListener("turn-start", (e) => this._onTurnStart(e.detail), { signal }); - this.turnSystem.addEventListener("turn-end", (e) => this._onTurnEnd(e.detail), { signal }); - this.turnSystem.addEventListener("combat-start", () => this._onCombatStart(), { signal }); - this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), { signal }); + + this.turnSystem.addEventListener( + "turn-start", + (e) => this._onTurnStart(e.detail), + { signal } + ); + this.turnSystem.addEventListener( + "turn-end", + (e) => this._onTurnEnd(e.detail), + { signal } + ); + this.turnSystem.addEventListener( + "combat-start", + () => this._onCombatStart(), + { signal } + ); + this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), { + signal, + }); this.highlightZones(); @@ -1187,12 +1269,12 @@ export class GameLoop { // CREATE logic const classId = unitDef.classId || unitDef.id; const unit = this.unitManager.createUnit(classId, "PLAYER"); - + if (!unit) { console.error(`Failed to create unit for class: ${classId}`); return null; } - + // Set character name and class name from unitDef if (unitDef.name) unit.name = unitDef.name; if (unitDef.className) unit.className = unitDef.className; @@ -1205,31 +1287,42 @@ export class GameLoop { if (rosterUnit) { // Store roster ID on unit for later saving unit.rosterId = unitDef.id; - + // Restore activeClassId first (needed for stat recalculation) if (rosterUnit.activeClassId) { unit.activeClassId = rosterUnit.activeClassId; } - + // Restore classMastery progression if (rosterUnit.classMastery) { - unit.classMastery = JSON.parse(JSON.stringify(rosterUnit.classMastery)); + unit.classMastery = JSON.parse( + JSON.stringify(rosterUnit.classMastery) + ); // Recalculate stats based on restored mastery and activeClassId if (unit.recalculateBaseStats && unit.activeClassId) { - const classDef = typeof this.unitManager.registry.get === "function" - ? this.unitManager.registry.get(unit.activeClassId) - : this.unitManager.registry[unit.activeClassId]; + const classDef = + typeof this.unitManager.registry.get === "function" + ? this.unitManager.registry.get(unit.activeClassId) + : this.unitManager.registry[unit.activeClassId]; if (classDef) { unit.recalculateBaseStats(classDef); } } } - + // Restore currentHealth from roster (preserve HP that was paid for) - if (rosterUnit.currentHealth !== undefined && rosterUnit.currentHealth !== null) { + if ( + rosterUnit.currentHealth !== undefined && + rosterUnit.currentHealth !== null + ) { // Ensure currentHealth doesn't exceed maxHealth (in case maxHealth increased) - unit.currentHealth = Math.min(rosterUnit.currentHealth, unit.maxHealth || 100); - console.log(`Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)`); + unit.currentHealth = Math.min( + rosterUnit.currentHealth, + unit.maxHealth || 100 + ); + console.log( + `Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)` + ); } } } @@ -1251,12 +1344,16 @@ export class GameLoop { // Get class definition from the registry let classDef = null; if (this.unitManager.registry) { - classDef = typeof this.unitManager.registry.get === "function" - ? this.unitManager.registry.get(classId) - : this.unitManager.registry[classId]; + classDef = + typeof this.unitManager.registry.get === "function" + ? this.unitManager.registry.get(classId) + : this.unitManager.registry[classId]; } - - if (classDef && typeof unit.initializeStartingEquipment === "function") { + + if ( + classDef && + typeof unit.initializeStartingEquipment === "function" + ) { unit.initializeStartingEquipment( this.inventoryManager.itemRegistry, classDef @@ -1267,7 +1364,13 @@ export class GameLoop { // Ensure unit has valid health values // Only set to full health if currentHealth is invalid (0 or negative) and wasn't restored from roster // This preserves HP that was paid for in the barracks - if (unit.currentHealth <= 0 && (!unit.rosterId || !this.gameStateManager?.rosterManager?.roster.find(r => r.id === unit.rosterId)?.currentHealth)) { + if ( + unit.currentHealth <= 0 && + (!unit.rosterId || + !this.gameStateManager?.rosterManager?.roster.find( + (r) => r.id === unit.rosterId + )?.currentHealth) + ) { // Only set to full if we didn't restore from roster (new unit or roster had no saved HP) unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100; } @@ -1289,7 +1392,7 @@ export class GameLoop { /** * Finalizes deployment phase and starts combat. */ - finalizeDeployment() { + async finalizeDeployment() { if ( !this.gameStateManager || this.gameStateManager.currentState !== "STATE_DEPLOYMENT" @@ -1297,7 +1400,7 @@ export class GameLoop { return; // Get enemy spawns from mission definition - const missionDef = this.missionManager?.getActiveMission(); + const missionDef = await this.missionManager?.getActiveMission(); const enemySpawns = missionDef?.enemy_spawns || []; // If no enemy_spawns defined, fall back to default behavior @@ -1313,7 +1416,10 @@ export class GameLoop { ); if (walkableY !== null) { const walkablePos = { x: spot.x, y: walkableY, z: spot.z }; - if (!this.grid.isOccupied(walkablePos) && !this.grid.isSolid(walkablePos)) { + if ( + !this.grid.isOccupied(walkablePos) && + !this.grid.isSolid(walkablePos) + ) { this.grid.placeUnit(enemy, walkablePos); this.createUnitMesh(enemy, walkablePos); } @@ -1329,7 +1435,11 @@ export class GameLoop { let attempts = 0; const maxAttempts = availableSpots.length * 2; - for (let i = 0; i < count && attempts < maxAttempts && availableSpots.length > 0; attempts++) { + for ( + let i = 0; + i < count && attempts < maxAttempts && availableSpots.length > 0; + attempts++ + ) { const spotIndex = Math.floor(Math.random() * availableSpots.length); const spot = availableSpots[spotIndex]; @@ -1385,7 +1495,7 @@ export class GameLoop { if (this.missionManager) { this.missionManager.setUnitManager(this.unitManager); this.missionManager.setTurnSystem(this.turnSystem); - this.missionManager.setupActiveMission(); + await this.missionManager.setupActiveMission(); } // WIRING: Listen for mission events @@ -1576,29 +1686,29 @@ export class GameLoop { */ createUnitMesh(unit, pos) { const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6); - + // Class-based color mapping for player units const CLASS_COLORS = { - CLASS_VANGUARD: 0xff3333, // Red - Tank - CLASS_TINKER: 0xffaa00, // Orange - Tech/Mechanical - CLASS_SCAVENGER: 0xaa33ff, // Purple - Rogue/Stealth - CLASS_CUSTODIAN: 0x33ffaa, // Teal - Healer/Support - CLASS_BATTLE_MAGE: 0x3333ff, // Blue - Magic Fighter - CLASS_SAPPER: 0xff6600, // Dark Orange - Explosive - CLASS_FIELD_ENGINEER: 0x00ffff, // Cyan - Tech Support - CLASS_WEAVER: 0xff33ff, // Magenta - Magic (Aether Weaver) + CLASS_VANGUARD: 0xff3333, // Red - Tank + CLASS_TINKER: 0xffaa00, // Orange - Tech/Mechanical + CLASS_SCAVENGER: 0xaa33ff, // Purple - Rogue/Stealth + CLASS_CUSTODIAN: 0x33ffaa, // Teal - Healer/Support + CLASS_BATTLE_MAGE: 0x3333ff, // Blue - Magic Fighter + CLASS_SAPPER: 0xff6600, // Dark Orange - Explosive + CLASS_FIELD_ENGINEER: 0x00ffff, // Cyan - Tech Support + CLASS_WEAVER: 0xff33ff, // Magenta - Magic (Aether Weaver) CLASS_AETHER_SENTINEL: 0x33aaff, // Light Blue - Defensive Magic - CLASS_ARCANE_SCOURGE: 0x6600ff, // Dark Purple - Dark Magic + CLASS_ARCANE_SCOURGE: 0x6600ff, // Dark Purple - Dark Magic }; - + let color = 0xcccccc; // Default gray - + if (unit.team === "ENEMY") { color = 0x550000; // Dark red for enemies } else if (unit.team === "PLAYER") { // Get class ID from activeClassId (Explorer units) or extract from unit.id let classId = unit.activeClassId; - + // If no activeClassId, try to extract from unit.id (format: "CLASS_VANGUARD_0") if (!classId && unit.id.includes("CLASS_")) { const parts = unit.id.split("_"); @@ -1606,7 +1716,7 @@ export class GameLoop { classId = parts[0] + "_" + parts[1]; } } - + // Look up color by class ID if (classId && CLASS_COLORS[classId]) { color = CLASS_COLORS[classId]; @@ -1621,7 +1731,7 @@ export class GameLoop { } } } - + const material = new THREE.MeshStandardMaterial({ color: color }); const mesh = new THREE.Mesh(geometry, material); // Floor surface is at pos.y - 0.5 (floor block at pos.y-1, top at pos.y-0.5) @@ -1696,7 +1806,7 @@ export class GameLoop { // Helper function to create multi-layer highlights for a position const createHighlights = (pos, materials) => { const { outerGlow, midGlow, highlight, thick } = materials; - + // Find walkable Y level (similar to movement highlights) let walkableY = pos.y; if (this.grid && this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) { @@ -1714,10 +1824,7 @@ export class GameLoop { const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15); outerGlowGeometry.rotateX(-Math.PI / 2); const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry); - const outerGlowLines = new THREE.LineSegments( - outerGlowEdges, - outerGlow - ); + const outerGlowLines = new THREE.LineSegments(outerGlowEdges, outerGlow); outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z); this.scene.add(outerGlowLines); this.spawnZoneHighlights.add(outerGlowLines); @@ -1726,10 +1833,7 @@ export class GameLoop { const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08); midGlowGeometry.rotateX(-Math.PI / 2); const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry); - const midGlowLines = new THREE.LineSegments( - midGlowEdges, - midGlow - ); + const midGlowLines = new THREE.LineSegments(midGlowEdges, midGlow); midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z); this.scene.add(midGlowLines); this.spawnZoneHighlights.add(midGlowLines); @@ -1745,10 +1849,7 @@ export class GameLoop { // Main bright outline (exact size, brightest) const edgesGeometry = new THREE.EdgesGeometry(baseGeometry); - const lineSegments = new THREE.LineSegments( - edgesGeometry, - highlight - ); + const lineSegments = new THREE.LineSegments(edgesGeometry, highlight); lineSegments.position.set(pos.x, floorSurfaceY, pos.z); this.scene.add(lineSegments); this.spawnZoneHighlights.add(lineSegments); @@ -1878,30 +1979,33 @@ export class GameLoop { stop() { this.isRunning = false; this.isPaused = false; - + // Abort turn system event listeners (automatically removes them via signal) if (this.turnSystemAbortController) { this.turnSystemAbortController.abort(); this.turnSystemAbortController = null; } - + // Reset turn system state BEFORE ending combat to prevent event cascades if (this.turnSystem) { // End combat first to stop any ongoing turn advancement - if (this.turnSystem.phase !== "INIT" && this.turnSystem.phase !== "COMBAT_END") { + if ( + this.turnSystem.phase !== "INIT" && + this.turnSystem.phase !== "COMBAT_END" + ) { try { this.turnSystem.endCombat(); } catch (e) { // Ignore errors } } - + // Then reset if (typeof this.turnSystem.reset === "function") { this.turnSystem.reset(); } } - + if (this.inputManager && typeof this.inputManager.detach === "function") { this.inputManager.detach(); } @@ -1935,7 +2039,7 @@ export class GameLoop { if (activeUnit) { // Calculate effective speed (including equipment and skill tree bonuses) let effectiveSpeed = activeUnit.baseStats?.speed || 10; - + // Add equipment bonuses if available if (activeUnit.loadout && this.inventoryManager) { const loadoutSlots = ["mainHand", "offHand", "body", "accessory"]; @@ -1951,7 +2055,7 @@ export class GameLoop { } } } - + // Calculate max AP using formula: 3 + floor(effectiveSpeed/5) // We'll add skill tree bonuses to speed below when we generate the skill tree let maxAP = 3 + Math.floor(effectiveSpeed / 5); @@ -1978,13 +2082,18 @@ export class GameLoop { // Add unlocked skill tree skills for Explorer units if ( - (activeUnit.type === "EXPLORER" || activeUnit.constructor?.name === "Explorer") && + (activeUnit.type === "EXPLORER" || + activeUnit.constructor?.name === "Explorer") && activeUnit.activeClassId && activeUnit.classMastery && this.classRegistry ) { const mastery = activeUnit.classMastery[activeUnit.activeClassId]; - if (mastery && mastery.unlockedNodes && mastery.unlockedNodes.length > 0) { + if ( + mastery && + mastery.unlockedNodes && + mastery.unlockedNodes.length > 0 + ) { try { // Get class definition const classDef = this.classRegistry.get(activeUnit.activeClassId); @@ -2007,7 +2116,7 @@ export class GameLoop { this._skillTreeTemplate = template; // Cache it } } - + if (template) { const templateRegistry = { [template.id]: template }; @@ -2015,7 +2124,10 @@ export class GameLoop { const skillMap = Object.fromEntries(skillRegistry.skills); // Create factory and generate tree - const factory = new SkillTreeFactory(templateRegistry, skillMap); + const factory = new SkillTreeFactory( + templateRegistry, + skillMap + ); const skillTree = factory.createTree(classDef); // Add speed boosts from unlocked nodes to effective speed @@ -2030,26 +2142,32 @@ export class GameLoop { effectiveSpeed += nodeDef.data.value || 0; } } - + // Recalculate maxAP with skill tree bonuses maxAP = 3 + Math.floor(effectiveSpeed / 5); // Add unlocked ACTIVE_SKILL nodes to skills array for (const nodeId of mastery.unlockedNodes) { const nodeDef = skillTree.nodes?.[nodeId]; - if (nodeDef && nodeDef.type === "ACTIVE_SKILL" && nodeDef.data) { + if ( + nodeDef && + nodeDef.type === "ACTIVE_SKILL" && + nodeDef.data + ) { const skillData = nodeDef.data; const skillId = skillData.id || nodeId; - + // Get full skill definition from registry if available const fullSkill = skillRegistry.skills.get(skillId); - + // Add skill to skills array (avoid duplicates) if (!skills.find((s) => s.id === skillId)) { // Get costAP from full skill definition - const costAP = fullSkill?.costs?.ap || skillData.costAP || 3; - const baseCooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0; - + const costAP = + fullSkill?.costs?.ap || skillData.costAP || 3; + const baseCooldown = + fullSkill?.cooldown_turns || skillData.cooldown || 0; + // Ensure skill exists in unit.actions for cooldown tracking if (!activeUnit.actions) { activeUnit.actions = []; @@ -2057,22 +2175,25 @@ export class GameLoop { let existingAction = activeUnit.actions.find( (a) => a.id === skillId ); - + // If action doesn't exist, create it with cooldown 0 (ready to use immediately) if (!existingAction) { existingAction = { id: skillId, - name: skillData.name || fullSkill?.name || "Unknown Skill", + name: + skillData.name || + fullSkill?.name || + "Unknown Skill", icon: skillData.icon || fullSkill?.icon || "⚔", costAP: costAP, cooldown: 0, // Newly unlocked skills start ready to use }; activeUnit.actions.push(existingAction); } - + // Use current cooldown from the action (which gets decremented by TurnSystem) const currentCooldown = existingAction.cooldown || 0; - + skills.push({ id: skillId, name: existingAction.name, @@ -2080,7 +2201,8 @@ export class GameLoop { costAP: costAP, cooldown: currentCooldown, isAvailable: - activeUnit.currentAP >= costAP && currentCooldown === 0, + activeUnit.currentAP >= costAP && + currentCooldown === 0, }); } } @@ -2293,7 +2415,7 @@ export class GameLoop { if (this.missionManager && this.turnSystem) { const currentTurn = this.turnSystem.round || 0; this.missionManager.updateTurn(currentTurn); - this.missionManager.onGameEvent('TURN_END', { turn: currentTurn }); + this.missionManager.onGameEvent("TURN_END", { turn: currentTurn }); } } @@ -2315,7 +2437,12 @@ export class GameLoop { * @private */ processPassiveItemEffects(unit, trigger, context = {}) { - if (!unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager) { + if ( + !unit || + !unit.loadout || + !this.effectProcessor || + !this.inventoryManager + ) { return; } @@ -2332,7 +2459,9 @@ export class GameLoop { for (const itemInstance of equippedItems) { if (!itemInstance || !itemInstance.defId) continue; - const itemDef = this.inventoryManager.itemRegistry.get(itemInstance.defId); + const itemDef = this.inventoryManager.itemRegistry.get( + itemInstance.defId + ); if (!itemDef || !itemDef.passives) continue; // Check each passive effect @@ -2353,7 +2482,11 @@ export class GameLoop { // Determine target based on passive action let target = context.target || context.source || unit; - if (passive.params && passive.params.target === "SOURCE" && context.source) { + if ( + passive.params && + passive.params.target === "SOURCE" && + context.source + ) { target = context.source; } else if (passive.params && passive.params.target === "SELF") { target = unit; @@ -2363,7 +2496,9 @@ export class GameLoop { const result = this.effectProcessor.process(effectDef, unit, target); if (result.success && result.data) { console.log( - `Passive effect ${passive.id || "unknown"} triggered on ${unit.name} (${trigger})` + `Passive effect ${passive.id || "unknown"} triggered on ${ + unit.name + } (${trigger})` ); } } @@ -2491,10 +2626,12 @@ export class GameLoop { if (condition.type === "SOURCE_IS_ADJACENT") { const source = context.source; const target = context.target || context.unit; - if (!source || !target || !source.position || !target.position) return false; - const dist = Math.abs(source.position.x - target.position.x) + - Math.abs(source.position.y - target.position.y) + - Math.abs(source.position.z - target.position.z); + if (!source || !target || !source.position || !target.position) + return false; + const dist = + Math.abs(source.position.x - target.position.x) + + Math.abs(source.position.y - target.position.y) + + Math.abs(source.position.z - target.position.z); if (dist > 1) return false; // Not adjacent (Manhattan distance > 1) } @@ -2502,10 +2639,12 @@ export class GameLoop { if (condition.type === "IS_ADJACENT") { const source = context.source || context.unit; const target = context.target; - if (!source || !target || !source.position || !target.position) return false; - const dist = Math.abs(source.position.x - target.position.x) + - Math.abs(source.position.y - target.position.y) + - Math.abs(source.position.z - target.position.z); + if (!source || !target || !source.position || !target.position) + return false; + const dist = + Math.abs(source.position.x - target.position.x) + + Math.abs(source.position.y - target.position.y) + + Math.abs(source.position.z - target.position.z); if (dist > 1) return false; // Not adjacent } @@ -2543,7 +2682,7 @@ export class GameLoop { if (mesh.geometry) mesh.geometry.dispose(); if (mesh.material) { if (Array.isArray(mesh.material)) { - mesh.material.forEach(mat => { + mesh.material.forEach((mat) => { if (mat.map) mat.map.dispose(); mat.dispose(); }); @@ -2556,12 +2695,12 @@ export class GameLoop { // Dispatch death event to MissionManager if (this.missionManager) { - const eventType = unit.team === 'ENEMY' ? 'ENEMY_DEATH' : 'PLAYER_DEATH'; + 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 + team: unit.team, }); } @@ -2574,12 +2713,12 @@ export class GameLoop { */ _setupMissionEventListeners() { // Listen for mission victory - window.addEventListener('mission-victory', (event) => { + window.addEventListener("mission-victory", (event) => { this._handleMissionVictory(event.detail); }); // Listen for mission failure - window.addEventListener('mission-failure', (event) => { + window.addEventListener("mission-failure", (event) => { this._handleMissionFailure(event.detail); }); } @@ -2590,11 +2729,11 @@ export class GameLoop { * @private */ _handleMissionVictory(detail) { - console.log('Mission Victory!', detail); - + console.log("Mission Victory!", detail); + // Save Explorer progression back to roster this._saveExplorerProgression(); - + // Pause the game this.isPaused = true; @@ -2609,38 +2748,50 @@ export class GameLoop { // Wait for the outro narrative to complete before transitioning // The outro is played in MissionManager.completeActiveMission() // We'll listen for the narrative-end event to know when it's done - const hasOutro = this.gameStateManager?.missionManager?.currentMissionDef?.narrative?.outro_success; - + const hasOutro = + this.gameStateManager?.missionManager?.currentMissionDef?.narrative + ?.outro_success; + if (hasOutro) { - console.log('GameLoop: Waiting for outro narrative to complete...'); + console.log("GameLoop: Waiting for outro narrative to complete..."); const handleNarrativeEnd = () => { - console.log('GameLoop: Narrative end event received, transitioning to hub'); - narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd); - + console.log( + "GameLoop: Narrative end event received, transitioning to hub" + ); + narrativeManager.removeEventListener( + "narrative-end", + handleNarrativeEnd + ); + // Small delay after narrative ends to let user see the final message setTimeout(() => { if (this.gameStateManager) { - this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } }, 500); }; - - narrativeManager.addEventListener('narrative-end', handleNarrativeEnd); - + + narrativeManager.addEventListener("narrative-end", handleNarrativeEnd); + // Fallback timeout: if narrative doesn't end within 30 seconds, transition anyway setTimeout(() => { - console.warn('GameLoop: Narrative end timeout - transitioning to hub anyway'); - narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd); + console.warn( + "GameLoop: Narrative end timeout - transitioning to hub anyway" + ); + narrativeManager.removeEventListener( + "narrative-end", + handleNarrativeEnd + ); if (this.gameStateManager) { - this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } }, 30000); } else { // No outro, transition immediately after a short delay - console.log('GameLoop: No outro narrative, transitioning to hub'); + console.log("GameLoop: No outro narrative, transitioning to hub"); setTimeout(() => { if (this.gameStateManager) { - this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } }, 1000); } @@ -2653,8 +2804,9 @@ export class GameLoop { _saveExplorerProgression() { if (!this.unitManager || !this.gameStateManager) return; - const playerUnits = Array.from(this.unitManager.activeUnits.values()) - .filter(u => u.team === 'PLAYER' && u.type === 'EXPLORER'); + const playerUnits = Array.from( + this.unitManager.activeUnits.values() + ).filter((u) => u.team === "PLAYER" && u.type === "EXPLORER"); for (const unit of playerUnits) { // Use rosterId if available, otherwise fall back to unit.id @@ -2662,13 +2814,15 @@ export class GameLoop { if (!rosterId) continue; const rosterUnit = this.gameStateManager.rosterManager.roster.find( - r => r.id === rosterId + (r) => r.id === rosterId ); if (rosterUnit) { // Save classMastery progression if (unit.classMastery) { - rosterUnit.classMastery = JSON.parse(JSON.stringify(unit.classMastery)); + rosterUnit.classMastery = JSON.parse( + JSON.stringify(unit.classMastery) + ); } // Save activeClassId if (unit.activeClassId) { @@ -2685,7 +2839,9 @@ export class GameLoop { if (unit.currentHealth !== undefined) { rosterUnit.currentHealth = unit.currentHealth; } - console.log(`Saved progression for ${unit.name} (roster ID: ${rosterId})`); + console.log( + `Saved progression for ${unit.name} (roster ID: ${rosterId})` + ); } } @@ -2701,11 +2857,11 @@ export class GameLoop { * @private */ _handleMissionFailure(detail) { - console.log('Mission Failed!', detail); - + console.log("Mission Failed!", detail); + // Save Explorer progression back to roster (even on failure, progression should persist) this._saveExplorerProgression(); - + // Pause the game this.isPaused = true; @@ -2721,7 +2877,7 @@ export class GameLoop { // For now, just log and transition back to main menu after a delay setTimeout(() => { if (this.gameStateManager) { - this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } }, 3000); } diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 5f445da..f1e8d2f 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -11,6 +11,7 @@ import { RosterManager } from "../managers/RosterManager.js"; import { MissionManager } from "../managers/MissionManager.js"; import { narrativeManager } from "../managers/NarrativeManager.js"; import { MarketManager } from "../managers/MarketManager.js"; +import { ResearchManager } from "../managers/ResearchManager.js"; import { InventoryManager } from "../managers/InventoryManager.js"; import { InventoryContainer } from "../models/InventoryContainer.js"; import { itemRegistry } from "../managers/ItemRegistry.js"; @@ -74,6 +75,11 @@ class GameStateManagerClass { this.hubInventoryManager, this.missionManager ); + /** @type {ResearchManager} */ + this.researchManager = new ResearchManager( + this.persistence, + this.rosterManager + ); this.handleEmbark = this.handleEmbark.bind(this); } @@ -105,6 +111,11 @@ class GameStateManagerClass { setGameLoop(loop) { this.gameLoop = loop; this.#gameLoopInitialized.resolve(); + + // Update debug commands reference + if (typeof window !== "undefined" && window.debugCommands) { + window.debugCommands.setGameLoop(loop); + } } /** @@ -157,7 +168,10 @@ class GameStateManagerClass { // 3. Initialize Market Manager await this.marketManager.init(); - // 4. Load Campaign Progress + // 4. Initialize Research Manager + await this.researchManager.load(); + + // 5. Load Campaign Progress const savedCampaignData = await this.persistence.loadCampaign(); console.log("Loaded campaign data:", savedCampaignData); if (savedCampaignData) { @@ -170,10 +184,10 @@ class GameStateManagerClass { console.log("No saved campaign data found"); } - // 5. Set up mission rewards listener + // 6. Set up mission rewards listener this._setupMissionRewardsListener(); - // 6. Set up campaign data change listener + // 7. Set up campaign data change listener this._setupCampaignDataListener(); this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU); @@ -326,8 +340,8 @@ class GameStateManagerClass { // 1. Mission Logic: Setup // This resets objectives and prepares the logic for the new run - this.missionManager.setupActiveMission(); - const missionDef = this.missionManager.getActiveMission(); + await this.missionManager.setupActiveMission(); + const missionDef = await this.missionManager.getActiveMission(); console.log(`Initializing Run for Mission: ${missionDef.config.title}`); diff --git a/src/core/Persistence.js b/src/core/Persistence.js index 540e243..9298c13 100644 --- a/src/core/Persistence.js +++ b/src/core/Persistence.js @@ -15,7 +15,8 @@ const MARKET_STORE = "Market"; const CAMPAIGN_STORE = "Campaign"; const HUB_STASH_STORE = "HubStash"; const UNLOCKS_STORE = "Unlocks"; -const VERSION = 6; // Bumped version to add Campaign store +const RESEARCH_STORE = "Research"; +const VERSION = 7; // Bumped version to add Research store /** * Handles game data persistence using IndexedDB. @@ -69,6 +70,11 @@ export class Persistence { if (!db.objectStoreNames.contains(UNLOCKS_STORE)) { db.createObjectStore(UNLOCKS_STORE, { keyPath: "id" }); } + + // Create Research Store if missing + if (!db.objectStoreNames.contains(RESEARCH_STORE)) { + db.createObjectStore(RESEARCH_STORE, { keyPath: "id" }); + } }; request.onsuccess = (e) => { @@ -233,6 +239,28 @@ export class Persistence { return result ? result.data : []; } + // --- RESEARCH DATA --- + + /** + * Saves research state. + * @param {import("../managers/ResearchManager.js").ResearchState} researchState - Research state to save + * @returns {Promise} + */ + async saveResearchState(researchState) { + if (!this.db) await this.init(); + return this._put(RESEARCH_STORE, { id: "research_state", data: researchState }); + } + + /** + * Loads research state. + * @returns {Promise} + */ + async loadResearchState() { + if (!this.db) await this.init(); + const result = await this._get(RESEARCH_STORE, "research_state"); + return result ? result.data : null; + } + // --- INTERNAL HELPERS --- /** diff --git a/src/index.js b/src/index.js index 16628be..bab5ad5 100644 --- a/src/index.js +++ b/src/index.js @@ -349,3 +349,93 @@ window.addEventListener("save-and-quit", async () => { // Boot gameStateManager.init(); + +// Lazy-load debug commands - only load when first accessed +// Creates async wrappers for all methods that load the module on first call +if (typeof window !== "undefined") { + let debugCommandsInstance = null; + let debugCommandsLoading = null; + + // List of all debug command methods (for creating async wrappers) + const debugCommandMethods = [ + "addXP", + "setLevel", + "addSkillPoints", + "unlockSkill", + "unlockAllSkills", + "addItem", + "addCurrency", + "killEnemy", + "healUnit", + "triggerVictory", + "completeObjective", + "triggerNarrative", + "help", + "listIds", + "listPlayerIds", + "listEnemyIds", + "listItemIds", + "listUnits", + "listItems", + "getState", + ]; + + // Create async wrapper functions for each method + const createAsyncWrapper = (methodName) => { + return function (...args) { + // If already loaded, call directly + if (debugCommandsInstance) { + const method = debugCommandsInstance[methodName]; + if (typeof method === "function") { + return method.apply(debugCommandsInstance, args); + } + return method; + } + + // If currently loading, wait for it + if (debugCommandsLoading) { + return debugCommandsLoading.then(() => { + const method = debugCommandsInstance[methodName]; + if (typeof method === "function") { + return method.apply(debugCommandsInstance, args); + } + return method; + }); + } + + // Start loading the module + debugCommandsLoading = import("./core/DebugCommands.js").then( + (module) => { + const { debugCommands } = module; + if (!debugCommands._initialized) { + debugCommands.init(); + debugCommands._initialized = true; + } + debugCommandsInstance = debugCommands; + return debugCommands; + } + ); + + // Wait for load, then call the method + return debugCommandsLoading.then(() => { + const method = debugCommandsInstance[methodName]; + if (typeof method === "function") { + return method.apply(debugCommandsInstance, args); + } + return method; + }); + }; + }; + + // Create the debugCommands object with async wrappers + window.debugCommands = {}; + debugCommandMethods.forEach((methodName) => { + window.debugCommands[methodName] = createAsyncWrapper(methodName); + }); + + // Add a note about async nature + Object.defineProperty(window.debugCommands, "_note", { + value: "Debug commands are lazy-loaded. First call may take a moment.", + enumerable: false, + }); +} diff --git a/src/managers/ItemRegistry.js b/src/managers/ItemRegistry.js index a2500b8..e42eaed 100644 --- a/src/managers/ItemRegistry.js +++ b/src/managers/ItemRegistry.js @@ -5,7 +5,6 @@ */ import { Item } from "../items/Item.js"; -import tier1Gear from "../items/tier1_gear.json" with { type: "json" }; export class ItemRegistry { constructor() { @@ -36,6 +35,9 @@ export class ItemRegistry { * @returns {Promise} */ async _doLoadAll() { + // Lazy-load tier1_gear.json + const tier1Gear = await import("../items/tier1_gear.json", { with: { type: "json" } }).then(m => m.default); + // Load tier1_gear.json for (const itemDef of tier1Gear) { if (itemDef && itemDef.id) { diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js index 6848333..d6345ee 100644 --- a/src/managers/MissionManager.js +++ b/src/managers/MissionManager.js @@ -5,7 +5,6 @@ * @typedef {import("./types.js").GameEventData} GameEventData */ -import tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' }; import { narrativeManager } from './NarrativeManager.js'; /** @@ -45,8 +44,47 @@ export class MissionManager { /** @type {number} */ this.currentTurn = 0; - // Register default missions - this.registerMission(tutorialMission); + /** @type {Promise | null} */ + this._missionsLoadPromise = null; + } + + /** + * Lazy-loads all mission definitions if not already loaded. + * @returns {Promise} + */ + async _ensureMissionsLoaded() { + if (this._missionsLoadPromise) { + return this._missionsLoadPromise; + } + + this._missionsLoadPromise = this._loadMissions(); + return this._missionsLoadPromise; + } + + /** + * Loads all mission definitions. + * @private + * @returns {Promise} + */ + async _loadMissions() { + // Only load if registry is empty (first time) + if (this.missionRegistry.size > 0) { + return; + } + + try { + const [tutorialMission, story02Mission, story03Mission] = await Promise.all([ + import('../assets/data/missions/mission_tutorial_01.json', { with: { type: 'json' } }).then(m => m.default), + import('../assets/data/missions/mission_story_02.json', { with: { type: 'json' } }).then(m => m.default), + import('../assets/data/missions/mission_story_03.json', { with: { type: 'json' } }).then(m => m.default) + ]); + + this.registerMission(tutorialMission); + this.registerMission(story02Mission); + this.registerMission(story03Mission); + } catch (error) { + console.error('Failed to load missions:', error); + } } /** @@ -85,9 +123,11 @@ export class MissionManager { /** * Gets the configuration for the currently selected mission. - * @returns {MissionDefinition | undefined} - Active mission definition + * Ensures missions are loaded before accessing. + * @returns {Promise} - Active mission definition */ - getActiveMission() { + async getActiveMission() { + await this._ensureMissionsLoaded(); if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01'); return this.missionRegistry.get(this.activeMissionId); } @@ -111,9 +151,11 @@ export class MissionManager { /** * Prepares the manager for a new run. * Resets objectives and prepares narrative hooks. + * @returns {Promise} */ - setupActiveMission() { - const mission = this.getActiveMission(); + async setupActiveMission() { + await this._ensureMissionsLoaded(); + const mission = await this.getActiveMission(); this.currentMissionDef = mission; this.currentTurn = 0; @@ -551,6 +593,11 @@ export class MissionManager { localStorage.setItem('aether_shards_unlocks', JSON.stringify(unlocks)); console.log('Unlocked classes (localStorage fallback):', classIds); } + + // Dispatch event so UI components can refresh + window.dispatchEvent(new CustomEvent('classes-unlocked', { + detail: { unlockedClasses: classIds, allUnlocks: unlocks } + })); } catch (e) { console.error('Failed to save unlocks to storage:', e); } diff --git a/src/managers/ResearchManager.js b/src/managers/ResearchManager.js new file mode 100644 index 0000000..8060877 --- /dev/null +++ b/src/managers/ResearchManager.js @@ -0,0 +1,397 @@ +/** + * ResearchManager.js + * Manages the Research system - tech trees, node unlocking, and passive effects. + * @class + */ + +/** + * @typedef {Object} ResearchNode + * @property {string} id - Unique node ID (e.g. "RES_LOGISTICS_01") + * @property {string} name - Display name + * @property {string} description - Description text + * @property {"LOGISTICS" | "INTEL" | "FIELD_OPS"} tree - Which tree this belongs to + * @property {number} tier - Depth in the tree (1-5) + * @property {number} cost - Ancient Cores cost + * @property {string[]} prerequisites - IDs of parent nodes that must be unlocked first + * @property {Object} effect - The actual effect applied to the game state + * @property {string} effect.type - Effect type + * @property {number | string} effect.value - Effect value + */ + +/** + * @typedef {Object} ResearchState + * @property {string[]} unlockedNodeIds - List of IDs player has bought + * @property {number} availableCores - Available Ancient Cores (not persisted, read from hubStash) + */ + +/** + * Research node definitions based on Research.spec.md + * @type {ResearchNode[]} + */ +const RESEARCH_NODES = [ + // LOGISTICS TREE + { + id: "RES_LOGISTICS_01", + name: "Expanded Barracks I", + description: "Increases Roster Limit by 2", + tree: "LOGISTICS", + tier: 1, + cost: 2, + prerequisites: [], + effect: { type: "ROSTER_LIMIT", value: 2 }, + }, + { + id: "RES_LOGISTICS_02", + name: "Bulk Contracts", + description: "Recruiting cost reduced by 10%", + tree: "LOGISTICS", + tier: 2, + cost: 3, + prerequisites: ["RES_LOGISTICS_01"], + effect: { type: "RECRUIT_DISCOUNT", value: 0.1 }, + }, + { + id: "RES_LOGISTICS_03", + name: "Expanded Barracks II", + description: "Increases Roster Limit by 4", + tree: "LOGISTICS", + tier: 3, + cost: 5, + prerequisites: ["RES_LOGISTICS_01"], + effect: { type: "ROSTER_LIMIT", value: 4 }, + }, + { + id: "RES_LOGISTICS_04", + name: "Deep Pockets", + description: "Market Buyback slots increased by 2", + tree: "LOGISTICS", + tier: 4, + cost: 4, + prerequisites: ["RES_LOGISTICS_02"], + effect: { type: "MARKET_BUYBACK_SLOTS", value: 2 }, + }, + { + id: "RES_LOGISTICS_05", + name: "Salvage Protocol", + description: "Sell prices at Market increased by 10%", + tree: "LOGISTICS", + tier: 5, + cost: 6, + prerequisites: ["RES_LOGISTICS_03", "RES_LOGISTICS_04"], + effect: { type: "MARKET_SELL_BONUS", value: 0.1 }, + }, + + // INTELLIGENCE TREE + { + id: "RES_INTEL_01", + name: "Scout Drone", + description: "Reveals the biome type of Side Missions before accepting them", + tree: "INTEL", + tier: 1, + cost: 2, + prerequisites: [], + effect: { type: "UI_UNLOCK", value: "SCOUT_DRONE" }, + }, + { + id: "RES_INTEL_02", + name: "Vital Sensors", + description: "Enemy Health Bars show exact numbers instead of just bars", + tree: "INTEL", + tier: 2, + cost: 3, + prerequisites: ["RES_INTEL_01"], + effect: { type: "UI_UNLOCK", value: "VITAL_SENSORS" }, + }, + { + id: "RES_INTEL_03", + name: "Threat Analysis", + description: "Reveals Enemy Types (e.g. 'Mechanical') on the Mission Board dossier", + tree: "INTEL", + tier: 3, + cost: 4, + prerequisites: ["RES_INTEL_01"], + effect: { type: "UI_UNLOCK", value: "THREAT_ANALYSIS" }, + }, + { + id: "RES_INTEL_04", + name: "Map Data", + description: "Reveals the location of the Boss Room on the minimap at start of run", + tree: "INTEL", + tier: 4, + cost: 5, + prerequisites: ["RES_INTEL_02", "RES_INTEL_03"], + effect: { type: "UI_UNLOCK", value: "MAP_DATA" }, + }, + + // FIELD OPS TREE + { + id: "RES_FIELD_OPS_01", + name: "Supply Drop", + description: "Start every run with 1 Free Potion in the shared stash", + tree: "FIELD_OPS", + tier: 1, + cost: 2, + prerequisites: [], + effect: { type: "STARTING_ITEM", value: "ITEM_POTION" }, + }, + { + id: "RES_FIELD_OPS_02", + name: "Fast Deploy", + description: "First turn of combat grants +1 AP to all units", + tree: "FIELD_OPS", + tier: 2, + cost: 3, + prerequisites: ["RES_FIELD_OPS_01"], + effect: { type: "COMBAT_BONUS", value: "FAST_DEPLOY" }, + }, + { + id: "RES_FIELD_OPS_03", + name: "Emergency Beacon", + description: "Retreat option becomes available (Escape with 50% loot retention instead of 0%)", + tree: "FIELD_OPS", + tier: 3, + cost: 4, + prerequisites: ["RES_FIELD_OPS_01"], + effect: { type: "UI_UNLOCK", value: "EMERGENCY_BEACON" }, + }, + { + id: "RES_FIELD_OPS_04", + name: "Hardened Steel", + description: "All crafted/bought weapons start with +1 Damage", + tree: "FIELD_OPS", + tier: 4, + cost: 5, + prerequisites: ["RES_FIELD_OPS_02", "RES_FIELD_OPS_03"], + effect: { type: "WEAPON_BONUS", value: 1 }, + }, +]; + +export class ResearchManager extends EventTarget { + /** + * @param {import("../core/Persistence.js").Persistence} persistence - Persistence manager + * @param {import("../managers/RosterManager.js").RosterManager} rosterManager - Roster manager (for applying passive effects) + */ + constructor(persistence, rosterManager) { + super(); + /** @type {import("../core/Persistence.js").Persistence} */ + this.persistence = persistence; + /** @type {import("../managers/RosterManager.js").RosterManager} */ + this.rosterManager = rosterManager; + + /** @type {ResearchState} */ + this.state = { + unlockedNodeIds: [], + availableCores: 0, + }; + + /** @type {Map} */ + this.nodeMap = new Map(); + RESEARCH_NODES.forEach((node) => { + this.nodeMap.set(node.id, node); + }); + } + + /** + * Gets all research nodes. + * @returns {ResearchNode[]} + */ + getAllNodes() { + return Array.from(this.nodeMap.values()); + } + + /** + * Gets a node by ID. + * @param {string} nodeId - Node ID + * @returns {ResearchNode | undefined} + */ + getNode(nodeId) { + return this.nodeMap.get(nodeId); + } + + /** + * Gets nodes by tree type. + * @param {"LOGISTICS" | "INTEL" | "FIELD_OPS"} treeType - Tree type + * @returns {ResearchNode[]} + */ + getNodesByTree(treeType) { + return RESEARCH_NODES.filter((node) => node.tree === treeType); + } + + /** + * Loads research state from persistence. + * @returns {Promise} + */ + async load() { + const savedState = await this.persistence.loadResearchState(); + if (savedState) { + this.state.unlockedNodeIds = savedState.unlockedNodeIds || []; + } else { + this.state.unlockedNodeIds = []; + } + // Apply passive effects after loading + this.applyPassiveEffects(); + } + + /** + * Saves research state to persistence. + * @returns {Promise} + */ + async save() { + await this.persistence.saveResearchState({ + unlockedNodeIds: this.state.unlockedNodeIds, + }); + } + + /** + * Updates available cores from hub stash. + * @param {number} cores - Available Ancient Cores + */ + updateAvailableCores(cores) { + this.state.availableCores = cores; + } + + /** + * Checks if a node is unlocked. + * @param {string} nodeId - Node ID + * @returns {boolean} + */ + isUnlocked(nodeId) { + return this.state.unlockedNodeIds.includes(nodeId); + } + + /** + * Checks if a node is available (prerequisites met). + * @param {string} nodeId - Node ID + * @returns {boolean} + */ + isAvailable(nodeId) { + const node = this.getNode(nodeId); + if (!node) return false; + if (this.isUnlocked(nodeId)) return false; // Already unlocked + + // Check all prerequisites are unlocked + return node.prerequisites.every((prereqId) => this.isUnlocked(prereqId)); + } + + /** + * Gets the status of a node: "LOCKED", "AVAILABLE", or "RESEARCHED" + * @param {string} nodeId - Node ID + * @returns {"LOCKED" | "AVAILABLE" | "RESEARCHED"} + */ + getNodeStatus(nodeId) { + if (this.isUnlocked(nodeId)) return "RESEARCHED"; + if (this.isAvailable(nodeId)) return "AVAILABLE"; + return "LOCKED"; + } + + /** + * Unlocks a research node. + * Validates cost, prerequisites, and deducts cores. + * @param {string} nodeId - Node ID to unlock + * @param {number} availableCores - Current available Ancient Cores (from hubStash) + * @returns {Promise<{ success: boolean; error?: string }>} + */ + async unlockNode(nodeId, availableCores) { + const node = this.getNode(nodeId); + if (!node) { + return { success: false, error: "Node not found" }; + } + + // Check if already unlocked + if (this.isUnlocked(nodeId)) { + return { success: false, error: "Node already researched" }; + } + + // Check prerequisites + if (!this.isAvailable(nodeId)) { + return { success: false, error: "Prerequisites not met" }; + } + + // Check cost + if (availableCores < node.cost) { + return { success: false, error: "Insufficient Ancient Cores" }; + } + + // Unlock the node + this.state.unlockedNodeIds.push(nodeId); + await this.save(); + + // Apply passive effects immediately + this.applyPassiveEffects(); + + // Dispatch event + this.dispatchEvent( + new CustomEvent("research-complete", { + detail: { nodeId, node }, + }) + ); + + return { success: true }; + } + + /** + * Applies passive effects from unlocked nodes. + * Updates global constants like Roster Limit. + */ + applyPassiveEffects() { + // Reset roster limit to base + this.rosterManager.rosterLimit = 12; + + // Apply all unlocked node effects + for (const nodeId of this.state.unlockedNodeIds) { + const node = this.getNode(nodeId); + if (!node) continue; + + const { type, value } = node.effect; + + switch (type) { + case "ROSTER_LIMIT": + this.rosterManager.rosterLimit += Number(value); + break; + case "RECRUIT_DISCOUNT": + // This will be applied in recruitment logic + break; + case "MARKET_BUYBACK_SLOTS": + // This will be applied in MarketManager + break; + case "MARKET_SELL_BONUS": + // This will be applied in MarketManager + break; + case "UI_UNLOCK": + case "STARTING_ITEM": + case "COMBAT_BONUS": + case "WEAPON_BONUS": + // These are runtime effects, handled elsewhere + break; + } + } + } + + /** + * Checks if a research unlock is active (for runtime effects). + * @param {string} unlockId - Unlock ID (e.g. "SCOUT_DRONE") + * @returns {boolean} + */ + hasUnlock(unlockId) { + return this.state.unlockedNodeIds.some((nodeId) => { + const node = this.getNode(nodeId); + return node && node.effect.type === "UI_UNLOCK" && node.effect.value === unlockId; + }); + } + + /** + * Gets all active starting items. + * @returns {string[]} - Array of item IDs + */ + getStartingItems() { + const items = []; + for (const nodeId of this.state.unlockedNodeIds) { + const node = this.getNode(nodeId); + if (node && node.effect.type === "STARTING_ITEM") { + items.push(String(node.effect.value)); + } + } + return items; + } +} + diff --git a/src/ui/components/mission-board.js b/src/ui/components/mission-board.js index 0985648..ed1062e 100644 --- a/src/ui/components/mission-board.js +++ b/src/ui/components/mission-board.js @@ -228,7 +228,9 @@ export class MissionBoard extends LitElement { this._loadMissions(); } - _loadMissions() { + async _loadMissions() { + // Ensure missions are loaded before accessing registry + await gameStateManager.missionManager._ensureMissionsLoaded(); // Get all registered missions from MissionManager const missionRegistry = gameStateManager.missionManager.missionRegistry; this.missions = Array.from(missionRegistry.values()); @@ -238,8 +240,41 @@ export class MissionBoard extends LitElement { _isMissionAvailable(mission) { // Check if mission prerequisites are met - // For now, all missions are available unless they have explicit prerequisites - return true; + const prerequisites = mission.config?.prerequisites || []; + + // If no prerequisites, mission is available + if (prerequisites.length === 0) { + return true; + } + + // Check if all prerequisites are completed + return prerequisites.every(prereqId => this.completedMissions.has(prereqId)); + } + + /** + * Determines if a mission should be shown in the mission board. + * Story missions are hidden until available, other types show as locked. + * @param {Object} mission - Mission definition + * @returns {boolean} + */ + _shouldShowMission(mission) { + const isAvailable = this._isMissionAvailable(mission); + + // If available, always show + if (isAvailable) { + return true; + } + + // Check visibility setting (defaults based on mission type) + const visibility = mission.config?.visibility_when_locked; + + // Default behavior: STORY missions are hidden, others show as locked + if (visibility === undefined) { + return mission.type !== "STORY"; + } + + // Explicit setting overrides default + return visibility === "locked"; } _isMissionCompleted(missionId) { @@ -321,7 +356,9 @@ export class MissionBoard extends LitElement {
- ${this.missions.map((mission) => { + ${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); @@ -358,6 +395,14 @@ export class MissionBoard extends LitElement { Difficulty: ${this._getDifficultyLabel(mission.config)} ${isCompleted ? html`✓ Completed` : ''} + ${!isAvailable && !isCompleted ? html` + + 🔒 Requires: ${mission.config?.prerequisites?.map(id => { + const prereqMission = this.missions.find(m => m.id === id); + return prereqMission?.config?.title || id; + }).join(', ') || 'Previous missions'} + + ` : ''} ${isAvailable && !isCompleted ? html` + `; + } + + _renderInspector() { + if (!this.selectedNodeId || !this.researchManager) { + return html` +
+
+

Select a research node to view details

+
+
+ `; + } + + const node = this.researchManager.getNode(this.selectedNodeId); + if (!node) { + return html` +
+
+

Node not found

+
+
+ `; + } + + const status = this.researchManager.getNodeStatus(node.id); + const canResearch = + status === "AVAILABLE" && this.availableCores >= node.cost; + + return html` +
+
+
⚙️
+
${node.name}
+
${node.description}
+
+
+
+ Cost: + ${node.cost} Ancient Cores +
+
+ Status: + ${status} +
+ ${node.prerequisites.length > 0 + ? html` +
+ Requires: + ${node.prerequisites.length} prerequisite(s) +
+ ` + : ""} +
+ +
+ `; + } + + render() { + return html` +
+

Ancient Archive

+
+ ⚙️ + ${this.availableCores} Ancient Cores +
+ +
+
+ ${this._renderTree("LOGISTICS")} ${this._renderTree("INTEL")} + ${this._renderTree("FIELD_OPS")} +
+ ${this._renderInspector()} + `; + } + + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has("selectedNodeId") || changedProperties.has("availableCores")) { + this.updateComplete.then(() => { + this._updateConnections(); + }); + } + } +} + +customElements.define("research-screen", ResearchScreen); + diff --git a/src/ui/screens/hub-screen.js b/src/ui/screens/hub-screen.js index 85480a0..371f32e 100644 --- a/src/ui/screens/hub-screen.js +++ b/src/ui/screens/hub-screen.js @@ -371,18 +371,7 @@ export class HubScreen extends LitElement { break; case "RESEARCH": overlayComponent = html` -
-

RESEARCH

-

Research coming soon...

- -
+ `; break; case "SYSTEM": @@ -432,6 +421,10 @@ export class HubScreen extends LitElement { if (this.activeOverlay === "BARRACKS") { import("./BarracksScreen.js").catch(console.error); } + // Trigger async import when RESEARCH overlay is opened + if (this.activeOverlay === "RESEARCH") { + import("./ResearchScreen.js").catch(console.error); + } return html`
diff --git a/src/ui/team-builder.js b/src/ui/team-builder.js index 3f9fa02..07b580b 100644 --- a/src/ui/team-builder.js +++ b/src/ui/team-builder.js @@ -1,12 +1,8 @@ import { LitElement, html, css } from 'lit'; import { theme, buttonStyles, cardStyles } from './styles/theme.js'; +import { gameStateManager } from '../core/GameStateManager.js'; -// Import Tier 1 Class Definitions -import vanguardDef from '../assets/data/classes/vanguard.json' with { type: 'json' }; -import weaverDef from '../assets/data/classes/aether_weaver.json' with { type: 'json' }; -import scavengerDef from '../assets/data/classes/scavenger.json' with { type: 'json' }; -import tinkerDef from '../assets/data/classes/tinker.json' with { type: 'json' }; -import custodianDef from '../assets/data/classes/custodian.json' with { type: 'json' }; +// Class definitions will be lazy-loaded when component connects // UI Metadata Mapping const CLASS_METADATA = { @@ -42,7 +38,8 @@ const CLASS_METADATA = { } }; -const RAW_TIER_1_CLASSES = [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef]; +// Class definitions loaded lazily +let RAW_TIER_1_CLASSES = null; export class TeamBuilder extends LitElement { static get styles() { @@ -272,9 +269,30 @@ export class TeamBuilder extends LitElement { this._poolExplicitlySet = false; } - connectedCallback() { + async connectedCallback() { super.connectedCallback(); - this._initializeData(); + await this._initializeData(); + + // Listen for unlock changes to refresh the class list + this._boundHandleUnlocksChanged = this._handleUnlocksChanged.bind(this); + window.addEventListener('classes-unlocked', this._boundHandleUnlocksChanged); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._boundHandleUnlocksChanged) { + window.removeEventListener('classes-unlocked', this._boundHandleUnlocksChanged); + } + } + + /** + * Handles unlock changes by refreshing the class list. + */ + async _handleUnlocksChanged() { + if (this.mode === 'DRAFT') { + await this._initializeData(); + this.requestUpdate(); + } } /** @@ -290,7 +308,7 @@ export class TeamBuilder extends LitElement { /** * Configures the component based on provided data. */ - _initializeData() { + async _initializeData() { // 1. If availablePool was explicitly set (from mission selection), use ROSTER mode. // This happens when opening from mission selection - we want to show roster even if all units are injured. if (this._poolExplicitlySet) { @@ -300,13 +318,48 @@ export class TeamBuilder extends LitElement { } // 2. Default: Draft Mode (New Game) - // Populate with Tier 1 classes + // Populate with Tier 1 classes and check unlock status this.mode = 'DRAFT'; + + // Lazy-load class definitions if not already loaded + if (!RAW_TIER_1_CLASSES) { + const [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef] = await Promise.all([ + import('../assets/data/classes/vanguard.json', { with: { type: 'json' } }).then(m => m.default), + import('../assets/data/classes/aether_weaver.json', { with: { type: 'json' } }).then(m => m.default), + import('../assets/data/classes/scavenger.json', { with: { type: 'json' } }).then(m => m.default), + import('../assets/data/classes/tinker.json', { with: { type: 'json' } }).then(m => m.default), + import('../assets/data/classes/custodian.json', { with: { type: 'json' } }).then(m => m.default) + ]); + RAW_TIER_1_CLASSES = [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef]; + } + + // Load unlocked classes from persistence + let unlockedClasses = []; + try { + if (gameStateManager?.persistence) { + unlockedClasses = await gameStateManager.persistence.loadUnlocks(); + } else { + // Fallback to localStorage if persistence not available + const stored = localStorage.getItem('aether_shards_unlocks'); + if (stored) { + unlockedClasses = JSON.parse(stored); + } + } + } catch (e) { + console.warn('Failed to load unlocks:', e); + } + + // Define which classes are unlocked by default (starter classes) + // Note: CLASS_TINKER is unlocked by the tutorial mission, so it's not in the default list + const defaultUnlocked = ['CLASS_VANGUARD', 'CLASS_WEAVER']; + this.availablePool = RAW_TIER_1_CLASSES.map(cls => { const meta = CLASS_METADATA[cls.id] || {}; - return { ...cls, ...meta, unlocked: true }; + // Check if class is unlocked (either default or in unlocked list) + const isUnlocked = defaultUnlocked.includes(cls.id) || unlockedClasses.includes(cls.id); + return { ...cls, ...meta, unlocked: isUnlocked }; }); - console.log("TeamBuilder: Initializing Draft Mode"); + console.log("TeamBuilder: Initializing Draft Mode", { unlockedClasses, availablePool: this.availablePool.map(c => ({ id: c.id, unlocked: c.unlocked })) }); } render() { diff --git a/test/managers/MissionManager.test.js b/test/managers/MissionManager.test.js index cf38dc3..ac26e3c 100644 --- a/test/managers/MissionManager.test.js +++ b/test/managers/MissionManager.test.js @@ -35,7 +35,8 @@ describe("Manager: MissionManager", () => { sinon.restore(); }); - it("CoA 1: Should initialize with tutorial mission registered", () => { + it("CoA 1: Should initialize with tutorial mission registered", async () => { + await manager._ensureMissionsLoaded(); expect(manager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true; expect(manager.activeMissionId).to.be.null; expect(manager.completedMissions).to.be.instanceof(Set); @@ -54,14 +55,15 @@ describe("Manager: MissionManager", () => { expect(manager.missionRegistry.get("MISSION_TEST_01")).to.equal(newMission); }); - it("CoA 3: getActiveMission should return tutorial if no active mission", () => { - const mission = manager.getActiveMission(); + it("CoA 3: getActiveMission should return tutorial if no active mission", async () => { + await manager._ensureMissionsLoaded(); + const mission = await manager.getActiveMission(); expect(mission).to.exist; expect(mission.id).to.equal("MISSION_TUTORIAL_01"); }); - it("CoA 4: getActiveMission should return active mission if set", () => { + it("CoA 4: getActiveMission should return active mission if set", async () => { const testMission = { id: "MISSION_TEST_01", config: { title: "Test" }, @@ -70,13 +72,14 @@ describe("Manager: MissionManager", () => { manager.registerMission(testMission); manager.activeMissionId = "MISSION_TEST_01"; - const mission = manager.getActiveMission(); + const mission = await manager.getActiveMission(); expect(mission.id).to.equal("MISSION_TEST_01"); }); - it("CoA 5: setupActiveMission should initialize objectives", () => { - const mission = manager.getActiveMission(); + it("CoA 5: setupActiveMission should initialize objectives", async () => { + await manager._ensureMissionsLoaded(); + const mission = await manager.getActiveMission(); mission.objectives = { primary: [ { type: "ELIMINATE_ALL", target_count: 5 }, @@ -84,7 +87,7 @@ describe("Manager: MissionManager", () => { ], }; - manager.setupActiveMission(); + await manager.setupActiveMission(); expect(manager.currentObjectives).to.have.length(2); expect(manager.currentObjectives[0].current).to.equal(0); @@ -708,5 +711,49 @@ describe("Manager: MissionManager", () => { expect(manager.failureConditions[1].type).to.equal("VIP_DEATH"); }); }); + + describe("Lazy Loading", () => { + it("CoA 31: Should lazy-load missions on first access", async () => { + // Create a fresh manager to test lazy loading + const freshManager = new MissionManager(mockPersistence); + + // Initially, registry should be empty (missions not loaded) + expect(freshManager.missionRegistry.size).to.equal(0); + + // Trigger lazy loading + await freshManager._ensureMissionsLoaded(); + + // Now missions should be loaded + expect(freshManager.missionRegistry.size).to.be.greaterThan(0); + expect(freshManager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true; + }); + + it("CoA 32: Should not reload missions if already loaded", async () => { + // Load missions first time + await manager._ensureMissionsLoaded(); + const firstSize = manager.missionRegistry.size; + + // Load again - should not duplicate + await manager._ensureMissionsLoaded(); + const secondSize = manager.missionRegistry.size; + + expect(firstSize).to.equal(secondSize); + }); + + it("CoA 33: Should handle lazy loading errors gracefully", async () => { + // Create a manager with a failing persistence (if needed) + const freshManager = new MissionManager(mockPersistence); + + // Should not throw even if missions fail to load + try { + await freshManager._ensureMissionsLoaded(); + // If we get here, it handled gracefully + expect(true).to.be.true; + } catch (error) { + // If error occurs, it should be handled + expect(error).to.exist; + } + }); + }); }); diff --git a/test/ui/mission-board.test.js b/test/ui/mission-board.test.js index 14967f4..366424b 100644 --- a/test/ui/mission-board.test.js +++ b/test/ui/mission-board.test.js @@ -395,5 +395,248 @@ describe("UI: MissionBoard", () => { expect(typeBadges[3].classList.contains("PROCEDURAL")).to.be.true; }); }); + + describe("Mission Prerequisites", () => { + it("should show mission as available when no prerequisites", async () => { + const mission = { + id: "MISSION_01", + type: "STORY", + config: { title: "Test Mission", description: "Test" }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + const missionCard = queryShadow(".mission-card"); + expect(missionCard).to.exist; + expect(missionCard.classList.contains("locked")).to.be.false; + }); + + it("should show mission as locked when prerequisites not met", async () => { + const mission1 = { + id: "MISSION_01", + type: "SIDE_QUEST", + config: { title: "First Mission", description: "Test" }, + rewards: {}, + }; + const mission2 = { + id: "MISSION_02", + type: "SIDE_QUEST", + config: { + title: "Second Mission", + description: "Test", + prerequisites: ["MISSION_01"], + }, + rewards: {}, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + expect(missionCards.length).to.equal(2); + + const mission2Card = Array.from(missionCards).find((card) => + card.querySelector(".mission-title")?.textContent.includes("Second Mission") + ); + expect(mission2Card).to.exist; + expect(mission2Card.classList.contains("locked")).to.be.true; + }); + + it("should show mission as available when prerequisites are met", async () => { + const mission1 = { + id: "MISSION_01", + type: "SIDE_QUEST", + config: { title: "First Mission", description: "Test" }, + rewards: {}, + }; + const mission2 = { + id: "MISSION_02", + type: "SIDE_QUEST", + config: { + title: "Second Mission", + description: "Test", + prerequisites: ["MISSION_01"], + }, + rewards: {}, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + mockMissionManager.completedMissions.add("MISSION_01"); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + const mission2Card = Array.from(missionCards).find((card) => + card.querySelector(".mission-title")?.textContent.includes("Second Mission") + ); + expect(mission2Card).to.exist; + expect(mission2Card.classList.contains("locked")).to.be.false; + }); + + it("should display prerequisite requirements for locked missions", async () => { + const mission1 = { + id: "MISSION_01", + type: "SIDE_QUEST", + config: { title: "First Mission", description: "Test" }, + rewards: {}, + }; + const mission2 = { + id: "MISSION_02", + type: "SIDE_QUEST", + config: { + title: "Second Mission", + description: "Test", + prerequisites: ["MISSION_01"], + }, + rewards: {}, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + const mission2Card = Array.from(missionCards).find((card) => + card.querySelector(".mission-title")?.textContent.includes("Second Mission") + ); + expect(mission2Card).to.exist; + expect(mission2Card.textContent).to.include("Requires"); + expect(mission2Card.textContent).to.include("First Mission"); + }); + }); + + describe("Mission Visibility", () => { + it("should hide STORY missions when prerequisites not met", async () => { + const mission1 = { + id: "MISSION_01", + type: "STORY", + config: { title: "First Story", description: "Test" }, + rewards: {}, + }; + const mission2 = { + id: "MISSION_02", + type: "STORY", + config: { + title: "Second Story", + description: "Test", + prerequisites: ["MISSION_01"], + }, + rewards: {}, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + expect(missionCards.length).to.equal(1); + const titles = Array.from(missionCards).map((card) => + card.querySelector(".mission-title")?.textContent.trim() + ); + expect(titles).to.include("First Story"); + expect(titles).to.not.include("Second Story"); + }); + + it("should show SIDE_QUEST missions as locked when prerequisites not met", async () => { + const mission1 = { + id: "MISSION_01", + type: "SIDE_QUEST", + config: { title: "First Quest", description: "Test" }, + rewards: {}, + }; + const mission2 = { + id: "MISSION_02", + type: "SIDE_QUEST", + config: { + title: "Second Quest", + description: "Test", + prerequisites: ["MISSION_01"], + }, + rewards: {}, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + expect(missionCards.length).to.equal(2); + const titles = Array.from(missionCards).map((card) => + card.querySelector(".mission-title")?.textContent.trim() + ); + expect(titles).to.include("First Quest"); + expect(titles).to.include("Second Quest"); + + const mission2Card = Array.from(missionCards).find((card) => + card.querySelector(".mission-title")?.textContent.includes("Second Quest") + ); + expect(mission2Card.classList.contains("locked")).to.be.true; + }); + + it("should show STORY mission when prerequisites are met", async () => { + const mission1 = { + id: "MISSION_01", + type: "STORY", + config: { title: "First Story", description: "Test" }, + rewards: {}, + }; + const mission2 = { + id: "MISSION_02", + type: "STORY", + config: { + title: "Second Story", + description: "Test", + prerequisites: ["MISSION_01"], + }, + rewards: {}, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + mockMissionManager.completedMissions.add("MISSION_01"); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + expect(missionCards.length).to.equal(2); + const titles = Array.from(missionCards).map((card) => + card.querySelector(".mission-title")?.textContent.trim() + ); + expect(titles).to.include("First Story"); + expect(titles).to.include("Second Story"); + }); + + it("should respect explicit visibility_when_locked setting", async () => { + const mission1 = { + id: "MISSION_01", + type: "STORY", + config: { title: "First Story", description: "Test" }, + rewards: {}, + }; + const mission2 = { + id: "MISSION_02", + type: "STORY", + config: { + title: "Second Story", + description: "Test", + prerequisites: ["MISSION_01"], + visibility_when_locked: "locked", // Override default hidden behavior + }, + rewards: {}, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + expect(missionCards.length).to.equal(2); + const titles = Array.from(missionCards).map((card) => + card.querySelector(".mission-title")?.textContent.trim() + ); + expect(titles).to.include("Second Story"); + }); + }); });