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.
This commit is contained in:
Matthew Mone 2026-01-01 09:18:09 -08:00
parent 8d2baacd5f
commit a7c60ac56d
26 changed files with 3312 additions and 231 deletions

View file

@ -35,6 +35,7 @@ alwaysApply: true
## **Coding Style** ## **Coding Style**
- Use ES6 Modules (import/export). - 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. - Prefer const over let. No var.
- Use JSDoc for all public methods and complex algorithms. - Use JSDoc for all public methods and complex algorithms.
- **No Circular Dependencies:** Managers should not import GameLoop. GameLoop acts as the orchestrator. - **No Circular Dependencies:** Managers should not import GameLoop. GameLoop acts as the orchestrator.

View file

@ -11,6 +11,7 @@ alwaysApply: false
- Use **LitElement** for all UI components. - Use **LitElement** for all UI components.
- Filename should match the component name (kebab-case) - Filename should match the component name (kebab-case)
- Styles must be scoped within static get styles(). - Styles must be scoped within static get styles().
- Use theme styles where applicable
## **Integration Logic** ## **Integration Logic**

105
specs/Research.spec.md Normal file
View file

@ -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.

View file

@ -41,6 +41,14 @@ export interface MissionConfig {
recommended_level?: number; recommended_level?: number;
/** Path to icon image */ /** Path to icon image */
icon?: string; 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 --- // --- BIOME / WORLD GEN ---

View file

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

View file

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

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

911
src/core/DebugCommands.js Normal file
View file

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

View file

@ -27,12 +27,7 @@ import { InventoryContainer } from "../models/InventoryContainer.js";
import { itemRegistry } from "../managers/ItemRegistry.js"; import { itemRegistry } from "../managers/ItemRegistry.js";
import { narrativeManager } from "../managers/NarrativeManager.js"; import { narrativeManager } from "../managers/NarrativeManager.js";
// Import class definitions // Class definitions will be lazy-loaded when startLevel is called
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" };
/** /**
* Main game loop managing rendering, input, and game state. * Main game loop managing rendering, input, and game state.
@ -165,7 +160,11 @@ export class GameLoop {
const runStash = new InventoryContainer("RUN_LOOT"); const runStash = new InventoryContainer("RUN_LOOT");
const hubStash = new InventoryContainer("HUB_VAULT"); const hubStash = new InventoryContainer("HUB_VAULT");
// Initialize InventoryManager with itemRegistry (will load items in startLevel) // 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 --- // --- SETUP INPUT MANAGER ---
this.inputManager = new InputManager( this.inputManager = new InputManager(
@ -366,7 +365,9 @@ export class GameLoop {
if (!activeUnit || activeUnit.team !== "PLAYER") { if (!activeUnit || activeUnit.team !== "PLAYER") {
// If no active unit or not player unit, try to get first player unit // If no active unit or not player unit, try to get first player unit
if (this.unitManager) { 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) { if (playerUnits.length > 0) {
this._dispatchOpenCharacterSheet(playerUnits[0]); this._dispatchOpenCharacterSheet(playerUnits[0]);
} }
@ -399,7 +400,8 @@ export class GameLoop {
// Determine if read-only (enemy turn or restricted) // Determine if read-only (enemy turn or restricted)
const activeUnit = this.turnSystem?.getActiveUnit(); const activeUnit = this.turnSystem?.getActiveUnit();
const isReadOnly = this.combatState === "TARGETING_SKILL" || const isReadOnly =
this.combatState === "TARGETING_SKILL" ||
(activeUnit && activeUnit.team !== "PLAYER"); (activeUnit && activeUnit.team !== "PLAYER");
window.dispatchEvent( window.dispatchEvent(
@ -531,7 +533,10 @@ export class GameLoop {
if (!activeUnit || activeUnit.team !== "PLAYER") return; if (!activeUnit || activeUnit.team !== "PLAYER") return;
// If clicking the same skill that's already active, cancel targeting // 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(); this.cancelSkillTargeting();
return; return;
} }
@ -558,8 +563,10 @@ export class GameLoop {
const skillDef = this.skillTargetingSystem.getSkillDef(skillId); const skillDef = this.skillTargetingSystem.getSkillDef(skillId);
if (skillDef && this.voxelManager && this.skillTargetingSystem) { if (skillDef && this.voxelManager && this.skillTargetingSystem) {
// Check if this is a teleport skill with unlimited range (range = -1) // Check if this is a teleport skill with unlimited range (range = -1)
const isTeleportSkill = skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT"); const isTeleportSkill =
const hasUnlimitedRange = skillDef.range === -1 || skillDef.range === Infinity; skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT");
const hasUnlimitedRange =
skillDef.range === -1 || skillDef.range === Infinity;
let allTilesInRange = []; let allTilesInRange = [];
@ -579,9 +586,21 @@ export class GameLoop {
} }
} else { } else {
// For normal skills, get tiles within range // For normal skills, get tiles within range
for (let x = activeUnit.position.x - skillDef.range; x <= activeUnit.position.x + skillDef.range; x++) { for (
for (let y = activeUnit.position.y - skillDef.range; y <= activeUnit.position.y + skillDef.range; y++) { let x = activeUnit.position.x - skillDef.range;
for (let z = activeUnit.position.z - skillDef.range; z <= activeUnit.position.z + skillDef.range; z++) { 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 = const dist =
Math.abs(x - activeUnit.position.x) + Math.abs(x - activeUnit.position.x) +
Math.abs(y - activeUnit.position.y) + Math.abs(y - activeUnit.position.y) +
@ -608,13 +627,16 @@ export class GameLoop {
if (validation.valid) { if (validation.valid) {
validTilesWithObstruction.push({ validTilesWithObstruction.push({
pos: tilePos, pos: tilePos,
obstruction: validation.obstruction || 0 obstruction: validation.obstruction || 0,
}); });
} }
}); });
// Highlight only valid targets with obstruction-based dimming // 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) // Update combat state to refresh UI (show cancel button)
@ -683,10 +705,14 @@ export class GameLoop {
skillId 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) { if (targets.length > 0) {
targets.forEach((t) => { 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}`
);
}); });
} }
@ -696,7 +722,9 @@ export class GameLoop {
const unitAtTarget = this.grid.getUnitAt(targetPos); const unitAtTarget = this.grid.getUnitAt(targetPos);
if (unitAtTarget) { if (unitAtTarget) {
targets = [unitAtTarget]; targets = [unitAtTarget];
console.log(`Fallback: Added unit at target position: ${unitAtTarget.name}`); console.log(
`Fallback: Added unit at target position: ${unitAtTarget.name}`
);
} }
} }
@ -727,7 +755,11 @@ export class GameLoop {
const failureChance = losResult.obstruction || 0; const failureChance = losResult.obstruction || 0;
if (Math.random() < failureChance) { if (Math.random() < failureChance) {
console.warn( 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 // 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 // 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 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 // 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) { if (result.success && result.data) {
// Update unit mesh position after teleport // Update unit mesh position after teleport
@ -781,7 +821,8 @@ export class GameLoop {
for (const target of targets) { for (const target of targets) {
if (!target) continue; if (!target) continue;
// Check if unit is alive // 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; if (target.currentHealth <= 0) continue;
// Process ON_SKILL_HIT passive effects (before processing effect) // Process ON_SKILL_HIT passive effects (before processing effect)
@ -793,7 +834,11 @@ export class GameLoop {
}); });
// Process effect through EffectProcessor // Process effect through EffectProcessor
const result = this.effectProcessor.process(effect, activeUnit, target); const result = this.effectProcessor.process(
effect,
activeUnit,
target
);
if (result.success) { if (result.success) {
// Log success messages based on effect type // Log success messages based on effect type
@ -844,7 +889,10 @@ export class GameLoop {
console.log( console.log(
`${activeUnit.name} dealt ${primaryResult.amount} damage to ${target.name} (chain lightning)` `${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( console.log(
`Chain lightning bounced to ${result.data.chainTargets.length} additional targets` `Chain lightning bounced to ${result.data.chainTargets.length} additional targets`
); );
@ -1014,6 +1062,26 @@ export class GameLoop {
// Create a proper registry with actual class definitions // Create a proper registry with actual class definitions
const classRegistry = new Map(); 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 // Register all class definitions
const classDefs = [ const classDefs = [
vanguardDef, vanguardDef,
@ -1093,10 +1161,24 @@ export class GameLoop {
this.turnSystemAbortController = new AbortController(); this.turnSystemAbortController = new AbortController();
const signal = this.turnSystemAbortController.signal; const signal = this.turnSystemAbortController.signal;
this.turnSystem.addEventListener("turn-start", (e) => this._onTurnStart(e.detail), { signal }); this.turnSystem.addEventListener(
this.turnSystem.addEventListener("turn-end", (e) => this._onTurnEnd(e.detail), { signal }); "turn-start",
this.turnSystem.addEventListener("combat-start", () => this._onCombatStart(), { signal }); (e) => this._onTurnStart(e.detail),
this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), { signal }); { 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(); this.highlightZones();
@ -1213,10 +1295,13 @@ export class GameLoop {
// Restore classMastery progression // Restore classMastery progression
if (rosterUnit.classMastery) { 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 // Recalculate stats based on restored mastery and activeClassId
if (unit.recalculateBaseStats && unit.activeClassId) { if (unit.recalculateBaseStats && unit.activeClassId) {
const classDef = typeof this.unitManager.registry.get === "function" const classDef =
typeof this.unitManager.registry.get === "function"
? this.unitManager.registry.get(unit.activeClassId) ? this.unitManager.registry.get(unit.activeClassId)
: this.unitManager.registry[unit.activeClassId]; : this.unitManager.registry[unit.activeClassId];
if (classDef) { if (classDef) {
@ -1226,10 +1311,18 @@ export class GameLoop {
} }
// Restore currentHealth from roster (preserve HP that was paid for) // 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) // Ensure currentHealth doesn't exceed maxHealth (in case maxHealth increased)
unit.currentHealth = Math.min(rosterUnit.currentHealth, unit.maxHealth || 100); unit.currentHealth = Math.min(
console.log(`Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)`); 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 // Get class definition from the registry
let classDef = null; let classDef = null;
if (this.unitManager.registry) { if (this.unitManager.registry) {
classDef = typeof this.unitManager.registry.get === "function" classDef =
typeof this.unitManager.registry.get === "function"
? this.unitManager.registry.get(classId) ? this.unitManager.registry.get(classId)
: this.unitManager.registry[classId]; : this.unitManager.registry[classId];
} }
if (classDef && typeof unit.initializeStartingEquipment === "function") { if (
classDef &&
typeof unit.initializeStartingEquipment === "function"
) {
unit.initializeStartingEquipment( unit.initializeStartingEquipment(
this.inventoryManager.itemRegistry, this.inventoryManager.itemRegistry,
classDef classDef
@ -1267,7 +1364,13 @@ export class GameLoop {
// Ensure unit has valid health values // Ensure unit has valid health values
// Only set to full health if currentHealth is invalid (0 or negative) and wasn't restored from roster // 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 // 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) // 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; unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100;
} }
@ -1289,7 +1392,7 @@ export class GameLoop {
/** /**
* Finalizes deployment phase and starts combat. * Finalizes deployment phase and starts combat.
*/ */
finalizeDeployment() { async finalizeDeployment() {
if ( if (
!this.gameStateManager || !this.gameStateManager ||
this.gameStateManager.currentState !== "STATE_DEPLOYMENT" this.gameStateManager.currentState !== "STATE_DEPLOYMENT"
@ -1297,7 +1400,7 @@ export class GameLoop {
return; return;
// Get enemy spawns from mission definition // Get enemy spawns from mission definition
const missionDef = this.missionManager?.getActiveMission(); const missionDef = await this.missionManager?.getActiveMission();
const enemySpawns = missionDef?.enemy_spawns || []; const enemySpawns = missionDef?.enemy_spawns || [];
// If no enemy_spawns defined, fall back to default behavior // If no enemy_spawns defined, fall back to default behavior
@ -1313,7 +1416,10 @@ export class GameLoop {
); );
if (walkableY !== null) { if (walkableY !== null) {
const walkablePos = { x: spot.x, y: walkableY, z: spot.z }; 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.grid.placeUnit(enemy, walkablePos);
this.createUnitMesh(enemy, walkablePos); this.createUnitMesh(enemy, walkablePos);
} }
@ -1329,7 +1435,11 @@ export class GameLoop {
let attempts = 0; let attempts = 0;
const maxAttempts = availableSpots.length * 2; 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 spotIndex = Math.floor(Math.random() * availableSpots.length);
const spot = availableSpots[spotIndex]; const spot = availableSpots[spotIndex];
@ -1385,7 +1495,7 @@ export class GameLoop {
if (this.missionManager) { if (this.missionManager) {
this.missionManager.setUnitManager(this.unitManager); this.missionManager.setUnitManager(this.unitManager);
this.missionManager.setTurnSystem(this.turnSystem); this.missionManager.setTurnSystem(this.turnSystem);
this.missionManager.setupActiveMission(); await this.missionManager.setupActiveMission();
} }
// WIRING: Listen for mission events // WIRING: Listen for mission events
@ -1714,10 +1824,7 @@ export class GameLoop {
const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15); const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15);
outerGlowGeometry.rotateX(-Math.PI / 2); outerGlowGeometry.rotateX(-Math.PI / 2);
const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry); const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry);
const outerGlowLines = new THREE.LineSegments( const outerGlowLines = new THREE.LineSegments(outerGlowEdges, outerGlow);
outerGlowEdges,
outerGlow
);
outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z); outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z);
this.scene.add(outerGlowLines); this.scene.add(outerGlowLines);
this.spawnZoneHighlights.add(outerGlowLines); this.spawnZoneHighlights.add(outerGlowLines);
@ -1726,10 +1833,7 @@ export class GameLoop {
const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08); const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08);
midGlowGeometry.rotateX(-Math.PI / 2); midGlowGeometry.rotateX(-Math.PI / 2);
const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry); const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry);
const midGlowLines = new THREE.LineSegments( const midGlowLines = new THREE.LineSegments(midGlowEdges, midGlow);
midGlowEdges,
midGlow
);
midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z); midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z);
this.scene.add(midGlowLines); this.scene.add(midGlowLines);
this.spawnZoneHighlights.add(midGlowLines); this.spawnZoneHighlights.add(midGlowLines);
@ -1745,10 +1849,7 @@ export class GameLoop {
// Main bright outline (exact size, brightest) // Main bright outline (exact size, brightest)
const edgesGeometry = new THREE.EdgesGeometry(baseGeometry); const edgesGeometry = new THREE.EdgesGeometry(baseGeometry);
const lineSegments = new THREE.LineSegments( const lineSegments = new THREE.LineSegments(edgesGeometry, highlight);
edgesGeometry,
highlight
);
lineSegments.position.set(pos.x, floorSurfaceY, pos.z); lineSegments.position.set(pos.x, floorSurfaceY, pos.z);
this.scene.add(lineSegments); this.scene.add(lineSegments);
this.spawnZoneHighlights.add(lineSegments); this.spawnZoneHighlights.add(lineSegments);
@ -1888,7 +1989,10 @@ export class GameLoop {
// Reset turn system state BEFORE ending combat to prevent event cascades // Reset turn system state BEFORE ending combat to prevent event cascades
if (this.turnSystem) { if (this.turnSystem) {
// End combat first to stop any ongoing turn advancement // 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 { try {
this.turnSystem.endCombat(); this.turnSystem.endCombat();
} catch (e) { } catch (e) {
@ -1978,13 +2082,18 @@ export class GameLoop {
// Add unlocked skill tree skills for Explorer units // Add unlocked skill tree skills for Explorer units
if ( if (
(activeUnit.type === "EXPLORER" || activeUnit.constructor?.name === "Explorer") && (activeUnit.type === "EXPLORER" ||
activeUnit.constructor?.name === "Explorer") &&
activeUnit.activeClassId && activeUnit.activeClassId &&
activeUnit.classMastery && activeUnit.classMastery &&
this.classRegistry this.classRegistry
) { ) {
const mastery = activeUnit.classMastery[activeUnit.activeClassId]; const mastery = activeUnit.classMastery[activeUnit.activeClassId];
if (mastery && mastery.unlockedNodes && mastery.unlockedNodes.length > 0) { if (
mastery &&
mastery.unlockedNodes &&
mastery.unlockedNodes.length > 0
) {
try { try {
// Get class definition // Get class definition
const classDef = this.classRegistry.get(activeUnit.activeClassId); const classDef = this.classRegistry.get(activeUnit.activeClassId);
@ -2015,7 +2124,10 @@ export class GameLoop {
const skillMap = Object.fromEntries(skillRegistry.skills); const skillMap = Object.fromEntries(skillRegistry.skills);
// Create factory and generate tree // Create factory and generate tree
const factory = new SkillTreeFactory(templateRegistry, skillMap); const factory = new SkillTreeFactory(
templateRegistry,
skillMap
);
const skillTree = factory.createTree(classDef); const skillTree = factory.createTree(classDef);
// Add speed boosts from unlocked nodes to effective speed // Add speed boosts from unlocked nodes to effective speed
@ -2037,7 +2149,11 @@ export class GameLoop {
// Add unlocked ACTIVE_SKILL nodes to skills array // Add unlocked ACTIVE_SKILL nodes to skills array
for (const nodeId of mastery.unlockedNodes) { for (const nodeId of mastery.unlockedNodes) {
const nodeDef = skillTree.nodes?.[nodeId]; 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 skillData = nodeDef.data;
const skillId = skillData.id || nodeId; const skillId = skillData.id || nodeId;
@ -2047,8 +2163,10 @@ export class GameLoop {
// Add skill to skills array (avoid duplicates) // Add skill to skills array (avoid duplicates)
if (!skills.find((s) => s.id === skillId)) { if (!skills.find((s) => s.id === skillId)) {
// Get costAP from full skill definition // Get costAP from full skill definition
const costAP = fullSkill?.costs?.ap || skillData.costAP || 3; const costAP =
const baseCooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0; fullSkill?.costs?.ap || skillData.costAP || 3;
const baseCooldown =
fullSkill?.cooldown_turns || skillData.cooldown || 0;
// Ensure skill exists in unit.actions for cooldown tracking // Ensure skill exists in unit.actions for cooldown tracking
if (!activeUnit.actions) { if (!activeUnit.actions) {
@ -2062,7 +2180,10 @@ export class GameLoop {
if (!existingAction) { if (!existingAction) {
existingAction = { existingAction = {
id: skillId, id: skillId,
name: skillData.name || fullSkill?.name || "Unknown Skill", name:
skillData.name ||
fullSkill?.name ||
"Unknown Skill",
icon: skillData.icon || fullSkill?.icon || "⚔", icon: skillData.icon || fullSkill?.icon || "⚔",
costAP: costAP, costAP: costAP,
cooldown: 0, // Newly unlocked skills start ready to use cooldown: 0, // Newly unlocked skills start ready to use
@ -2080,7 +2201,8 @@ export class GameLoop {
costAP: costAP, costAP: costAP,
cooldown: currentCooldown, cooldown: currentCooldown,
isAvailable: isAvailable:
activeUnit.currentAP >= costAP && currentCooldown === 0, activeUnit.currentAP >= costAP &&
currentCooldown === 0,
}); });
} }
} }
@ -2293,7 +2415,7 @@ export class GameLoop {
if (this.missionManager && this.turnSystem) { if (this.missionManager && this.turnSystem) {
const currentTurn = this.turnSystem.round || 0; const currentTurn = this.turnSystem.round || 0;
this.missionManager.updateTurn(currentTurn); 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 * @private
*/ */
processPassiveItemEffects(unit, trigger, context = {}) { processPassiveItemEffects(unit, trigger, context = {}) {
if (!unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager) { if (
!unit ||
!unit.loadout ||
!this.effectProcessor ||
!this.inventoryManager
) {
return; return;
} }
@ -2332,7 +2459,9 @@ export class GameLoop {
for (const itemInstance of equippedItems) { for (const itemInstance of equippedItems) {
if (!itemInstance || !itemInstance.defId) continue; 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; if (!itemDef || !itemDef.passives) continue;
// Check each passive effect // Check each passive effect
@ -2353,7 +2482,11 @@ export class GameLoop {
// Determine target based on passive action // Determine target based on passive action
let target = context.target || context.source || unit; 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; target = context.source;
} else if (passive.params && passive.params.target === "SELF") { } else if (passive.params && passive.params.target === "SELF") {
target = unit; target = unit;
@ -2363,7 +2496,9 @@ export class GameLoop {
const result = this.effectProcessor.process(effectDef, unit, target); const result = this.effectProcessor.process(effectDef, unit, target);
if (result.success && result.data) { if (result.success && result.data) {
console.log( console.log(
`Passive effect ${passive.id || "unknown"} triggered on ${unit.name} (${trigger})` `Passive effect ${passive.id || "unknown"} triggered on ${
unit.name
} (${trigger})`
); );
} }
} }
@ -2491,8 +2626,10 @@ export class GameLoop {
if (condition.type === "SOURCE_IS_ADJACENT") { if (condition.type === "SOURCE_IS_ADJACENT") {
const source = context.source; const source = context.source;
const target = context.target || context.unit; const target = context.target || context.unit;
if (!source || !target || !source.position || !target.position) return false; if (!source || !target || !source.position || !target.position)
const dist = Math.abs(source.position.x - target.position.x) + return false;
const dist =
Math.abs(source.position.x - target.position.x) +
Math.abs(source.position.y - target.position.y) + Math.abs(source.position.y - target.position.y) +
Math.abs(source.position.z - target.position.z); Math.abs(source.position.z - target.position.z);
if (dist > 1) return false; // Not adjacent (Manhattan distance > 1) if (dist > 1) return false; // Not adjacent (Manhattan distance > 1)
@ -2502,8 +2639,10 @@ export class GameLoop {
if (condition.type === "IS_ADJACENT") { if (condition.type === "IS_ADJACENT") {
const source = context.source || context.unit; const source = context.source || context.unit;
const target = context.target; const target = context.target;
if (!source || !target || !source.position || !target.position) return false; if (!source || !target || !source.position || !target.position)
const dist = Math.abs(source.position.x - target.position.x) + return false;
const dist =
Math.abs(source.position.x - target.position.x) +
Math.abs(source.position.y - target.position.y) + Math.abs(source.position.y - target.position.y) +
Math.abs(source.position.z - target.position.z); Math.abs(source.position.z - target.position.z);
if (dist > 1) return false; // Not adjacent if (dist > 1) return false; // Not adjacent
@ -2543,7 +2682,7 @@ export class GameLoop {
if (mesh.geometry) mesh.geometry.dispose(); if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) { if (mesh.material) {
if (Array.isArray(mesh.material)) { if (Array.isArray(mesh.material)) {
mesh.material.forEach(mat => { mesh.material.forEach((mat) => {
if (mat.map) mat.map.dispose(); if (mat.map) mat.map.dispose();
mat.dispose(); mat.dispose();
}); });
@ -2556,12 +2695,12 @@ export class GameLoop {
// Dispatch death event to MissionManager // Dispatch death event to MissionManager
if (this.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; const unitDefId = unit.defId || unit.id;
this.missionManager.onGameEvent(eventType, { this.missionManager.onGameEvent(eventType, {
unitId: unit.id, unitId: unit.id,
defId: unitDefId, defId: unitDefId,
team: unit.team team: unit.team,
}); });
} }
@ -2574,12 +2713,12 @@ export class GameLoop {
*/ */
_setupMissionEventListeners() { _setupMissionEventListeners() {
// Listen for mission victory // Listen for mission victory
window.addEventListener('mission-victory', (event) => { window.addEventListener("mission-victory", (event) => {
this._handleMissionVictory(event.detail); this._handleMissionVictory(event.detail);
}); });
// Listen for mission failure // Listen for mission failure
window.addEventListener('mission-failure', (event) => { window.addEventListener("mission-failure", (event) => {
this._handleMissionFailure(event.detail); this._handleMissionFailure(event.detail);
}); });
} }
@ -2590,7 +2729,7 @@ export class GameLoop {
* @private * @private
*/ */
_handleMissionVictory(detail) { _handleMissionVictory(detail) {
console.log('Mission Victory!', detail); console.log("Mission Victory!", detail);
// Save Explorer progression back to roster // Save Explorer progression back to roster
this._saveExplorerProgression(); this._saveExplorerProgression();
@ -2609,38 +2748,50 @@ export class GameLoop {
// Wait for the outro narrative to complete before transitioning // Wait for the outro narrative to complete before transitioning
// The outro is played in MissionManager.completeActiveMission() // The outro is played in MissionManager.completeActiveMission()
// We'll listen for the narrative-end event to know when it's done // 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) { if (hasOutro) {
console.log('GameLoop: Waiting for outro narrative to complete...'); console.log("GameLoop: Waiting for outro narrative to complete...");
const handleNarrativeEnd = () => { const handleNarrativeEnd = () => {
console.log('GameLoop: Narrative end event received, transitioning to hub'); console.log(
narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd); "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 // Small delay after narrative ends to let user see the final message
setTimeout(() => { setTimeout(() => {
if (this.gameStateManager) { if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU'); this.gameStateManager.transitionTo("STATE_MAIN_MENU");
} }
}, 500); }, 500);
}; };
narrativeManager.addEventListener('narrative-end', handleNarrativeEnd); narrativeManager.addEventListener("narrative-end", handleNarrativeEnd);
// Fallback timeout: if narrative doesn't end within 30 seconds, transition anyway // Fallback timeout: if narrative doesn't end within 30 seconds, transition anyway
setTimeout(() => { setTimeout(() => {
console.warn('GameLoop: Narrative end timeout - transitioning to hub anyway'); console.warn(
narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd); "GameLoop: Narrative end timeout - transitioning to hub anyway"
);
narrativeManager.removeEventListener(
"narrative-end",
handleNarrativeEnd
);
if (this.gameStateManager) { if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU'); this.gameStateManager.transitionTo("STATE_MAIN_MENU");
} }
}, 30000); }, 30000);
} else { } else {
// No outro, transition immediately after a short delay // 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(() => { setTimeout(() => {
if (this.gameStateManager) { if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU'); this.gameStateManager.transitionTo("STATE_MAIN_MENU");
} }
}, 1000); }, 1000);
} }
@ -2653,8 +2804,9 @@ export class GameLoop {
_saveExplorerProgression() { _saveExplorerProgression() {
if (!this.unitManager || !this.gameStateManager) return; if (!this.unitManager || !this.gameStateManager) return;
const playerUnits = Array.from(this.unitManager.activeUnits.values()) const playerUnits = Array.from(
.filter(u => u.team === 'PLAYER' && u.type === 'EXPLORER'); this.unitManager.activeUnits.values()
).filter((u) => u.team === "PLAYER" && u.type === "EXPLORER");
for (const unit of playerUnits) { for (const unit of playerUnits) {
// Use rosterId if available, otherwise fall back to unit.id // Use rosterId if available, otherwise fall back to unit.id
@ -2662,13 +2814,15 @@ export class GameLoop {
if (!rosterId) continue; if (!rosterId) continue;
const rosterUnit = this.gameStateManager.rosterManager.roster.find( const rosterUnit = this.gameStateManager.rosterManager.roster.find(
r => r.id === rosterId (r) => r.id === rosterId
); );
if (rosterUnit) { if (rosterUnit) {
// Save classMastery progression // Save classMastery progression
if (unit.classMastery) { if (unit.classMastery) {
rosterUnit.classMastery = JSON.parse(JSON.stringify(unit.classMastery)); rosterUnit.classMastery = JSON.parse(
JSON.stringify(unit.classMastery)
);
} }
// Save activeClassId // Save activeClassId
if (unit.activeClassId) { if (unit.activeClassId) {
@ -2685,7 +2839,9 @@ export class GameLoop {
if (unit.currentHealth !== undefined) { if (unit.currentHealth !== undefined) {
rosterUnit.currentHealth = unit.currentHealth; 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,7 +2857,7 @@ export class GameLoop {
* @private * @private
*/ */
_handleMissionFailure(detail) { _handleMissionFailure(detail) {
console.log('Mission Failed!', detail); console.log("Mission Failed!", detail);
// Save Explorer progression back to roster (even on failure, progression should persist) // Save Explorer progression back to roster (even on failure, progression should persist)
this._saveExplorerProgression(); this._saveExplorerProgression();
@ -2721,7 +2877,7 @@ export class GameLoop {
// For now, just log and transition back to main menu after a delay // For now, just log and transition back to main menu after a delay
setTimeout(() => { setTimeout(() => {
if (this.gameStateManager) { if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU'); this.gameStateManager.transitionTo("STATE_MAIN_MENU");
} }
}, 3000); }, 3000);
} }

View file

@ -11,6 +11,7 @@ import { RosterManager } from "../managers/RosterManager.js";
import { MissionManager } from "../managers/MissionManager.js"; import { MissionManager } from "../managers/MissionManager.js";
import { narrativeManager } from "../managers/NarrativeManager.js"; import { narrativeManager } from "../managers/NarrativeManager.js";
import { MarketManager } from "../managers/MarketManager.js"; import { MarketManager } from "../managers/MarketManager.js";
import { ResearchManager } from "../managers/ResearchManager.js";
import { InventoryManager } from "../managers/InventoryManager.js"; import { InventoryManager } from "../managers/InventoryManager.js";
import { InventoryContainer } from "../models/InventoryContainer.js"; import { InventoryContainer } from "../models/InventoryContainer.js";
import { itemRegistry } from "../managers/ItemRegistry.js"; import { itemRegistry } from "../managers/ItemRegistry.js";
@ -74,6 +75,11 @@ class GameStateManagerClass {
this.hubInventoryManager, this.hubInventoryManager,
this.missionManager this.missionManager
); );
/** @type {ResearchManager} */
this.researchManager = new ResearchManager(
this.persistence,
this.rosterManager
);
this.handleEmbark = this.handleEmbark.bind(this); this.handleEmbark = this.handleEmbark.bind(this);
} }
@ -105,6 +111,11 @@ class GameStateManagerClass {
setGameLoop(loop) { setGameLoop(loop) {
this.gameLoop = loop; this.gameLoop = loop;
this.#gameLoopInitialized.resolve(); 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 // 3. Initialize Market Manager
await this.marketManager.init(); 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(); const savedCampaignData = await this.persistence.loadCampaign();
console.log("Loaded campaign data:", savedCampaignData); console.log("Loaded campaign data:", savedCampaignData);
if (savedCampaignData) { if (savedCampaignData) {
@ -170,10 +184,10 @@ class GameStateManagerClass {
console.log("No saved campaign data found"); console.log("No saved campaign data found");
} }
// 5. Set up mission rewards listener // 6. Set up mission rewards listener
this._setupMissionRewardsListener(); this._setupMissionRewardsListener();
// 6. Set up campaign data change listener // 7. Set up campaign data change listener
this._setupCampaignDataListener(); this._setupCampaignDataListener();
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU); this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
@ -326,8 +340,8 @@ class GameStateManagerClass {
// 1. Mission Logic: Setup // 1. Mission Logic: Setup
// This resets objectives and prepares the logic for the new run // This resets objectives and prepares the logic for the new run
this.missionManager.setupActiveMission(); await this.missionManager.setupActiveMission();
const missionDef = this.missionManager.getActiveMission(); const missionDef = await this.missionManager.getActiveMission();
console.log(`Initializing Run for Mission: ${missionDef.config.title}`); console.log(`Initializing Run for Mission: ${missionDef.config.title}`);

View file

@ -15,7 +15,8 @@ const MARKET_STORE = "Market";
const CAMPAIGN_STORE = "Campaign"; const CAMPAIGN_STORE = "Campaign";
const HUB_STASH_STORE = "HubStash"; const HUB_STASH_STORE = "HubStash";
const UNLOCKS_STORE = "Unlocks"; 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. * Handles game data persistence using IndexedDB.
@ -69,6 +70,11 @@ export class Persistence {
if (!db.objectStoreNames.contains(UNLOCKS_STORE)) { if (!db.objectStoreNames.contains(UNLOCKS_STORE)) {
db.createObjectStore(UNLOCKS_STORE, { keyPath: "id" }); 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) => { request.onsuccess = (e) => {
@ -233,6 +239,28 @@ export class Persistence {
return result ? result.data : []; return result ? result.data : [];
} }
// --- RESEARCH DATA ---
/**
* Saves research state.
* @param {import("../managers/ResearchManager.js").ResearchState} researchState - Research state to save
* @returns {Promise<void>}
*/
async saveResearchState(researchState) {
if (!this.db) await this.init();
return this._put(RESEARCH_STORE, { id: "research_state", data: researchState });
}
/**
* Loads research state.
* @returns {Promise<import("../managers/ResearchManager.js").ResearchState | null>}
*/
async loadResearchState() {
if (!this.db) await this.init();
const result = await this._get(RESEARCH_STORE, "research_state");
return result ? result.data : null;
}
// --- INTERNAL HELPERS --- // --- INTERNAL HELPERS ---
/** /**

View file

@ -349,3 +349,93 @@ window.addEventListener("save-and-quit", async () => {
// Boot // Boot
gameStateManager.init(); 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,
});
}

View file

@ -5,7 +5,6 @@
*/ */
import { Item } from "../items/Item.js"; import { Item } from "../items/Item.js";
import tier1Gear from "../items/tier1_gear.json" with { type: "json" };
export class ItemRegistry { export class ItemRegistry {
constructor() { constructor() {
@ -36,6 +35,9 @@ export class ItemRegistry {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async _doLoadAll() { 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 // Load tier1_gear.json
for (const itemDef of tier1Gear) { for (const itemDef of tier1Gear) {
if (itemDef && itemDef.id) { if (itemDef && itemDef.id) {

View file

@ -5,7 +5,6 @@
* @typedef {import("./types.js").GameEventData} GameEventData * @typedef {import("./types.js").GameEventData} GameEventData
*/ */
import tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' };
import { narrativeManager } from './NarrativeManager.js'; import { narrativeManager } from './NarrativeManager.js';
/** /**
@ -45,8 +44,47 @@ export class MissionManager {
/** @type {number} */ /** @type {number} */
this.currentTurn = 0; this.currentTurn = 0;
// Register default missions /** @type {Promise<void> | null} */
this._missionsLoadPromise = null;
}
/**
* Lazy-loads all mission definitions if not already loaded.
* @returns {Promise<void>}
*/
async _ensureMissionsLoaded() {
if (this._missionsLoadPromise) {
return this._missionsLoadPromise;
}
this._missionsLoadPromise = this._loadMissions();
return this._missionsLoadPromise;
}
/**
* Loads all mission definitions.
* @private
* @returns {Promise<void>}
*/
async _loadMissions() {
// Only load if registry is empty (first time)
if (this.missionRegistry.size > 0) {
return;
}
try {
const [tutorialMission, story02Mission, story03Mission] = await Promise.all([
import('../assets/data/missions/mission_tutorial_01.json', { with: { type: 'json' } }).then(m => m.default),
import('../assets/data/missions/mission_story_02.json', { with: { type: 'json' } }).then(m => m.default),
import('../assets/data/missions/mission_story_03.json', { with: { type: 'json' } }).then(m => m.default)
]);
this.registerMission(tutorialMission); this.registerMission(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. * Gets the configuration for the currently selected mission.
* @returns {MissionDefinition | undefined} - Active mission definition * Ensures missions are loaded before accessing.
* @returns {Promise<MissionDefinition | undefined>} - Active mission definition
*/ */
getActiveMission() { async getActiveMission() {
await this._ensureMissionsLoaded();
if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01'); if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01');
return this.missionRegistry.get(this.activeMissionId); return this.missionRegistry.get(this.activeMissionId);
} }
@ -111,9 +151,11 @@ export class MissionManager {
/** /**
* Prepares the manager for a new run. * Prepares the manager for a new run.
* Resets objectives and prepares narrative hooks. * Resets objectives and prepares narrative hooks.
* @returns {Promise<void>}
*/ */
setupActiveMission() { async setupActiveMission() {
const mission = this.getActiveMission(); await this._ensureMissionsLoaded();
const mission = await this.getActiveMission();
this.currentMissionDef = mission; this.currentMissionDef = mission;
this.currentTurn = 0; this.currentTurn = 0;
@ -551,6 +593,11 @@ export class MissionManager {
localStorage.setItem('aether_shards_unlocks', JSON.stringify(unlocks)); localStorage.setItem('aether_shards_unlocks', JSON.stringify(unlocks));
console.log('Unlocked classes (localStorage fallback):', classIds); 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) { } catch (e) {
console.error('Failed to save unlocks to storage:', e); console.error('Failed to save unlocks to storage:', e);
} }

View file

@ -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<string, ResearchNode>} */
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<void>}
*/
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<void>}
*/
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;
}
}

View file

@ -228,7 +228,9 @@ export class MissionBoard extends LitElement {
this._loadMissions(); this._loadMissions();
} }
_loadMissions() { async _loadMissions() {
// Ensure missions are loaded before accessing registry
await gameStateManager.missionManager._ensureMissionsLoaded();
// Get all registered missions from MissionManager // Get all registered missions from MissionManager
const missionRegistry = gameStateManager.missionManager.missionRegistry; const missionRegistry = gameStateManager.missionManager.missionRegistry;
this.missions = Array.from(missionRegistry.values()); this.missions = Array.from(missionRegistry.values());
@ -238,10 +240,43 @@ export class MissionBoard extends LitElement {
_isMissionAvailable(mission) { _isMissionAvailable(mission) {
// Check if mission prerequisites are met // Check if mission prerequisites are met
// For now, all missions are available unless they have explicit prerequisites const prerequisites = mission.config?.prerequisites || [];
// If no prerequisites, mission is available
if (prerequisites.length === 0) {
return true; 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) { _isMissionCompleted(missionId) {
return this.completedMissions.has(missionId); return this.completedMissions.has(missionId);
} }
@ -321,7 +356,9 @@ export class MissionBoard extends LitElement {
</div> </div>
<div class="missions-grid"> <div class="missions-grid">
${this.missions.map((mission) => { ${this.missions
.filter(mission => this._shouldShowMission(mission))
.map((mission) => {
const isCompleted = this._isMissionCompleted(mission.id); const isCompleted = this._isMissionCompleted(mission.id);
const isAvailable = this._isMissionAvailable(mission); const isAvailable = this._isMissionAvailable(mission);
const rewards = this._formatRewards(mission.rewards); const rewards = this._formatRewards(mission.rewards);
@ -358,6 +395,14 @@ export class MissionBoard extends LitElement {
Difficulty: ${this._getDifficultyLabel(mission.config)} Difficulty: ${this._getDifficultyLabel(mission.config)}
</span> </span>
${isCompleted ? html`<span style="color: var(--color-accent-green);">✓ Completed</span>` : ''} ${isCompleted ? html`<span style="color: var(--color-accent-green);">✓ Completed</span>` : ''}
${!isAvailable && !isCompleted ? html`
<span style="color: var(--color-text-muted); font-size: var(--font-size-xs);">
🔒 Requires: ${mission.config?.prerequisites?.map(id => {
const prereqMission = this.missions.find(m => m.id === id);
return prereqMission?.config?.title || id;
}).join(', ') || 'Previous missions'}
</span>
` : ''}
${isAvailable && !isCompleted ? html` ${isAvailable && !isCompleted ? html`
<button <button
class="btn btn-primary" class="btn btn-primary"

View file

@ -45,7 +45,7 @@ export class GameViewport extends LitElement {
#handleStartBattle() { #handleStartBattle() {
if (gameStateManager.gameLoop) { if (gameStateManager.gameLoop) {
gameStateManager.gameLoop.finalizeDeployment(); gameStateManager.gameLoop.finalizeDeployment().catch(console.error);
} }
} }
@ -77,9 +77,17 @@ export class GameViewport extends LitElement {
// Don't set squad from rosterLoaded - that's the full roster, not the current mission squad // Don't set squad from rosterLoaded - that's the full roster, not the current mission squad
// Squad will be set from activeRunData when transitioning to deployment state // Squad will be set from activeRunData when transitioning to deployment state
// Get mission definition for deployment hints // Get mission definition for deployment hints (lazy-loaded)
this.missionDef = this.missionDef = null;
gameStateManager.missionManager?.getActiveMission() || null; if (gameStateManager.missionManager) {
gameStateManager.missionManager
.getActiveMission()
.then((mission) => {
this.missionDef = mission || null;
this.requestUpdate();
})
.catch(console.error);
}
// Set up combat state updates // Set up combat state updates
this.#setupCombatStateUpdates(); this.#setupCombatStateUpdates();

View file

@ -0,0 +1,712 @@
import { LitElement, html, css } from "lit";
import { gameStateManager } from "../../core/GameStateManager.js";
import { theme, buttonStyles, cardStyles } from "../styles/theme.js";
/**
* ResearchScreen.js
* The Ancient Archive - Research facility UI component.
* @class
*/
export class ResearchScreen extends LitElement {
static get styles() {
return [
theme,
buttonStyles,
cardStyles,
css`
:host {
display: block;
background: var(--color-bg-secondary);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-xl);
max-width: 1600px;
max-height: 85vh;
overflow: hidden;
color: var(--color-text-primary);
font-family: var(--font-family);
display: grid;
grid-template-columns: 1fr 350px;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"trees inspector";
gap: var(--spacing-lg);
}
.header {
grid-area: header;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: var(--border-width-medium) solid
var(--color-border-default);
padding-bottom: var(--spacing-md);
}
.header h2 {
margin: 0;
color: var(--color-accent-cyan);
font-size: var(--font-size-4xl);
}
.cores-display {
display: flex;
align-items: center;
gap: var(--spacing-md);
font-size: var(--font-size-2xl);
color: var(--color-accent-gold);
}
.cores-icon {
font-size: var(--font-size-3xl);
}
.close-button {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
background: transparent;
border: var(--border-width-medium) solid var(--color-border-default);
color: var(--color-text-primary);
width: 40px;
height: 40px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
transition: all var(--transition-normal);
}
.close-button:hover {
border-color: var(--color-accent-red);
color: var(--color-accent-red);
background: rgba(255, 102, 102, 0.1);
}
/* Trees Container */
.trees-container {
grid-area: trees;
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
overflow-y: auto;
padding-right: var(--spacing-sm);
}
.tree-section {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.tree-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--color-bg-card);
border: var(--border-width-medium) solid var(--color-border-default);
}
.tree-header.logistics {
border-color: var(--color-accent-green);
}
.tree-header.intel {
border-color: var(--color-accent-cyan);
}
.tree-header.field-ops {
border-color: var(--color-accent-red);
}
.tree-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
}
.tree-title.logistics {
color: var(--color-accent-green);
}
.tree-title.intel {
color: var(--color-accent-cyan);
}
.tree-title.field-ops {
color: var(--color-accent-red);
}
/* Tree View */
.tree-view {
position: relative;
min-height: 300px;
background: var(--color-bg-card);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-lg);
overflow-x: auto;
overflow-y: visible;
}
.tree-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.tree-svg path {
fill: none;
stroke-width: 2;
}
.tree-svg path.connection-line.locked {
stroke: var(--color-border-dark);
opacity: 0.3;
}
.tree-svg path.connection-line.available {
stroke: var(--color-accent-cyan);
opacity: 0.6;
}
.tree-svg path.connection-line.researched {
stroke: var(--color-accent-gold);
opacity: 0.8;
}
.tree-nodes {
position: relative;
display: flex;
gap: var(--spacing-xl);
align-items: flex-start;
padding: var(--spacing-lg);
min-width: fit-content;
}
.tier-column {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
align-items: center;
}
.tier-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
text-transform: uppercase;
margin-bottom: var(--spacing-sm);
}
/* Node */
.node {
position: relative;
width: 80px;
height: 80px;
border: var(--border-width-medium) solid var(--color-border-default);
background: var(--color-bg-panel);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all var(--transition-normal);
z-index: 2;
}
.node:hover {
transform: scale(1.1);
box-shadow: 0 0 15px rgba(0, 255, 255, 0.5);
}
.node.locked {
border-color: var(--color-border-dark);
background: rgba(0, 0, 0, 0.5);
opacity: 0.5;
cursor: not-allowed;
}
.node.available {
border-color: var(--color-accent-cyan);
background: rgba(0, 255, 255, 0.1);
box-shadow: 0 0 10px rgba(0, 255, 255, 0.3);
}
.node.researched {
border-color: var(--color-accent-gold);
background: rgba(255, 215, 0, 0.2);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.5);
}
.node-icon {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-xs);
}
.node-cost {
font-size: var(--font-size-xs);
color: var(--color-accent-gold);
}
/* Inspector Panel */
.inspector {
grid-area: inspector;
background: var(--color-bg-panel);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
overflow-y: auto;
}
.inspector-header {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
border-bottom: var(--border-width-medium) solid
var(--color-border-default);
padding-bottom: var(--spacing-md);
}
.inspector-icon {
width: 100px;
height: 100px;
border: var(--border-width-medium) solid var(--color-border-default);
background: var(--color-bg-card);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-4xl);
margin: 0 auto;
}
.inspector-name {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
text-align: center;
color: var(--color-accent-cyan);
}
.inspector-description {
color: var(--color-text-secondary);
line-height: 1.5;
text-align: center;
}
.inspector-details {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: var(--spacing-sm);
background: var(--color-bg-card);
border: var(--border-width-thin) solid var(--color-border-default);
}
.detail-label {
color: var(--color-text-secondary);
}
.detail-value {
color: var(--color-text-primary);
font-weight: var(--font-weight-bold);
}
.status-badge {
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
text-align: center;
}
.status-badge.locked {
background: var(--color-border-dark);
color: var(--color-text-secondary);
}
.status-badge.available {
background: var(--color-accent-cyan);
color: #000;
}
.status-badge.researched {
background: var(--color-accent-gold);
color: #000;
}
.research-button {
width: 100%;
padding: var(--spacing-md);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
background: var(--color-accent-cyan);
color: #000;
border: var(--border-width-medium) solid var(--color-accent-cyan);
cursor: pointer;
transition: all var(--transition-normal);
}
.research-button:hover:not(:disabled) {
background: var(--color-accent-cyan-dark);
box-shadow: 0 0 15px rgba(0, 255, 255, 0.5);
}
.research-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
text-align: center;
color: var(--color-text-secondary);
padding: var(--spacing-xl);
}
`,
];
}
static get properties() {
return {
selectedNodeId: { type: String },
availableCores: { type: Number },
};
}
constructor() {
super();
this.selectedNodeId = null;
this.availableCores = 0;
this.researchManager = null;
this._boundHandleResearchComplete = this._handleResearchComplete.bind(this);
this._boundHandleWalletUpdate = this._handleWalletUpdate.bind(this);
}
connectedCallback() {
super.connectedCallback();
this._loadData();
window.addEventListener("research-complete", this._boundHandleResearchComplete);
window.addEventListener("wallet-updated", this._boundHandleWalletUpdate);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("research-complete", this._boundHandleResearchComplete);
window.removeEventListener("wallet-updated", this._boundHandleWalletUpdate);
}
async _loadData() {
// Get research manager from gameStateManager
if (gameStateManager.researchManager) {
this.researchManager = gameStateManager.researchManager;
await this.researchManager.load();
this._updateAvailableCores();
this.requestUpdate();
// Update connections after render
this.updateComplete.then(() => {
this._updateConnections();
});
}
}
_updateAvailableCores() {
if (gameStateManager.hubStash) {
this.availableCores =
gameStateManager.hubStash.currency.ancientCores || 0;
if (this.researchManager) {
this.researchManager.updateAvailableCores(this.availableCores);
}
}
}
_handleWalletUpdate() {
this._updateAvailableCores();
this.requestUpdate();
}
_handleResearchComplete() {
this._updateAvailableCores();
this.requestUpdate();
// Update connections after state change
this.updateComplete.then(() => {
this._updateConnections();
});
}
_selectNode(nodeId) {
this.selectedNodeId = nodeId;
this.requestUpdate();
}
_dispatchClose() {
this.dispatchEvent(new CustomEvent("close"));
}
async _researchNode() {
if (!this.selectedNodeId || !this.researchManager) return;
const result = await this.researchManager.unlockNode(
this.selectedNodeId,
this.availableCores
);
if (result.success) {
// Deduct cores from hub stash
if (gameStateManager.hubStash) {
const node = this.researchManager.getNode(this.selectedNodeId);
gameStateManager.hubStash.currency.ancientCores -= node.cost;
await gameStateManager._saveHubStash();
this._updateAvailableCores();
// Dispatch event to update wallet display
window.dispatchEvent(
new CustomEvent("wallet-updated", {
detail: { wallet: gameStateManager.hubStash.currency },
})
);
}
// Dispatch research event
window.dispatchEvent(
new CustomEvent("research-complete", {
detail: { nodeId: this.selectedNodeId },
})
);
} else {
alert(result.error || "Failed to research node");
}
}
_updateConnections() {
if (!this.researchManager) return;
const trees = ["LOGISTICS", "INTEL", "FIELD_OPS"];
trees.forEach((treeType) => {
const treeView = this.shadowRoot?.querySelector(
`.tree-view[data-tree="${treeType}"]`
);
if (!treeView) return;
const svg = treeView.querySelector(".tree-svg");
if (!svg) return;
// Clear existing paths
svg.innerHTML = "";
const nodes = this.researchManager.getNodesByTree(treeType);
const containerRect = treeView.getBoundingClientRect();
// Draw connections for each node to its prerequisites
nodes.forEach((node) => {
if (node.prerequisites.length === 0) return;
const nodeElement = this.shadowRoot?.querySelector(
`[data-node-id="${node.id}"]`
);
if (!nodeElement) return;
const nodeRect = nodeElement.getBoundingClientRect();
const nodeCenter = {
x: nodeRect.left + nodeRect.width / 2 - containerRect.left,
y: nodeRect.top + nodeRect.height / 2 - containerRect.top,
};
node.prerequisites.forEach((prereqId) => {
const prereqElement = this.shadowRoot?.querySelector(
`[data-node-id="${prereqId}"]`
);
if (!prereqElement) return;
const prereqRect = prereqElement.getBoundingClientRect();
const prereqCenter = {
x: prereqRect.left + prereqRect.width / 2 - containerRect.left,
y: prereqRect.top + prereqRect.height / 2 - containerRect.top,
};
// Determine line style based on node status
const status = this.researchManager.getNodeStatus(node.id);
const pathClass = `connection-line ${status.toLowerCase()}`;
// Create horizontal connection with vertical offset
const midY = (prereqCenter.y + nodeCenter.y) / 2;
const pathData = `M ${prereqCenter.x} ${prereqCenter.y} L ${prereqCenter.x} ${midY} L ${nodeCenter.x} ${midY} L ${nodeCenter.x} ${nodeCenter.y}`;
const path = document.createElementNS(
"http://www.w3.org/2000/svg",
"path"
);
path.setAttribute("d", pathData);
path.setAttribute("class", pathClass);
svg.appendChild(path);
});
});
});
}
_renderTree(treeType) {
if (!this.researchManager) return html``;
const nodes = this.researchManager.getNodesByTree(treeType);
const nodesByTier = {};
nodes.forEach((node) => {
if (!nodesByTier[node.tier]) {
nodesByTier[node.tier] = [];
}
nodesByTier[node.tier].push(node);
});
const treeClass = treeType.toLowerCase().replace("_", "-");
return html`
<div class="tree-section">
<div class="tree-header ${treeClass}">
<h3 class="tree-title ${treeClass}">${treeType.replace("_", " ")}</h3>
</div>
<div class="tree-view" data-tree="${treeType}">
<svg class="tree-svg"></svg>
<div class="tree-nodes">
${Object.keys(nodesByTier)
.sort((a, b) => Number(a) - Number(b))
.map(
(tier) => html`
<div class="tier-column">
<div class="tier-label">Tier ${tier}</div>
${nodesByTier[tier].map(
(node) => this._renderNode(node, treeClass)
)}
</div>
`
)}
</div>
</div>
</div>
`;
}
_renderNode(node, treeClass) {
const status = this.researchManager
? this.researchManager.getNodeStatus(node.id)
: "LOCKED";
const isSelected = this.selectedNodeId === node.id;
return html`
<button
class="node ${status.toLowerCase()} ${isSelected ? "selected" : ""}"
data-node-id="${node.id}"
@click=${() => this._selectNode(node.id)}
?disabled=${status === "LOCKED"}
>
<div class="node-icon"></div>
<div class="node-cost">${node.cost}</div>
</button>
`;
}
_renderInspector() {
if (!this.selectedNodeId || !this.researchManager) {
return html`
<div class="inspector">
<div class="empty-state">
<p>Select a research node to view details</p>
</div>
</div>
`;
}
const node = this.researchManager.getNode(this.selectedNodeId);
if (!node) {
return html`
<div class="inspector">
<div class="empty-state">
<p>Node not found</p>
</div>
</div>
`;
}
const status = this.researchManager.getNodeStatus(node.id);
const canResearch =
status === "AVAILABLE" && this.availableCores >= node.cost;
return html`
<div class="inspector">
<div class="inspector-header">
<div class="inspector-icon"></div>
<div class="inspector-name">${node.name}</div>
<div class="inspector-description">${node.description}</div>
</div>
<div class="inspector-details">
<div class="detail-row">
<span class="detail-label">Cost:</span>
<span class="detail-value">${node.cost} Ancient Cores</span>
</div>
<div class="detail-row">
<span class="detail-label">Status:</span>
<span class="status-badge ${status.toLowerCase()}">${status}</span>
</div>
${node.prerequisites.length > 0
? html`
<div class="detail-row">
<span class="detail-label">Requires:</span>
<span class="detail-value"
>${node.prerequisites.length} prerequisite(s)</span
>
</div>
`
: ""}
</div>
<button
class="research-button"
@click=${this._researchNode}
?disabled=${!canResearch}
>
${status === "RESEARCHED" ? "RESEARCHED" : "RESEARCH"}
</button>
</div>
`;
}
render() {
return html`
<div class="header">
<h2>Ancient Archive</h2>
<div class="cores-display">
<span class="cores-icon"></span>
<span>${this.availableCores} Ancient Cores</span>
</div>
<button class="close-button" @click=${this._dispatchClose} aria-label="Close">
</button>
</div>
<div class="trees-container">
${this._renderTree("LOGISTICS")} ${this._renderTree("INTEL")}
${this._renderTree("FIELD_OPS")}
</div>
${this._renderInspector()}
`;
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has("selectedNodeId") || changedProperties.has("availableCores")) {
this.updateComplete.then(() => {
this._updateConnections();
});
}
}
}
customElements.define("research-screen", ResearchScreen);

View file

@ -371,18 +371,7 @@ export class HubScreen extends LitElement {
break; break;
case "RESEARCH": case "RESEARCH":
overlayComponent = html` overlayComponent = html`
<div <research-screen @close=${this._closeOverlay}></research-screen>
style="background: rgba(20, 20, 30, 0.95); padding: 30px; border: 2px solid #555; max-width: 800px;"
>
<h2 style="margin-top: 0; color: #ffd700;">RESEARCH</h2>
<p>Research coming soon...</p>
<button
@click=${this._closeOverlay}
style="margin-top: 20px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
>
Close
</button>
</div>
`; `;
break; break;
case "SYSTEM": case "SYSTEM":
@ -432,6 +421,10 @@ export class HubScreen extends LitElement {
if (this.activeOverlay === "BARRACKS") { if (this.activeOverlay === "BARRACKS") {
import("./BarracksScreen.js").catch(console.error); 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` return html`
<div class="background"></div> <div class="background"></div>

View file

@ -1,12 +1,8 @@
import { LitElement, html, css } from 'lit'; import { LitElement, html, css } from 'lit';
import { theme, buttonStyles, cardStyles } from './styles/theme.js'; import { theme, buttonStyles, cardStyles } from './styles/theme.js';
import { gameStateManager } from '../core/GameStateManager.js';
// Import Tier 1 Class Definitions // Class definitions will be lazy-loaded when component connects
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' };
// UI Metadata Mapping // UI Metadata Mapping
const CLASS_METADATA = { 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 { export class TeamBuilder extends LitElement {
static get styles() { static get styles() {
@ -272,9 +269,30 @@ export class TeamBuilder extends LitElement {
this._poolExplicitlySet = false; this._poolExplicitlySet = false;
} }
connectedCallback() { async connectedCallback() {
super.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. * Configures the component based on provided data.
*/ */
_initializeData() { async _initializeData() {
// 1. If availablePool was explicitly set (from mission selection), use ROSTER mode. // 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. // This happens when opening from mission selection - we want to show roster even if all units are injured.
if (this._poolExplicitlySet) { if (this._poolExplicitlySet) {
@ -300,13 +318,48 @@ export class TeamBuilder extends LitElement {
} }
// 2. Default: Draft Mode (New Game) // 2. Default: Draft Mode (New Game)
// Populate with Tier 1 classes // Populate with Tier 1 classes and check unlock status
this.mode = 'DRAFT'; 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 => { this.availablePool = RAW_TIER_1_CLASSES.map(cls => {
const meta = CLASS_METADATA[cls.id] || {}; 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() { render() {

View file

@ -35,7 +35,8 @@ describe("Manager: MissionManager", () => {
sinon.restore(); 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.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true;
expect(manager.activeMissionId).to.be.null; expect(manager.activeMissionId).to.be.null;
expect(manager.completedMissions).to.be.instanceof(Set); expect(manager.completedMissions).to.be.instanceof(Set);
@ -54,14 +55,15 @@ describe("Manager: MissionManager", () => {
expect(manager.missionRegistry.get("MISSION_TEST_01")).to.equal(newMission); expect(manager.missionRegistry.get("MISSION_TEST_01")).to.equal(newMission);
}); });
it("CoA 3: getActiveMission should return tutorial if no active mission", () => { it("CoA 3: getActiveMission should return tutorial if no active mission", async () => {
const mission = manager.getActiveMission(); await manager._ensureMissionsLoaded();
const mission = await manager.getActiveMission();
expect(mission).to.exist; expect(mission).to.exist;
expect(mission.id).to.equal("MISSION_TUTORIAL_01"); 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 = { const testMission = {
id: "MISSION_TEST_01", id: "MISSION_TEST_01",
config: { title: "Test" }, config: { title: "Test" },
@ -70,13 +72,14 @@ describe("Manager: MissionManager", () => {
manager.registerMission(testMission); manager.registerMission(testMission);
manager.activeMissionId = "MISSION_TEST_01"; manager.activeMissionId = "MISSION_TEST_01";
const mission = manager.getActiveMission(); const mission = await manager.getActiveMission();
expect(mission.id).to.equal("MISSION_TEST_01"); expect(mission.id).to.equal("MISSION_TEST_01");
}); });
it("CoA 5: setupActiveMission should initialize objectives", () => { it("CoA 5: setupActiveMission should initialize objectives", async () => {
const mission = manager.getActiveMission(); await manager._ensureMissionsLoaded();
const mission = await manager.getActiveMission();
mission.objectives = { mission.objectives = {
primary: [ primary: [
{ type: "ELIMINATE_ALL", target_count: 5 }, { 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).to.have.length(2);
expect(manager.currentObjectives[0].current).to.equal(0); expect(manager.currentObjectives[0].current).to.equal(0);
@ -708,5 +711,49 @@ describe("Manager: MissionManager", () => {
expect(manager.failureConditions[1].type).to.equal("VIP_DEATH"); 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;
}
});
});
}); });

View file

@ -395,5 +395,248 @@ describe("UI: MissionBoard", () => {
expect(typeBadges[3].classList.contains("PROCEDURAL")).to.be.true; 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");
});
});
}); });