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