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:
parent
8d2baacd5f
commit
a7c60ac56d
26 changed files with 3312 additions and 231 deletions
|
|
@ -35,6 +35,7 @@ alwaysApply: true
|
|||
## **Coding Style**
|
||||
|
||||
- Use ES6 Modules (import/export).
|
||||
- Lazy load at need, only staticly import if something is needed at load, prior to user interaction.
|
||||
- Prefer const over let. No var.
|
||||
- Use JSDoc for all public methods and complex algorithms.
|
||||
- **No Circular Dependencies:** Managers should not import GameLoop. GameLoop acts as the orchestrator.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ alwaysApply: false
|
|||
- Use **LitElement** for all UI components.
|
||||
- Filename should match the component name (kebab-case)
|
||||
- Styles must be scoped within static get styles().
|
||||
- Use theme styles where applicable
|
||||
|
||||
## **Integration Logic**
|
||||
|
||||
|
|
|
|||
105
specs/Research.spec.md
Normal file
105
specs/Research.spec.md
Normal 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.
|
||||
8
src/assets/data/missions/mission.d.ts
vendored
8
src/assets/data/missions/mission.d.ts
vendored
|
|
@ -41,6 +41,14 @@ export interface MissionConfig {
|
|||
recommended_level?: number;
|
||||
/** Path to icon image */
|
||||
icon?: string;
|
||||
/** List of mission IDs that must be completed before this mission is available */
|
||||
prerequisites?: string[];
|
||||
/**
|
||||
* Controls visibility when prerequisites are not met.
|
||||
* - "hidden": Mission is completely hidden until prerequisites are met (default for STORY)
|
||||
* - "locked": Mission shows as locked with requirements visible (default for SIDE_QUEST, PROCEDURAL)
|
||||
*/
|
||||
visibility_when_locked?: "hidden" | "locked";
|
||||
}
|
||||
|
||||
// --- BIOME / WORLD GEN ---
|
||||
|
|
|
|||
55
src/assets/data/missions/mission_story_02.json
Normal file
55
src/assets/data/missions/mission_story_02.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/assets/data/missions/mission_story_03.json
Normal file
59
src/assets/data/missions/mission_story_03.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/assets/data/narrative/story_02_intro.json
Normal file
29
src/assets/data/narrative/story_02_intro.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
src/assets/data/narrative/story_02_outro.json
Normal file
22
src/assets/data/narrative/story_02_outro.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
src/assets/data/narrative/story_03_intro.json
Normal file
21
src/assets/data/narrative/story_03_intro.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
src/assets/data/narrative/story_03_mid.json
Normal file
13
src/assets/data/narrative/story_03_mid.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
src/assets/data/narrative/story_03_outro.json
Normal file
21
src/assets/data/narrative/story_03_outro.json
Normal 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
911
src/core/DebugCommands.js
Normal 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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ import { RosterManager } from "../managers/RosterManager.js";
|
|||
import { MissionManager } from "../managers/MissionManager.js";
|
||||
import { narrativeManager } from "../managers/NarrativeManager.js";
|
||||
import { MarketManager } from "../managers/MarketManager.js";
|
||||
import { ResearchManager } from "../managers/ResearchManager.js";
|
||||
import { InventoryManager } from "../managers/InventoryManager.js";
|
||||
import { InventoryContainer } from "../models/InventoryContainer.js";
|
||||
import { itemRegistry } from "../managers/ItemRegistry.js";
|
||||
|
|
@ -74,6 +75,11 @@ class GameStateManagerClass {
|
|||
this.hubInventoryManager,
|
||||
this.missionManager
|
||||
);
|
||||
/** @type {ResearchManager} */
|
||||
this.researchManager = new ResearchManager(
|
||||
this.persistence,
|
||||
this.rosterManager
|
||||
);
|
||||
|
||||
this.handleEmbark = this.handleEmbark.bind(this);
|
||||
}
|
||||
|
|
@ -105,6 +111,11 @@ class GameStateManagerClass {
|
|||
setGameLoop(loop) {
|
||||
this.gameLoop = loop;
|
||||
this.#gameLoopInitialized.resolve();
|
||||
|
||||
// Update debug commands reference
|
||||
if (typeof window !== "undefined" && window.debugCommands) {
|
||||
window.debugCommands.setGameLoop(loop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,7 +168,10 @@ class GameStateManagerClass {
|
|||
// 3. Initialize Market Manager
|
||||
await this.marketManager.init();
|
||||
|
||||
// 4. Load Campaign Progress
|
||||
// 4. Initialize Research Manager
|
||||
await this.researchManager.load();
|
||||
|
||||
// 5. Load Campaign Progress
|
||||
const savedCampaignData = await this.persistence.loadCampaign();
|
||||
console.log("Loaded campaign data:", savedCampaignData);
|
||||
if (savedCampaignData) {
|
||||
|
|
@ -170,10 +184,10 @@ class GameStateManagerClass {
|
|||
console.log("No saved campaign data found");
|
||||
}
|
||||
|
||||
// 5. Set up mission rewards listener
|
||||
// 6. Set up mission rewards listener
|
||||
this._setupMissionRewardsListener();
|
||||
|
||||
// 6. Set up campaign data change listener
|
||||
// 7. Set up campaign data change listener
|
||||
this._setupCampaignDataListener();
|
||||
|
||||
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
|
||||
|
|
@ -326,8 +340,8 @@ class GameStateManagerClass {
|
|||
|
||||
// 1. Mission Logic: Setup
|
||||
// This resets objectives and prepares the logic for the new run
|
||||
this.missionManager.setupActiveMission();
|
||||
const missionDef = this.missionManager.getActiveMission();
|
||||
await this.missionManager.setupActiveMission();
|
||||
const missionDef = await this.missionManager.getActiveMission();
|
||||
|
||||
console.log(`Initializing Run for Mission: ${missionDef.config.title}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const MARKET_STORE = "Market";
|
|||
const CAMPAIGN_STORE = "Campaign";
|
||||
const HUB_STASH_STORE = "HubStash";
|
||||
const UNLOCKS_STORE = "Unlocks";
|
||||
const VERSION = 6; // Bumped version to add Campaign store
|
||||
const RESEARCH_STORE = "Research";
|
||||
const VERSION = 7; // Bumped version to add Research store
|
||||
|
||||
/**
|
||||
* Handles game data persistence using IndexedDB.
|
||||
|
|
@ -69,6 +70,11 @@ export class Persistence {
|
|||
if (!db.objectStoreNames.contains(UNLOCKS_STORE)) {
|
||||
db.createObjectStore(UNLOCKS_STORE, { keyPath: "id" });
|
||||
}
|
||||
|
||||
// Create Research Store if missing
|
||||
if (!db.objectStoreNames.contains(RESEARCH_STORE)) {
|
||||
db.createObjectStore(RESEARCH_STORE, { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (e) => {
|
||||
|
|
@ -233,6 +239,28 @@ export class Persistence {
|
|||
return result ? result.data : [];
|
||||
}
|
||||
|
||||
// --- RESEARCH DATA ---
|
||||
|
||||
/**
|
||||
* Saves research state.
|
||||
* @param {import("../managers/ResearchManager.js").ResearchState} researchState - Research state to save
|
||||
* @returns {Promise<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 ---
|
||||
|
||||
/**
|
||||
|
|
|
|||
90
src/index.js
90
src/index.js
|
|
@ -349,3 +349,93 @@ window.addEventListener("save-and-quit", async () => {
|
|||
|
||||
// Boot
|
||||
gameStateManager.init();
|
||||
|
||||
// Lazy-load debug commands - only load when first accessed
|
||||
// Creates async wrappers for all methods that load the module on first call
|
||||
if (typeof window !== "undefined") {
|
||||
let debugCommandsInstance = null;
|
||||
let debugCommandsLoading = null;
|
||||
|
||||
// List of all debug command methods (for creating async wrappers)
|
||||
const debugCommandMethods = [
|
||||
"addXP",
|
||||
"setLevel",
|
||||
"addSkillPoints",
|
||||
"unlockSkill",
|
||||
"unlockAllSkills",
|
||||
"addItem",
|
||||
"addCurrency",
|
||||
"killEnemy",
|
||||
"healUnit",
|
||||
"triggerVictory",
|
||||
"completeObjective",
|
||||
"triggerNarrative",
|
||||
"help",
|
||||
"listIds",
|
||||
"listPlayerIds",
|
||||
"listEnemyIds",
|
||||
"listItemIds",
|
||||
"listUnits",
|
||||
"listItems",
|
||||
"getState",
|
||||
];
|
||||
|
||||
// Create async wrapper functions for each method
|
||||
const createAsyncWrapper = (methodName) => {
|
||||
return function (...args) {
|
||||
// If already loaded, call directly
|
||||
if (debugCommandsInstance) {
|
||||
const method = debugCommandsInstance[methodName];
|
||||
if (typeof method === "function") {
|
||||
return method.apply(debugCommandsInstance, args);
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
// If currently loading, wait for it
|
||||
if (debugCommandsLoading) {
|
||||
return debugCommandsLoading.then(() => {
|
||||
const method = debugCommandsInstance[methodName];
|
||||
if (typeof method === "function") {
|
||||
return method.apply(debugCommandsInstance, args);
|
||||
}
|
||||
return method;
|
||||
});
|
||||
}
|
||||
|
||||
// Start loading the module
|
||||
debugCommandsLoading = import("./core/DebugCommands.js").then(
|
||||
(module) => {
|
||||
const { debugCommands } = module;
|
||||
if (!debugCommands._initialized) {
|
||||
debugCommands.init();
|
||||
debugCommands._initialized = true;
|
||||
}
|
||||
debugCommandsInstance = debugCommands;
|
||||
return debugCommands;
|
||||
}
|
||||
);
|
||||
|
||||
// Wait for load, then call the method
|
||||
return debugCommandsLoading.then(() => {
|
||||
const method = debugCommandsInstance[methodName];
|
||||
if (typeof method === "function") {
|
||||
return method.apply(debugCommandsInstance, args);
|
||||
}
|
||||
return method;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Create the debugCommands object with async wrappers
|
||||
window.debugCommands = {};
|
||||
debugCommandMethods.forEach((methodName) => {
|
||||
window.debugCommands[methodName] = createAsyncWrapper(methodName);
|
||||
});
|
||||
|
||||
// Add a note about async nature
|
||||
Object.defineProperty(window.debugCommands, "_note", {
|
||||
value: "Debug commands are lazy-loaded. First call may take a moment.",
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
import { Item } from "../items/Item.js";
|
||||
import tier1Gear from "../items/tier1_gear.json" with { type: "json" };
|
||||
|
||||
export class ItemRegistry {
|
||||
constructor() {
|
||||
|
|
@ -36,6 +35,9 @@ export class ItemRegistry {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _doLoadAll() {
|
||||
// Lazy-load tier1_gear.json
|
||||
const tier1Gear = await import("../items/tier1_gear.json", { with: { type: "json" } }).then(m => m.default);
|
||||
|
||||
// Load tier1_gear.json
|
||||
for (const itemDef of tier1Gear) {
|
||||
if (itemDef && itemDef.id) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
* @typedef {import("./types.js").GameEventData} GameEventData
|
||||
*/
|
||||
|
||||
import tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' };
|
||||
import { narrativeManager } from './NarrativeManager.js';
|
||||
|
||||
/**
|
||||
|
|
@ -45,8 +44,47 @@ export class MissionManager {
|
|||
/** @type {number} */
|
||||
this.currentTurn = 0;
|
||||
|
||||
// Register default missions
|
||||
this.registerMission(tutorialMission);
|
||||
/** @type {Promise<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(story02Mission);
|
||||
this.registerMission(story03Mission);
|
||||
} catch (error) {
|
||||
console.error('Failed to load missions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -85,9 +123,11 @@ export class MissionManager {
|
|||
|
||||
/**
|
||||
* Gets the configuration for the currently selected mission.
|
||||
* @returns {MissionDefinition | undefined} - Active mission definition
|
||||
* Ensures missions are loaded before accessing.
|
||||
* @returns {Promise<MissionDefinition | undefined>} - Active mission definition
|
||||
*/
|
||||
getActiveMission() {
|
||||
async getActiveMission() {
|
||||
await this._ensureMissionsLoaded();
|
||||
if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01');
|
||||
return this.missionRegistry.get(this.activeMissionId);
|
||||
}
|
||||
|
|
@ -111,9 +151,11 @@ export class MissionManager {
|
|||
/**
|
||||
* Prepares the manager for a new run.
|
||||
* Resets objectives and prepares narrative hooks.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
setupActiveMission() {
|
||||
const mission = this.getActiveMission();
|
||||
async setupActiveMission() {
|
||||
await this._ensureMissionsLoaded();
|
||||
const mission = await this.getActiveMission();
|
||||
this.currentMissionDef = mission;
|
||||
this.currentTurn = 0;
|
||||
|
||||
|
|
@ -551,6 +593,11 @@ export class MissionManager {
|
|||
localStorage.setItem('aether_shards_unlocks', JSON.stringify(unlocks));
|
||||
console.log('Unlocked classes (localStorage fallback):', classIds);
|
||||
}
|
||||
|
||||
// Dispatch event so UI components can refresh
|
||||
window.dispatchEvent(new CustomEvent('classes-unlocked', {
|
||||
detail: { unlockedClasses: classIds, allUnlocks: unlocks }
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to save unlocks to storage:', e);
|
||||
}
|
||||
|
|
|
|||
397
src/managers/ResearchManager.js
Normal file
397
src/managers/ResearchManager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +228,9 @@ export class MissionBoard extends LitElement {
|
|||
this._loadMissions();
|
||||
}
|
||||
|
||||
_loadMissions() {
|
||||
async _loadMissions() {
|
||||
// Ensure missions are loaded before accessing registry
|
||||
await gameStateManager.missionManager._ensureMissionsLoaded();
|
||||
// Get all registered missions from MissionManager
|
||||
const missionRegistry = gameStateManager.missionManager.missionRegistry;
|
||||
this.missions = Array.from(missionRegistry.values());
|
||||
|
|
@ -238,8 +240,41 @@ export class MissionBoard extends LitElement {
|
|||
|
||||
_isMissionAvailable(mission) {
|
||||
// Check if mission prerequisites are met
|
||||
// For now, all missions are available unless they have explicit prerequisites
|
||||
return true;
|
||||
const prerequisites = mission.config?.prerequisites || [];
|
||||
|
||||
// If no prerequisites, mission is available
|
||||
if (prerequisites.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if all prerequisites are completed
|
||||
return prerequisites.every(prereqId => this.completedMissions.has(prereqId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a mission should be shown in the mission board.
|
||||
* Story missions are hidden until available, other types show as locked.
|
||||
* @param {Object} mission - Mission definition
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldShowMission(mission) {
|
||||
const isAvailable = this._isMissionAvailable(mission);
|
||||
|
||||
// If available, always show
|
||||
if (isAvailable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check visibility setting (defaults based on mission type)
|
||||
const visibility = mission.config?.visibility_when_locked;
|
||||
|
||||
// Default behavior: STORY missions are hidden, others show as locked
|
||||
if (visibility === undefined) {
|
||||
return mission.type !== "STORY";
|
||||
}
|
||||
|
||||
// Explicit setting overrides default
|
||||
return visibility === "locked";
|
||||
}
|
||||
|
||||
_isMissionCompleted(missionId) {
|
||||
|
|
@ -321,7 +356,9 @@ export class MissionBoard extends LitElement {
|
|||
</div>
|
||||
|
||||
<div class="missions-grid">
|
||||
${this.missions.map((mission) => {
|
||||
${this.missions
|
||||
.filter(mission => this._shouldShowMission(mission))
|
||||
.map((mission) => {
|
||||
const isCompleted = this._isMissionCompleted(mission.id);
|
||||
const isAvailable = this._isMissionAvailable(mission);
|
||||
const rewards = this._formatRewards(mission.rewards);
|
||||
|
|
@ -358,6 +395,14 @@ export class MissionBoard extends LitElement {
|
|||
Difficulty: ${this._getDifficultyLabel(mission.config)}
|
||||
</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`
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export class GameViewport extends LitElement {
|
|||
|
||||
#handleStartBattle() {
|
||||
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
|
||||
// Squad will be set from activeRunData when transitioning to deployment state
|
||||
|
||||
// Get mission definition for deployment hints
|
||||
this.missionDef =
|
||||
gameStateManager.missionManager?.getActiveMission() || null;
|
||||
// Get mission definition for deployment hints (lazy-loaded)
|
||||
this.missionDef = null;
|
||||
if (gameStateManager.missionManager) {
|
||||
gameStateManager.missionManager
|
||||
.getActiveMission()
|
||||
.then((mission) => {
|
||||
this.missionDef = mission || null;
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
// Set up combat state updates
|
||||
this.#setupCombatStateUpdates();
|
||||
|
|
|
|||
712
src/ui/screens/ResearchScreen.js
Normal file
712
src/ui/screens/ResearchScreen.js
Normal 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);
|
||||
|
||||
|
|
@ -371,18 +371,7 @@ export class HubScreen extends LitElement {
|
|||
break;
|
||||
case "RESEARCH":
|
||||
overlayComponent = html`
|
||||
<div
|
||||
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>
|
||||
<research-screen @close=${this._closeOverlay}></research-screen>
|
||||
`;
|
||||
break;
|
||||
case "SYSTEM":
|
||||
|
|
@ -432,6 +421,10 @@ export class HubScreen extends LitElement {
|
|||
if (this.activeOverlay === "BARRACKS") {
|
||||
import("./BarracksScreen.js").catch(console.error);
|
||||
}
|
||||
// Trigger async import when RESEARCH overlay is opened
|
||||
if (this.activeOverlay === "RESEARCH") {
|
||||
import("./ResearchScreen.js").catch(console.error);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="background"></div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { LitElement, html, css } from 'lit';
|
||||
import { theme, buttonStyles, cardStyles } from './styles/theme.js';
|
||||
import { gameStateManager } from '../core/GameStateManager.js';
|
||||
|
||||
// Import Tier 1 Class Definitions
|
||||
import vanguardDef from '../assets/data/classes/vanguard.json' with { type: 'json' };
|
||||
import weaverDef from '../assets/data/classes/aether_weaver.json' with { type: 'json' };
|
||||
import scavengerDef from '../assets/data/classes/scavenger.json' with { type: 'json' };
|
||||
import tinkerDef from '../assets/data/classes/tinker.json' with { type: 'json' };
|
||||
import custodianDef from '../assets/data/classes/custodian.json' with { type: 'json' };
|
||||
// Class definitions will be lazy-loaded when component connects
|
||||
|
||||
// UI Metadata Mapping
|
||||
const CLASS_METADATA = {
|
||||
|
|
@ -42,7 +38,8 @@ const CLASS_METADATA = {
|
|||
}
|
||||
};
|
||||
|
||||
const RAW_TIER_1_CLASSES = [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef];
|
||||
// Class definitions loaded lazily
|
||||
let RAW_TIER_1_CLASSES = null;
|
||||
|
||||
export class TeamBuilder extends LitElement {
|
||||
static get styles() {
|
||||
|
|
@ -272,9 +269,30 @@ export class TeamBuilder extends LitElement {
|
|||
this._poolExplicitlySet = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._initializeData();
|
||||
await this._initializeData();
|
||||
|
||||
// Listen for unlock changes to refresh the class list
|
||||
this._boundHandleUnlocksChanged = this._handleUnlocksChanged.bind(this);
|
||||
window.addEventListener('classes-unlocked', this._boundHandleUnlocksChanged);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._boundHandleUnlocksChanged) {
|
||||
window.removeEventListener('classes-unlocked', this._boundHandleUnlocksChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unlock changes by refreshing the class list.
|
||||
*/
|
||||
async _handleUnlocksChanged() {
|
||||
if (this.mode === 'DRAFT') {
|
||||
await this._initializeData();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -290,7 +308,7 @@ export class TeamBuilder extends LitElement {
|
|||
/**
|
||||
* Configures the component based on provided data.
|
||||
*/
|
||||
_initializeData() {
|
||||
async _initializeData() {
|
||||
// 1. If availablePool was explicitly set (from mission selection), use ROSTER mode.
|
||||
// This happens when opening from mission selection - we want to show roster even if all units are injured.
|
||||
if (this._poolExplicitlySet) {
|
||||
|
|
@ -300,13 +318,48 @@ export class TeamBuilder extends LitElement {
|
|||
}
|
||||
|
||||
// 2. Default: Draft Mode (New Game)
|
||||
// Populate with Tier 1 classes
|
||||
// Populate with Tier 1 classes and check unlock status
|
||||
this.mode = 'DRAFT';
|
||||
|
||||
// Lazy-load class definitions if not already loaded
|
||||
if (!RAW_TIER_1_CLASSES) {
|
||||
const [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef] = await Promise.all([
|
||||
import('../assets/data/classes/vanguard.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/classes/aether_weaver.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/classes/scavenger.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/classes/tinker.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/classes/custodian.json', { with: { type: 'json' } }).then(m => m.default)
|
||||
]);
|
||||
RAW_TIER_1_CLASSES = [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef];
|
||||
}
|
||||
|
||||
// Load unlocked classes from persistence
|
||||
let unlockedClasses = [];
|
||||
try {
|
||||
if (gameStateManager?.persistence) {
|
||||
unlockedClasses = await gameStateManager.persistence.loadUnlocks();
|
||||
} else {
|
||||
// Fallback to localStorage if persistence not available
|
||||
const stored = localStorage.getItem('aether_shards_unlocks');
|
||||
if (stored) {
|
||||
unlockedClasses = JSON.parse(stored);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load unlocks:', e);
|
||||
}
|
||||
|
||||
// Define which classes are unlocked by default (starter classes)
|
||||
// Note: CLASS_TINKER is unlocked by the tutorial mission, so it's not in the default list
|
||||
const defaultUnlocked = ['CLASS_VANGUARD', 'CLASS_WEAVER'];
|
||||
|
||||
this.availablePool = RAW_TIER_1_CLASSES.map(cls => {
|
||||
const meta = CLASS_METADATA[cls.id] || {};
|
||||
return { ...cls, ...meta, unlocked: true };
|
||||
// Check if class is unlocked (either default or in unlocked list)
|
||||
const isUnlocked = defaultUnlocked.includes(cls.id) || unlockedClasses.includes(cls.id);
|
||||
return { ...cls, ...meta, unlocked: isUnlocked };
|
||||
});
|
||||
console.log("TeamBuilder: Initializing Draft Mode");
|
||||
console.log("TeamBuilder: Initializing Draft Mode", { unlockedClasses, availablePool: this.availablePool.map(c => ({ id: c.id, unlocked: c.unlocked })) });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ describe("Manager: MissionManager", () => {
|
|||
sinon.restore();
|
||||
});
|
||||
|
||||
it("CoA 1: Should initialize with tutorial mission registered", () => {
|
||||
it("CoA 1: Should initialize with tutorial mission registered", async () => {
|
||||
await manager._ensureMissionsLoaded();
|
||||
expect(manager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true;
|
||||
expect(manager.activeMissionId).to.be.null;
|
||||
expect(manager.completedMissions).to.be.instanceof(Set);
|
||||
|
|
@ -54,14 +55,15 @@ describe("Manager: MissionManager", () => {
|
|||
expect(manager.missionRegistry.get("MISSION_TEST_01")).to.equal(newMission);
|
||||
});
|
||||
|
||||
it("CoA 3: getActiveMission should return tutorial if no active mission", () => {
|
||||
const mission = manager.getActiveMission();
|
||||
it("CoA 3: getActiveMission should return tutorial if no active mission", async () => {
|
||||
await manager._ensureMissionsLoaded();
|
||||
const mission = await manager.getActiveMission();
|
||||
|
||||
expect(mission).to.exist;
|
||||
expect(mission.id).to.equal("MISSION_TUTORIAL_01");
|
||||
});
|
||||
|
||||
it("CoA 4: getActiveMission should return active mission if set", () => {
|
||||
it("CoA 4: getActiveMission should return active mission if set", async () => {
|
||||
const testMission = {
|
||||
id: "MISSION_TEST_01",
|
||||
config: { title: "Test" },
|
||||
|
|
@ -70,13 +72,14 @@ describe("Manager: MissionManager", () => {
|
|||
manager.registerMission(testMission);
|
||||
manager.activeMissionId = "MISSION_TEST_01";
|
||||
|
||||
const mission = manager.getActiveMission();
|
||||
const mission = await manager.getActiveMission();
|
||||
|
||||
expect(mission.id).to.equal("MISSION_TEST_01");
|
||||
});
|
||||
|
||||
it("CoA 5: setupActiveMission should initialize objectives", () => {
|
||||
const mission = manager.getActiveMission();
|
||||
it("CoA 5: setupActiveMission should initialize objectives", async () => {
|
||||
await manager._ensureMissionsLoaded();
|
||||
const mission = await manager.getActiveMission();
|
||||
mission.objectives = {
|
||||
primary: [
|
||||
{ type: "ELIMINATE_ALL", target_count: 5 },
|
||||
|
|
@ -84,7 +87,7 @@ describe("Manager: MissionManager", () => {
|
|||
],
|
||||
};
|
||||
|
||||
manager.setupActiveMission();
|
||||
await manager.setupActiveMission();
|
||||
|
||||
expect(manager.currentObjectives).to.have.length(2);
|
||||
expect(manager.currentObjectives[0].current).to.equal(0);
|
||||
|
|
@ -708,5 +711,49 @@ describe("Manager: MissionManager", () => {
|
|||
expect(manager.failureConditions[1].type).to.equal("VIP_DEATH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lazy Loading", () => {
|
||||
it("CoA 31: Should lazy-load missions on first access", async () => {
|
||||
// Create a fresh manager to test lazy loading
|
||||
const freshManager = new MissionManager(mockPersistence);
|
||||
|
||||
// Initially, registry should be empty (missions not loaded)
|
||||
expect(freshManager.missionRegistry.size).to.equal(0);
|
||||
|
||||
// Trigger lazy loading
|
||||
await freshManager._ensureMissionsLoaded();
|
||||
|
||||
// Now missions should be loaded
|
||||
expect(freshManager.missionRegistry.size).to.be.greaterThan(0);
|
||||
expect(freshManager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true;
|
||||
});
|
||||
|
||||
it("CoA 32: Should not reload missions if already loaded", async () => {
|
||||
// Load missions first time
|
||||
await manager._ensureMissionsLoaded();
|
||||
const firstSize = manager.missionRegistry.size;
|
||||
|
||||
// Load again - should not duplicate
|
||||
await manager._ensureMissionsLoaded();
|
||||
const secondSize = manager.missionRegistry.size;
|
||||
|
||||
expect(firstSize).to.equal(secondSize);
|
||||
});
|
||||
|
||||
it("CoA 33: Should handle lazy loading errors gracefully", async () => {
|
||||
// Create a manager with a failing persistence (if needed)
|
||||
const freshManager = new MissionManager(mockPersistence);
|
||||
|
||||
// Should not throw even if missions fail to load
|
||||
try {
|
||||
await freshManager._ensureMissionsLoaded();
|
||||
// If we get here, it handled gracefully
|
||||
expect(true).to.be.true;
|
||||
} catch (error) {
|
||||
// If error occurs, it should be handled
|
||||
expect(error).to.exist;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -395,5 +395,248 @@ describe("UI: MissionBoard", () => {
|
|||
expect(typeBadges[3].classList.contains("PROCEDURAL")).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mission Prerequisites", () => {
|
||||
it("should show mission as available when no prerequisites", async () => {
|
||||
const mission = {
|
||||
id: "MISSION_01",
|
||||
type: "STORY",
|
||||
config: { title: "Test Mission", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
mockMissionManager.missionRegistry.set(mission.id, mission);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCard = queryShadow(".mission-card");
|
||||
expect(missionCard).to.exist;
|
||||
expect(missionCard.classList.contains("locked")).to.be.false;
|
||||
});
|
||||
|
||||
it("should show mission as locked when prerequisites not met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "SIDE_QUEST",
|
||||
config: { title: "First Mission", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "SIDE_QUEST",
|
||||
config: {
|
||||
title: "Second Mission",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(2);
|
||||
|
||||
const mission2Card = Array.from(missionCards).find((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
||||
);
|
||||
expect(mission2Card).to.exist;
|
||||
expect(mission2Card.classList.contains("locked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should show mission as available when prerequisites are met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "SIDE_QUEST",
|
||||
config: { title: "First Mission", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "SIDE_QUEST",
|
||||
config: {
|
||||
title: "Second Mission",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
mockMissionManager.completedMissions.add("MISSION_01");
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
const mission2Card = Array.from(missionCards).find((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
||||
);
|
||||
expect(mission2Card).to.exist;
|
||||
expect(mission2Card.classList.contains("locked")).to.be.false;
|
||||
});
|
||||
|
||||
it("should display prerequisite requirements for locked missions", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "SIDE_QUEST",
|
||||
config: { title: "First Mission", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "SIDE_QUEST",
|
||||
config: {
|
||||
title: "Second Mission",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
const mission2Card = Array.from(missionCards).find((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
||||
);
|
||||
expect(mission2Card).to.exist;
|
||||
expect(mission2Card.textContent).to.include("Requires");
|
||||
expect(mission2Card.textContent).to.include("First Mission");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mission Visibility", () => {
|
||||
it("should hide STORY missions when prerequisites not met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "STORY",
|
||||
config: { title: "First Story", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "STORY",
|
||||
config: {
|
||||
title: "Second Story",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(1);
|
||||
const titles = Array.from(missionCards).map((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.trim()
|
||||
);
|
||||
expect(titles).to.include("First Story");
|
||||
expect(titles).to.not.include("Second Story");
|
||||
});
|
||||
|
||||
it("should show SIDE_QUEST missions as locked when prerequisites not met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "SIDE_QUEST",
|
||||
config: { title: "First Quest", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "SIDE_QUEST",
|
||||
config: {
|
||||
title: "Second Quest",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(2);
|
||||
const titles = Array.from(missionCards).map((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.trim()
|
||||
);
|
||||
expect(titles).to.include("First Quest");
|
||||
expect(titles).to.include("Second Quest");
|
||||
|
||||
const mission2Card = Array.from(missionCards).find((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.includes("Second Quest")
|
||||
);
|
||||
expect(mission2Card.classList.contains("locked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should show STORY mission when prerequisites are met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "STORY",
|
||||
config: { title: "First Story", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "STORY",
|
||||
config: {
|
||||
title: "Second Story",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
mockMissionManager.completedMissions.add("MISSION_01");
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(2);
|
||||
const titles = Array.from(missionCards).map((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.trim()
|
||||
);
|
||||
expect(titles).to.include("First Story");
|
||||
expect(titles).to.include("Second Story");
|
||||
});
|
||||
|
||||
it("should respect explicit visibility_when_locked setting", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "STORY",
|
||||
config: { title: "First Story", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "STORY",
|
||||
config: {
|
||||
title: "Second Story",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
visibility_when_locked: "locked", // Override default hidden behavior
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(2);
|
||||
const titles = Array.from(missionCards).map((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.trim()
|
||||
);
|
||||
expect(titles).to.include("Second Story");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue