Implement Research system and enhance mission management
- Introduce the ResearchManager to manage tech trees, node unlocking, and passive effects, enhancing gameplay depth. - Update GameStateManager to integrate the ResearchManager, ensuring seamless data handling for research states. - Implement lazy loading for mission definitions and class data to improve performance and resource management. - Enhance UI components, including the ResearchScreen and MissionBoard, to support new research features and mission prerequisites. - Add comprehensive tests for the ResearchManager and related UI components to validate functionality and integration within the game architecture.
This commit is contained in:
parent
8d2baacd5f
commit
a7c60ac56d
26 changed files with 3312 additions and 231 deletions
|
|
@ -35,6 +35,7 @@ alwaysApply: true
|
|||
## **Coding Style**
|
||||
|
||||
- Use ES6 Modules (import/export).
|
||||
- Lazy load at need, only staticly import if something is needed at load, prior to user interaction.
|
||||
- Prefer const over let. No var.
|
||||
- Use JSDoc for all public methods and complex algorithms.
|
||||
- **No Circular Dependencies:** Managers should not import GameLoop. GameLoop acts as the orchestrator.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ alwaysApply: false
|
|||
- Use **LitElement** for all UI components.
|
||||
- Filename should match the component name (kebab-case)
|
||||
- Styles must be scoped within static get styles().
|
||||
- Use theme styles where applicable
|
||||
|
||||
## **Integration Logic**
|
||||
|
||||
|
|
|
|||
105
specs/Research.spec.md
Normal file
105
specs/Research.spec.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# **Research Specification: The Ancient Archive**
|
||||
|
||||
This document defines the UI and Logic for the Research Facility. This is the primary sink for **Ancient Cores** (Rare Currency) and provides global, permanent buffs to the campaign.
|
||||
|
||||
## **1. Context & Unlock**
|
||||
|
||||
What is it?
|
||||
A facility where the player studies recovered technology and magical theory to improve the efficiency of their operation. Unlike Class Mastery (which buffs specific units), Research buffs the player's infrastructure.
|
||||
**How is it unlocked?**
|
||||
|
||||
- **Requirement:** Complete Story Mission 3 ("The Buried Library").
|
||||
- **Narrative Event:** The player recovers a damaged "Archive Core" AI. Installing it in the Hub opens the Research tab.
|
||||
|
||||
## **2. Visual Design & Layout**
|
||||
|
||||
**Setting:** A cluttered corner of the camp filled with glowing blue hologram projectors and piles of scrolls.
|
||||
|
||||
- **Vibe:** Intellectual, mystical, high-tech.
|
||||
|
||||
**Layout:**
|
||||
|
||||
- **Header:** "Ancient Archive". Displays **Ancient Cores** count (Large).
|
||||
- **Main View (The Tree):** A horizontal scrolling view of 3 distinct "Tech Trees".
|
||||
1. **Logistics (Green):** Economic and Roster upgrades.
|
||||
2. **Intelligence (Blue):** Map and Enemy info upgrades.
|
||||
3. **Field Ops (Red):** Combat preparation and starting bonuses.
|
||||
- **Inspector Panel (Right):**
|
||||
- Selected Node Name & Icon.
|
||||
- Description ("Increases Roster Size by 2").
|
||||
- Cost ("2 Ancient Cores").
|
||||
- Status ("Locked", "Available", "Researched").
|
||||
- Button: "RESEARCH".
|
||||
|
||||
## **3. The Tech Trees**
|
||||
|
||||
### **A. Logistics (Economy & Management)**
|
||||
|
||||
1. **Expanded Barracks I:** Roster Limit +2.
|
||||
2. **Bulk Contracts:** Recruiting cost -10%.
|
||||
3. **Expanded Barracks II:** Roster Limit +4.
|
||||
4. **Deep Pockets:** Market Buyback slots +2.
|
||||
5. **Salvage Protocol:** Sell prices at Market +10%.
|
||||
|
||||
### **B. Intelligence (Information Warfare)**
|
||||
|
||||
1. **Scout Drone:** Reveals the biome type of Side Missions before accepting them.
|
||||
2. **Vital Sensors:** Enemy Health Bars show exact numbers instead of just bars.
|
||||
3. **Threat Analysis:** Reveals Enemy Types (e.g. "Mechanical") on the Mission Board dossier.
|
||||
4. **Map Data:** Reveals the location of the Boss Room on the minimap at start of run.
|
||||
|
||||
### **C. Field Ops (Combat Prep)**
|
||||
|
||||
1. **Supply Drop:** Start every run with 1 Free Potion in the shared stash.
|
||||
2. **Fast Deploy:** First turn of combat grants +1 AP to all units.
|
||||
3. **Emergency Beacon:** "Retreat" option becomes available (Escape with 50% loot retention instead of 0%).
|
||||
4. **Hardened Steel:** All crafted/bought weapons start with +1 Damage.
|
||||
|
||||
## **4. TypeScript Interfaces**
|
||||
|
||||
```ts
|
||||
// src/types/Research.ts
|
||||
|
||||
export type ResearchTreeType = "LOGISTICS" | "INTEL" | "FIELD_OPS";
|
||||
|
||||
export interface ResearchNode {
|
||||
id: string; // e.g. "RES_LOGISTICS_01"
|
||||
name: string;
|
||||
description: string;
|
||||
tree: ResearchTreeType;
|
||||
tier: number; // Depth in the tree (1-5)
|
||||
|
||||
cost: number; // Ancient Cores
|
||||
|
||||
/** IDs of parent nodes that must be unlocked first */
|
||||
prerequisites: string[];
|
||||
|
||||
/** The actual effect applied to the game state */
|
||||
effect: {
|
||||
type: "ROSTER_LIMIT" | "MARKET_DISCOUNT" | "STARTING_ITEM" | "UI_UNLOCK";
|
||||
value: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResearchState {
|
||||
unlockedNodeIds: string[]; // List of IDs player has bought
|
||||
availableCores: number;
|
||||
}
|
||||
```
|
||||
|
||||
## **5. Conditions of Acceptance (CoA)**
|
||||
|
||||
**CoA 1: Dependency Logic**
|
||||
|
||||
- A node cannot be researched unless _all_ its prerequisites are in the unlockedNodeIds list.
|
||||
- The UI must visually distinguish between Locked (Grey), Available (Lit), and Completed (Gold).
|
||||
|
||||
**CoA 2: Currency Consumption**
|
||||
|
||||
- Researching a node must deduct the exact Ancient Core cost from the global persistence.
|
||||
- The action must fail if availableCores < cost.
|
||||
|
||||
**CoA** 3: Effect **Application**
|
||||
|
||||
- **Passive:** "Expanded Barracks" must immediately update the RosterManager.rosterLimit.
|
||||
- **Runtime:** "Supply Drop" must trigger a check in GameLoop.startLevel to insert the item.
|
||||
8
src/assets/data/missions/mission.d.ts
vendored
8
src/assets/data/missions/mission.d.ts
vendored
|
|
@ -41,6 +41,14 @@ export interface MissionConfig {
|
|||
recommended_level?: number;
|
||||
/** Path to icon image */
|
||||
icon?: string;
|
||||
/** List of mission IDs that must be completed before this mission is available */
|
||||
prerequisites?: string[];
|
||||
/**
|
||||
* Controls visibility when prerequisites are not met.
|
||||
* - "hidden": Mission is completely hidden until prerequisites are met (default for STORY)
|
||||
* - "locked": Mission shows as locked with requirements visible (default for SIDE_QUEST, PROCEDURAL)
|
||||
*/
|
||||
visibility_when_locked?: "hidden" | "locked";
|
||||
}
|
||||
|
||||
// --- BIOME / WORLD GEN ---
|
||||
|
|
|
|||
55
src/assets/data/missions/mission_story_02.json
Normal file
55
src/assets/data/missions/mission_story_02.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"id": "MISSION_STORY_02",
|
||||
"type": "STORY",
|
||||
"config": {
|
||||
"title": "The Signal",
|
||||
"description": "A subspace relay in the Fungal Caves is jamming trade routes. Clear the interference.",
|
||||
"difficulty_tier": 1,
|
||||
"recommended_level": 2,
|
||||
"icon": "assets/icons/mission_signal.png"
|
||||
},
|
||||
"biome": {
|
||||
"type": "BIOME_FUNGAL_CAVES",
|
||||
"generator_config": {
|
||||
"seed_type": "RANDOM",
|
||||
"size": { "x": 22, "y": 8, "z": 22 },
|
||||
"room_count": 5,
|
||||
"density": "MEDIUM"
|
||||
},
|
||||
"hazards": ["HAZARD_POISON_SPORES"]
|
||||
},
|
||||
"deployment": {
|
||||
"squad_size_limit": 4
|
||||
},
|
||||
"narrative": {
|
||||
"intro_sequence": "NARRATIVE_STORY_02_INTRO",
|
||||
"outro_success": "NARRATIVE_STORY_02_OUTRO"
|
||||
},
|
||||
"objectives": {
|
||||
"primary": [
|
||||
{
|
||||
"id": "OBJ_FIX_RELAY",
|
||||
"type": "INTERACT",
|
||||
"target_object_id": "OBJ_SIGNAL_RELAY",
|
||||
"description": "Reboot the Ancient Signal Relay."
|
||||
}
|
||||
],
|
||||
"secondary": [
|
||||
{
|
||||
"id": "OBJ_CLEAR_INFESTATION",
|
||||
"type": "ELIMINATE_ALL",
|
||||
"description": "Clear all Shardborn from the relay chamber."
|
||||
}
|
||||
]
|
||||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"xp": 200,
|
||||
"currency": { "aether_shards": 150 },
|
||||
"unlocks": ["CLASS_SCAVENGER"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"GOLDEN_EXCHANGE": 25
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/assets/data/missions/mission_story_03.json
Normal file
59
src/assets/data/missions/mission_story_03.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"id": "MISSION_STORY_03",
|
||||
"type": "STORY",
|
||||
"config": {
|
||||
"title": "The Buried Library",
|
||||
"description": "Recover ancient data from the Crystal Spires. Beware of unstable platforms.",
|
||||
"difficulty_tier": 2,
|
||||
"recommended_level": 3,
|
||||
"icon": "assets/icons/mission_library.png",
|
||||
"prerequisites": ["MISSION_STORY_02"]
|
||||
},
|
||||
"biome": {
|
||||
"type": "BIOME_CRYSTAL_SPIRES",
|
||||
"generator_config": {
|
||||
"seed_type": "RANDOM",
|
||||
"size": { "x": 16, "y": 12, "z": 16 },
|
||||
"room_count": 0,
|
||||
"density": "LOW"
|
||||
},
|
||||
"hazards": ["HAZARD_GRAVITY_FLUX"]
|
||||
},
|
||||
"deployment": {
|
||||
"squad_size_limit": 4
|
||||
},
|
||||
"narrative": {
|
||||
"intro_sequence": "NARRATIVE_STORY_03_INTRO",
|
||||
"outro_success": "NARRATIVE_STORY_03_OUTRO",
|
||||
"scripted_events": [
|
||||
{
|
||||
"trigger": "ON_TURN_START",
|
||||
"turn_index": 2,
|
||||
"action": "PLAY_SEQUENCE",
|
||||
"sequence_id": "NARRATIVE_STORY_03_MID"
|
||||
}
|
||||
]
|
||||
},
|
||||
"objectives": {
|
||||
"primary": [
|
||||
{
|
||||
"id": "OBJ_RECOVER_DATA",
|
||||
"type": "INTERACT",
|
||||
"target_object_id": "OBJ_DATA_TERMINAL",
|
||||
"target_count": 3,
|
||||
"description": "Recover 3 Data Fragments from the floating islands."
|
||||
}
|
||||
],
|
||||
"failure_conditions": [{ "type": "SQUAD_WIPE" }]
|
||||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"xp": 350,
|
||||
"currency": { "aether_shards": 200, "ancient_cores": 2 },
|
||||
"unlocks": ["CLASS_CUSTODIAN"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"ARCANE_DOMINION": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/assets/data/narrative/story_02_intro.json
Normal file
29
src/assets/data/narrative/story_02_intro.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"id": "NARRATIVE_STORY_02_INTRO",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "Baroness Seraphina",
|
||||
"portrait": "assets/images/portraits/scavenger.png",
|
||||
"text": "Ah, the 'heroes' Vorn speaks so highly of. I am Baroness Seraphina, representing the Golden Exchange.",
|
||||
"next": "2"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "Baroness Seraphina",
|
||||
"portrait": "assets/images/portraits/scavenger.png",
|
||||
"text": "My airships are navigating blind. Something in the Fungal Caves is scattering our comms signals. It's bad for business.",
|
||||
"next": "3"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "Baroness Seraphina",
|
||||
"portrait": "assets/images/portraits/scavenger.png",
|
||||
"text": "Locate the relay node and reboot it. Do that, and I'll open my personal stocks to you.",
|
||||
"next": "END"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
src/assets/data/narrative/story_02_outro.json
Normal file
22
src/assets/data/narrative/story_02_outro.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"id": "NARRATIVE_STORY_02_OUTRO",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "Baroness Seraphina",
|
||||
"portrait": "assets/images/portraits/scavenger.png",
|
||||
"text": "Signal is green. My ships are flying again. You're useful... for mercenaries.",
|
||||
"next": "2"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "Baroness Seraphina",
|
||||
"portrait": "assets/images/portraits/scavenger.png",
|
||||
"text": "I've unlocked the Marketplace terminal for you. Don't expect a discount.",
|
||||
"trigger": { "type": "UNLOCK_CLASS", "class_id": "CLASS_SCAVENGER" },
|
||||
"next": "END"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
src/assets/data/narrative/story_03_intro.json
Normal file
21
src/assets/data/narrative/story_03_intro.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"id": "NARRATIVE_STORY_03_INTRO",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "Arch-Librarian Elara",
|
||||
"portrait": "assets/images/portraits/weaver.png",
|
||||
"text": "The signal you cleared revealed coordinates to a pre-collapse data center. We call it 'The Library'.",
|
||||
"next": "2"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "Arch-Librarian Elara",
|
||||
"portrait": "assets/images/portraits/weaver.png",
|
||||
"text": "It is located in the Crystal Spires. The gravity there is... fluid. Watch your step.",
|
||||
"next": "END"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
src/assets/data/narrative/story_03_mid.json
Normal file
13
src/assets/data/narrative/story_03_mid.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"id": "NARRATIVE_STORY_03_MID",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "TUTORIAL",
|
||||
"speaker": "Tactical OS",
|
||||
"text": "Verticality Alert: Ranged units (Weavers) gain +20% accuracy when firing from higher ground.",
|
||||
"block_input": false,
|
||||
"next": "END"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
src/assets/data/narrative/story_03_outro.json
Normal file
21
src/assets/data/narrative/story_03_outro.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"id": "NARRATIVE_STORY_03_OUTRO",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "Arch-Librarian Elara",
|
||||
"portrait": "assets/images/portraits/weaver.png",
|
||||
"text": "This data is incredible. It outlines a method for refining Ancient Cores.",
|
||||
"next": "2"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "DIALOGUE",
|
||||
"speaker": "System",
|
||||
"text": "Research Facility Unlocked. You can now spend Ancient Cores on permanent upgrades.",
|
||||
"trigger": { "type": "UNLOCK_FACILITY", "facility_id": "RESEARCH" },
|
||||
"next": "END"
|
||||
}
|
||||
]
|
||||
}
|
||||
911
src/core/DebugCommands.js
Normal file
911
src/core/DebugCommands.js
Normal file
|
|
@ -0,0 +1,911 @@
|
|||
/**
|
||||
* DebugCommands.js
|
||||
* Global command system for testing game functionality.
|
||||
* Access via window.debugCommands in the browser console.
|
||||
*/
|
||||
|
||||
import { gameStateManager } from "./GameStateManager.js";
|
||||
import { itemRegistry } from "../managers/ItemRegistry.js";
|
||||
|
||||
/**
|
||||
* Debug command system for testing game mechanics.
|
||||
* @class
|
||||
*/
|
||||
export class DebugCommands {
|
||||
constructor() {
|
||||
this.gameStateManager = null;
|
||||
this.gameLoop = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the debug commands system.
|
||||
* Should be called after gameStateManager is initialized.
|
||||
*/
|
||||
init() {
|
||||
this.gameStateManager = gameStateManager;
|
||||
// GameLoop will be set when it's available
|
||||
if (gameStateManager.gameLoop) {
|
||||
this.gameLoop = gameStateManager.gameLoop;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the game loop reference.
|
||||
* @param {import("./GameLoop.js").GameLoop} loop - Game loop instance
|
||||
*/
|
||||
setGameLoop(loop) {
|
||||
this.gameLoop = loop;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EXPLORER & LEVELING COMMANDS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Adds experience to an explorer and handles leveling.
|
||||
* @param {string} unitId - Unit ID (or "first" for first explorer, "all" for all)
|
||||
* @param {number} amount - XP amount to add
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
addXP(unitId = "first", amount = 100) {
|
||||
const units = this._getExplorers(unitId);
|
||||
if (units.length === 0) {
|
||||
return `No explorers found with ID: ${unitId}`;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const unit of units) {
|
||||
const oldLevel = unit.getLevel();
|
||||
unit.gainExperience(amount);
|
||||
|
||||
// Check for level up (simple XP curve: 100 * level^2)
|
||||
const mastery = unit.classMastery[unit.activeClassId];
|
||||
const xpForNextLevel = 100 * (mastery.level ** 2);
|
||||
|
||||
while (mastery.xp >= xpForNextLevel && mastery.level < 30) {
|
||||
mastery.level += 1;
|
||||
mastery.skillPoints += 1; // Award skill point on level up
|
||||
mastery.xp -= xpForNextLevel;
|
||||
|
||||
// Recalculate stats if class definition is available
|
||||
if (this.gameLoop?.classRegistry) {
|
||||
const classDef = this.gameLoop.classRegistry.get(unit.activeClassId);
|
||||
if (classDef) {
|
||||
unit.recalculateBaseStats(classDef);
|
||||
// Recalculate effective stats with equipment
|
||||
if (unit.recalculateStats) {
|
||||
unit.recalculateStats(
|
||||
this.gameLoop.inventoryManager?.itemRegistry || itemRegistry
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newLevel = unit.getLevel();
|
||||
const leveledUp = newLevel > oldLevel;
|
||||
results.push(
|
||||
`${unit.name} (${unit.id}): +${amount} XP. Level: ${oldLevel} → ${newLevel}. ` +
|
||||
`XP: ${mastery.xp}/${100 * (newLevel ** 2)}. ` +
|
||||
`Skill Points: ${mastery.skillPoints}. ` +
|
||||
(leveledUp ? "✨ LEVELED UP!" : "")
|
||||
);
|
||||
}
|
||||
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an explorer's level directly.
|
||||
* @param {string} unitId - Unit ID (or "first" for first explorer)
|
||||
* @param {number} level - Target level (1-30)
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
setLevel(unitId = "first", level = 5) {
|
||||
const units = this._getExplorers(unitId);
|
||||
if (units.length === 0) {
|
||||
return `No explorers found with ID: ${unitId}`;
|
||||
}
|
||||
|
||||
level = Math.max(1, Math.min(30, level));
|
||||
|
||||
const results = [];
|
||||
for (const unit of units) {
|
||||
const mastery = unit.classMastery[unit.activeClassId];
|
||||
const oldLevel = mastery.level;
|
||||
mastery.level = level;
|
||||
|
||||
// Calculate XP for this level (simple curve: sum of 100 * level^2 for all previous levels)
|
||||
let totalXP = 0;
|
||||
for (let i = 1; i < level; i++) {
|
||||
totalXP += 100 * (i ** 2);
|
||||
}
|
||||
mastery.xp = 0; // Reset XP to 0 for current level
|
||||
|
||||
// Award skill points (1 per level, starting from level 2)
|
||||
mastery.skillPoints = Math.max(0, level - 1);
|
||||
|
||||
// Recalculate stats
|
||||
if (this.gameLoop?.classRegistry) {
|
||||
const classDef = this.gameLoop.classRegistry.get(unit.activeClassId);
|
||||
if (classDef) {
|
||||
unit.recalculateBaseStats(classDef);
|
||||
if (unit.recalculateStats) {
|
||||
unit.recalculateStats(
|
||||
this.gameLoop.inventoryManager?.itemRegistry || itemRegistry
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push(
|
||||
`${unit.name} (${unit.id}): Level set to ${level} (was ${oldLevel}). ` +
|
||||
`Skill Points: ${mastery.skillPoints}`
|
||||
);
|
||||
}
|
||||
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds skill points to an explorer.
|
||||
* @param {string} unitId - Unit ID (or "first" for first explorer)
|
||||
* @param {number} amount - Skill points to add
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
addSkillPoints(unitId = "first", amount = 5) {
|
||||
const units = this._getExplorers(unitId);
|
||||
if (units.length === 0) {
|
||||
return `No explorers found with ID: ${unitId}`;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const unit of units) {
|
||||
const mastery = unit.classMastery[unit.activeClassId];
|
||||
mastery.skillPoints = (mastery.skillPoints || 0) + amount;
|
||||
results.push(
|
||||
`${unit.name} (${unit.id}): +${amount} skill points. Total: ${mastery.skillPoints}`
|
||||
);
|
||||
}
|
||||
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SKILL TREE COMMANDS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Unlocks a skill node for an explorer.
|
||||
* @param {string} unitId - Unit ID (or "first" for first explorer)
|
||||
* @param {string} nodeId - Skill node ID to unlock
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
unlockSkill(unitId = "first", nodeId) {
|
||||
if (!nodeId) {
|
||||
return "Error: nodeId is required. Usage: unlockSkill(unitId, nodeId)";
|
||||
}
|
||||
|
||||
const units = this._getExplorers(unitId);
|
||||
if (units.length === 0) {
|
||||
return `No explorers found with ID: ${unitId}`;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const unit of units) {
|
||||
const mastery = unit.classMastery[unit.activeClassId];
|
||||
if (!mastery.unlockedNodes) {
|
||||
mastery.unlockedNodes = [];
|
||||
}
|
||||
|
||||
if (mastery.unlockedNodes.includes(nodeId)) {
|
||||
results.push(`${unit.name} (${unit.id}): Node ${nodeId} already unlocked`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get node cost (default to 1 if we can't determine)
|
||||
let cost = 1;
|
||||
if (this.gameLoop?.classRegistry) {
|
||||
const classDef = this.gameLoop.classRegistry.get(unit.activeClassId);
|
||||
// Skill trees are generated dynamically, so we can't get node cost from classDef
|
||||
// Default to 1 for now
|
||||
}
|
||||
|
||||
// Unlock the node (bypass skill point check for debug)
|
||||
mastery.unlockedNodes.push(nodeId);
|
||||
|
||||
// Recalculate stats if unit has the method
|
||||
if (unit.recalculateStats && this.gameLoop?.classRegistry) {
|
||||
const classDef = this.gameLoop.classRegistry.get(unit.activeClassId);
|
||||
if (classDef?.skillTreeData) {
|
||||
unit.recalculateStats(
|
||||
this.gameLoop.inventoryManager?.itemRegistry || itemRegistry,
|
||||
classDef.skillTreeData
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(
|
||||
`${unit.name} (${unit.id}): Unlocked skill node ${nodeId} (cost: ${cost})`
|
||||
);
|
||||
}
|
||||
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks all skill nodes for an explorer.
|
||||
* @param {string} unitId - Unit ID (or "first" for first explorer)
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
unlockAllSkills(unitId = "first") {
|
||||
const units = this._getExplorers(unitId);
|
||||
if (units.length === 0) {
|
||||
return `No explorers found with ID: ${unitId}`;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const unit of units) {
|
||||
if (!this.gameLoop?.classRegistry) {
|
||||
results.push(`${unit.name}: Cannot unlock all skills - class registry not available`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const classDef = this.gameLoop.classRegistry.get(unit.activeClassId);
|
||||
if (!classDef) {
|
||||
results.push(`${unit.name}: Class definition not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skill trees are generated dynamically, so we can't easily get all node IDs
|
||||
// For now, return a helpful message
|
||||
results.push(
|
||||
`${unit.name} (${unit.id}): Note - Skill trees are generated dynamically. ` +
|
||||
`Use unlockSkill(unitId, nodeId) with specific node IDs. ` +
|
||||
`To see available nodes, open the character sheet for this unit.`
|
||||
);
|
||||
}
|
||||
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INVENTORY COMMANDS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Adds an item to inventory (hub stash or run stash).
|
||||
* @param {string} itemDefId - Item definition ID
|
||||
* @param {number} quantity - Quantity to add (default: 1)
|
||||
* @param {string} target - "hub" or "run" (default: "hub")
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
addItem(itemDefId, quantity = 1, target = "hub") {
|
||||
if (!itemDefId) {
|
||||
return "Error: itemDefId is required. Usage: addItem(itemDefId, quantity, target)";
|
||||
}
|
||||
|
||||
const itemDef = itemRegistry.get(itemDefId);
|
||||
if (!itemDef) {
|
||||
return `Error: Item definition not found: ${itemDefId}`;
|
||||
}
|
||||
|
||||
const itemInstance = {
|
||||
uid: `ITEM_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
defId: itemDefId,
|
||||
isNew: true,
|
||||
quantity: quantity,
|
||||
};
|
||||
|
||||
if (target === "run" && this.gameLoop?.inventoryManager?.runStash) {
|
||||
this.gameLoop.inventoryManager.runStash.addItem(itemInstance);
|
||||
return `Added ${quantity}x ${itemDefId} to run stash`;
|
||||
} else if (this.gameStateManager?.hubStash) {
|
||||
this.gameStateManager.hubStash.addItem(itemInstance);
|
||||
// Save hub stash
|
||||
if (this.gameStateManager._saveHubStash) {
|
||||
this.gameStateManager._saveHubStash();
|
||||
}
|
||||
return `Added ${quantity}x ${itemDefId} to hub stash`;
|
||||
} else {
|
||||
return "Error: No stash available";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds currency to hub stash.
|
||||
* @param {number} shards - Aether shards to add
|
||||
* @param {number} cores - Ancient cores to add (optional)
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
addCurrency(shards = 1000, cores = 0) {
|
||||
if (!this.gameStateManager?.hubStash) {
|
||||
return "Error: Hub stash not available";
|
||||
}
|
||||
|
||||
this.gameStateManager.hubStash.currency.aetherShards =
|
||||
(this.gameStateManager.hubStash.currency.aetherShards || 0) + shards;
|
||||
if (cores > 0) {
|
||||
this.gameStateManager.hubStash.currency.ancientCores =
|
||||
(this.gameStateManager.hubStash.currency.ancientCores || 0) + cores;
|
||||
}
|
||||
|
||||
// Save hub stash
|
||||
if (this.gameStateManager._saveHubStash) {
|
||||
this.gameStateManager._saveHubStash();
|
||||
}
|
||||
|
||||
return `Added ${shards} Aether Shards${cores > 0 ? ` and ${cores} Ancient Cores` : ""} to hub stash`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMBAT COMMANDS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Kills an enemy (or all enemies).
|
||||
* @param {string} enemyId - Enemy unit ID (or "all" for all enemies)
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
killEnemy(enemyId = "all") {
|
||||
if (!this.gameLoop?.unitManager) {
|
||||
return "Error: Game loop or unit manager not available";
|
||||
}
|
||||
|
||||
const enemies = this._getEnemies(enemyId);
|
||||
if (enemies.length === 0) {
|
||||
return `No enemies found${enemyId !== "all" ? ` with ID: ${enemyId}` : ""}`;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const enemy of enemies) {
|
||||
if (this.gameLoop.handleUnitDeath) {
|
||||
this.gameLoop.handleUnitDeath(enemy);
|
||||
} else {
|
||||
// Fallback: set health to 0 and remove from unit manager
|
||||
enemy.currentHealth = 0;
|
||||
if (this.gameLoop.unitManager.removeUnit) {
|
||||
this.gameLoop.unitManager.removeUnit(enemy.id);
|
||||
}
|
||||
}
|
||||
results.push(`Killed ${enemy.name} (${enemy.id})`);
|
||||
}
|
||||
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Heals a unit to full health.
|
||||
* @param {string} unitId - Unit ID (or "all" for all player units)
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
healUnit(unitId = "all") {
|
||||
if (!this.gameLoop?.unitManager) {
|
||||
return "Error: Game loop or unit manager not available";
|
||||
}
|
||||
|
||||
const units = unitId === "all"
|
||||
? this.gameLoop.unitManager.getUnitsByTeam("PLAYER")
|
||||
: [this.gameLoop.unitManager.getUnitById(unitId)].filter(Boolean);
|
||||
|
||||
if (units.length === 0) {
|
||||
return `No units found${unitId !== "all" ? ` with ID: ${unitId}` : ""}`;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const unit of units) {
|
||||
const oldHP = unit.currentHealth;
|
||||
unit.currentHealth = unit.maxHealth;
|
||||
results.push(`${unit.name} (${unit.id}): Healed ${unit.maxHealth - oldHP} HP (${oldHP} → ${unit.maxHealth})`);
|
||||
}
|
||||
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MISSION & NARRATIVE COMMANDS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Triggers mission victory.
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
triggerVictory() {
|
||||
if (!this.gameStateManager?.missionManager) {
|
||||
return "Error: Mission manager not available";
|
||||
}
|
||||
|
||||
// Complete all primary objectives
|
||||
if (this.gameStateManager.missionManager.currentObjectives) {
|
||||
this.gameStateManager.missionManager.currentObjectives.forEach((obj) => {
|
||||
obj.complete = true;
|
||||
obj.current = obj.target_count || 1;
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger victory check
|
||||
this.gameStateManager.missionManager.checkVictory();
|
||||
|
||||
return "Mission victory triggered!";
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a specific objective.
|
||||
* @param {string} objectiveId - Objective ID (or index as number)
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
completeObjective(objectiveId) {
|
||||
if (!this.gameStateManager?.missionManager) {
|
||||
return "Error: Mission manager not available";
|
||||
}
|
||||
|
||||
const objectives = this.gameStateManager.missionManager.currentObjectives || [];
|
||||
let objective = null;
|
||||
|
||||
if (typeof objectiveId === "number") {
|
||||
objective = objectives[objectiveId];
|
||||
} else {
|
||||
objective = objectives.find((obj) => obj.id === objectiveId);
|
||||
}
|
||||
|
||||
if (!objective) {
|
||||
return `Error: Objective not found: ${objectiveId}`;
|
||||
}
|
||||
|
||||
objective.complete = true;
|
||||
objective.current = objective.target_count || 1;
|
||||
|
||||
// Check victory
|
||||
this.gameStateManager.missionManager.checkVictory();
|
||||
|
||||
return `Objective completed: ${objective.type} (${objective.id || "unnamed"})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the next narrative sequence.
|
||||
* @param {string} narrativeId - Narrative ID (optional, uses current mission's narrative if not provided)
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
triggerNarrative(narrativeId = null) {
|
||||
if (!this.gameStateManager?.narrativeManager) {
|
||||
return "Error: Narrative manager not available";
|
||||
}
|
||||
|
||||
if (narrativeId) {
|
||||
// Load and start specific narrative
|
||||
this.gameStateManager.narrativeManager
|
||||
.loadSequence(narrativeId)
|
||||
.then(() => {
|
||||
this.gameStateManager.narrativeManager.startSequence(narrativeId);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading narrative:", error);
|
||||
});
|
||||
return `Triggered narrative: ${narrativeId}`;
|
||||
} else {
|
||||
// Try to trigger current mission's intro/outro
|
||||
const missionDef = this.gameStateManager.missionManager?.currentMissionDef;
|
||||
if (missionDef?.narrative) {
|
||||
const narrativeToUse =
|
||||
missionDef.narrative.intro_success ||
|
||||
missionDef.narrative.intro ||
|
||||
missionDef.narrative.outro_success;
|
||||
if (narrativeToUse) {
|
||||
this.gameStateManager.narrativeManager
|
||||
.loadSequence(narrativeToUse)
|
||||
.then(() => {
|
||||
this.gameStateManager.narrativeManager.startSequence(narrativeToUse);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading narrative:", error);
|
||||
});
|
||||
return `Triggered narrative: ${narrativeToUse}`;
|
||||
}
|
||||
}
|
||||
return "Error: No narrative specified and no mission narrative found";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITY COMMANDS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Lists all available commands in a readable format.
|
||||
* Uses console formatting for better readability.
|
||||
*/
|
||||
help() {
|
||||
console.log(
|
||||
"%c=== DEBUG COMMANDS HELP ===\n",
|
||||
"font-weight: bold; font-size: 16px; color: #4CAF50;"
|
||||
);
|
||||
|
||||
console.log(
|
||||
"%cEXPLORER & LEVELING:",
|
||||
"font-weight: bold; color: #2196F3;"
|
||||
);
|
||||
console.log(" %caddXP(unitId, amount)%c - Add XP to explorer(s)", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" unitId: \"first\", \"all\", or specific ID");
|
||||
console.log(" %csetLevel(unitId, level)%c - Set explorer level directly (1-30)", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %caddSkillPoints(unitId, amount)%c - Add skill points to explorer", "color: #FF9800;", "color: inherit;");
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
"%cSKILL TREE:",
|
||||
"font-weight: bold; color: #2196F3;"
|
||||
);
|
||||
console.log(" %cunlockSkill(unitId, nodeId)%c - Unlock a specific skill node", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %cunlockAllSkills(unitId)%c - Unlock all skill nodes for explorer", "color: #FF9800;", "color: inherit;");
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
"%cINVENTORY:",
|
||||
"font-weight: bold; color: #2196F3;"
|
||||
);
|
||||
console.log(" %caddItem(itemDefId, quantity, target)%c - Add item", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" target: \"hub\" or \"run\"");
|
||||
console.log(" %caddCurrency(shards, cores)%c - Add currency to hub stash", "color: #FF9800;", "color: inherit;");
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
"%cCOMBAT:",
|
||||
"font-weight: bold; color: #2196F3;"
|
||||
);
|
||||
console.log(" %ckillEnemy(enemyId)%c - Kill enemy", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" enemyId: \"all\" or specific ID");
|
||||
console.log(" %chealUnit(unitId)%c - Heal unit to full", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" unitId: \"all\" or specific ID");
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
"%cMISSION & NARRATIVE:",
|
||||
"font-weight: bold; color: #2196F3;"
|
||||
);
|
||||
console.log(" %ctriggerVictory()%c - Trigger mission victory", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %ccompleteObjective(objectiveId)%c - Complete a specific objective", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %ctriggerNarrative(narrativeId)%c - Trigger narrative sequence", "color: #FF9800;", "color: inherit;");
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
"%cUTILITY:",
|
||||
"font-weight: bold; color: #2196F3;"
|
||||
);
|
||||
console.log(" %chelp()%c - Show this help", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %clistIds()%c - List all available IDs", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %clistPlayerIds()%c - List player unit IDs only", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %clistEnemyIds()%c - List enemy unit IDs only", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %clistItemIds(limit)%c - List item IDs only (default limit: 100)", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %clistUnits()%c - List all units with details", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %clistItems(limit)%c - List available items with details (default limit: 50)", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %cgetState()%c - Get current game state info", "color: #FF9800;", "color: inherit;");
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
"%cQUICK EXAMPLES:",
|
||||
"font-weight: bold; color: #9C27B0;"
|
||||
);
|
||||
console.log(" %cdebugCommands.addXP(\"first\", 500)", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.setLevel(\"all\", 10)", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.addItem(\"ITEM_SWORD_T1\", 1, \"hub\")", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.killEnemy(\"all\")", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.triggerVictory()", "color: #4CAF50; font-family: monospace;");
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
"%cTIP: Use listIds() to see all available IDs for commands!",
|
||||
"font-style: italic; color: #757575;"
|
||||
);
|
||||
|
||||
return "Help displayed above. Check the console for formatted output.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all units in the game.
|
||||
* @returns {string} Unit list
|
||||
*/
|
||||
listUnits() {
|
||||
if (!this.gameLoop?.unitManager) {
|
||||
return "Error: Game loop or unit manager not available";
|
||||
}
|
||||
|
||||
const allUnits = this.gameLoop.unitManager.getAllUnits();
|
||||
if (allUnits.length === 0) {
|
||||
return "No units found";
|
||||
}
|
||||
|
||||
const playerUnits = allUnits.filter((u) => u.team === "PLAYER");
|
||||
const enemyUnits = allUnits.filter((u) => u.team === "ENEMY");
|
||||
|
||||
let result = `Total Units: ${allUnits.length}\n\n`;
|
||||
|
||||
if (playerUnits.length > 0) {
|
||||
result += "PLAYER UNITS:\n";
|
||||
playerUnits.forEach((unit) => {
|
||||
const level = unit.getLevel ? unit.getLevel() : "?";
|
||||
result += ` - ${unit.name} (${unit.id}) - Level ${level} - HP: ${unit.currentHealth}/${unit.maxHealth}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (enemyUnits.length > 0) {
|
||||
result += "\nENEMY UNITS:\n";
|
||||
enemyUnits.forEach((unit) => {
|
||||
result += ` - ${unit.name} (${unit.id}) - HP: ${unit.currentHealth}/${unit.maxHealth}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists player unit IDs only (for use with commands).
|
||||
* @returns {string} Player IDs list
|
||||
*/
|
||||
listPlayerIds() {
|
||||
if (!this.gameLoop?.unitManager) {
|
||||
return "Error: Game loop or unit manager not available";
|
||||
}
|
||||
|
||||
const playerUnits = this.gameLoop.unitManager.getUnitsByTeam("PLAYER");
|
||||
if (playerUnits.length === 0) {
|
||||
return "No player units found";
|
||||
}
|
||||
|
||||
let result = "PLAYER UNIT IDs:\n";
|
||||
playerUnits.forEach((unit) => {
|
||||
const level = unit.getLevel ? unit.getLevel() : "?";
|
||||
result += ` "${unit.id}" - ${unit.name} (Level ${level})\n`;
|
||||
});
|
||||
result += `\nUse with: addXP("${playerUnits[0]?.id}", 100) or addXP("first", 100) or addXP("all", 100)`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists enemy unit IDs only (for use with commands).
|
||||
* @returns {string} Enemy IDs list
|
||||
*/
|
||||
listEnemyIds() {
|
||||
if (!this.gameLoop?.unitManager) {
|
||||
return "Error: Game loop or unit manager not available";
|
||||
}
|
||||
|
||||
const enemyUnits = this.gameLoop.unitManager.getUnitsByTeam("ENEMY");
|
||||
if (enemyUnits.length === 0) {
|
||||
return "No enemy units found";
|
||||
}
|
||||
|
||||
let result = "ENEMY UNIT IDs:\n";
|
||||
enemyUnits.forEach((unit) => {
|
||||
result += ` "${unit.id}" - ${unit.name} (HP: ${unit.currentHealth}/${unit.maxHealth})\n`;
|
||||
});
|
||||
result += `\nUse with: killEnemy("${enemyUnits[0]?.id}") or killEnemy("all")`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists available items in the registry.
|
||||
* @param {number} limit - Maximum number of items to show (default: 50)
|
||||
* @returns {string} Item list
|
||||
*/
|
||||
listItems(limit = 50) {
|
||||
const items = Array.from(itemRegistry.getAll ? itemRegistry.getAll() : []);
|
||||
if (items.length === 0) {
|
||||
return "No items found in registry";
|
||||
}
|
||||
|
||||
let result = `Available Items (showing ${Math.min(limit, items.length)} of ${items.length}):\n\n`;
|
||||
items.slice(0, limit).forEach((item) => {
|
||||
result += ` - ${item.id} (${item.type || "UNKNOWN"})`;
|
||||
if (item.name) {
|
||||
result += `: ${item.name}`;
|
||||
}
|
||||
result += "\n";
|
||||
});
|
||||
|
||||
if (items.length > limit) {
|
||||
result += `\n... and ${items.length - limit} more items`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists item IDs only (for use with addItem command).
|
||||
* @param {number} limit - Maximum number of items to show (default: 100)
|
||||
* @returns {string} Item IDs list
|
||||
*/
|
||||
listItemIds(limit = 100) {
|
||||
const items = Array.from(itemRegistry.getAll ? itemRegistry.getAll() : []);
|
||||
if (items.length === 0) {
|
||||
return "No items found in registry";
|
||||
}
|
||||
|
||||
let result = "ITEM IDs:\n";
|
||||
items.slice(0, limit).forEach((item) => {
|
||||
result += ` "${item.id}"`;
|
||||
if (item.name) {
|
||||
result += ` - ${item.name}`;
|
||||
}
|
||||
if (item.type) {
|
||||
result += ` (${item.type})`;
|
||||
}
|
||||
result += "\n";
|
||||
});
|
||||
|
||||
if (items.length > limit) {
|
||||
result += `\n... and ${items.length - limit} more items (use listItems() to see all)`;
|
||||
}
|
||||
|
||||
result += `\nUse with: addItem("${items[0]?.id}", 1, "hub")`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available IDs (players, enemies, items, objectives, narratives).
|
||||
* @returns {string} Comprehensive ID list
|
||||
*/
|
||||
listIds() {
|
||||
let result = "=== AVAILABLE IDs ===\n\n";
|
||||
|
||||
// Player IDs
|
||||
if (this.gameLoop?.unitManager) {
|
||||
const playerUnits = this.gameLoop.unitManager.getUnitsByTeam("PLAYER");
|
||||
if (playerUnits.length > 0) {
|
||||
result += "PLAYER UNIT IDs:\n";
|
||||
playerUnits.forEach((unit) => {
|
||||
result += ` "${unit.id}" - ${unit.name}\n`;
|
||||
});
|
||||
result += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Enemy IDs
|
||||
if (this.gameLoop?.unitManager) {
|
||||
const enemyUnits = this.gameLoop.unitManager.getUnitsByTeam("ENEMY");
|
||||
if (enemyUnits.length > 0) {
|
||||
result += "ENEMY UNIT IDs:\n";
|
||||
enemyUnits.forEach((unit) => {
|
||||
result += ` "${unit.id}" - ${unit.name}\n`;
|
||||
});
|
||||
result += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Item IDs
|
||||
const items = Array.from(itemRegistry.getAll ? itemRegistry.getAll() : []);
|
||||
if (items.length > 0) {
|
||||
result += `ITEM IDs (showing first 20 of ${items.length}):\n`;
|
||||
items.slice(0, 20).forEach((item) => {
|
||||
result += ` "${item.id}"`;
|
||||
if (item.name) {
|
||||
result += ` - ${item.name}`;
|
||||
}
|
||||
result += "\n";
|
||||
});
|
||||
if (items.length > 20) {
|
||||
result += ` ... and ${items.length - 20} more (use listItemIds() to see all)\n`;
|
||||
}
|
||||
result += "\n";
|
||||
}
|
||||
|
||||
// Objective IDs
|
||||
if (this.gameStateManager?.missionManager?.currentObjectives) {
|
||||
const objectives = this.gameStateManager.missionManager.currentObjectives;
|
||||
if (objectives.length > 0) {
|
||||
result += "OBJECTIVE IDs:\n";
|
||||
objectives.forEach((obj, idx) => {
|
||||
result += ` ${idx} or "${obj.id || `objective_${idx}`}" - ${obj.type}\n`;
|
||||
});
|
||||
result += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Mission ID
|
||||
if (this.gameStateManager?.missionManager?.activeMissionId) {
|
||||
result += `ACTIVE MISSION ID:\n "${this.gameStateManager.missionManager.activeMissionId}"\n\n`;
|
||||
}
|
||||
|
||||
// Narrative IDs (from current mission)
|
||||
if (this.gameStateManager?.missionManager?.currentMissionDef?.narrative) {
|
||||
const narrative = this.gameStateManager.missionManager.currentMissionDef.narrative;
|
||||
result += "NARRATIVE IDs (from current mission):\n";
|
||||
if (narrative.intro) result += ` "${narrative.intro}" - Intro\n`;
|
||||
if (narrative.intro_success) result += ` "${narrative.intro_success}" - Intro Success\n`;
|
||||
if (narrative.outro_success) result += ` "${narrative.outro_success}" - Outro Success\n`;
|
||||
if (narrative.outro_failure) result += ` "${narrative.outro_failure}" - Outro Failure\n`;
|
||||
result += "\n";
|
||||
}
|
||||
|
||||
result += "Use listPlayerIds(), listEnemyIds(), or listItemIds() for detailed lists.";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current game state information.
|
||||
* @returns {string} State info
|
||||
*/
|
||||
getState() {
|
||||
let result = "Game State:\n\n";
|
||||
|
||||
result += `Current State: ${this.gameStateManager?.currentState || "UNKNOWN"}\n`;
|
||||
result += `Game Running: ${this.gameLoop?.isRunning || false}\n`;
|
||||
result += `Game Paused: ${this.gameLoop?.isPaused || false}\n`;
|
||||
|
||||
if (this.gameStateManager?.missionManager) {
|
||||
const mm = this.gameStateManager.missionManager;
|
||||
result += `\nMission: ${mm.activeMissionId || "None"}\n`;
|
||||
if (mm.currentObjectives) {
|
||||
result += `Objectives: ${mm.currentObjectives.length}\n`;
|
||||
mm.currentObjectives.forEach((obj, idx) => {
|
||||
result += ` ${idx + 1}. ${obj.type} - ${obj.complete ? "✓" : "✗"} (${obj.current || 0}/${obj.target_count || 0})\n`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.gameLoop?.turnSystem) {
|
||||
const activeUnit = this.gameLoop.turnSystem.getActiveUnit();
|
||||
if (activeUnit) {
|
||||
result += `\nActive Unit: ${activeUnit.name} (${activeUnit.id})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER METHODS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Gets explorer units by ID or special keywords.
|
||||
* @param {string} unitId - Unit ID, "first", or "all"
|
||||
* @returns {Array} Array of explorer units
|
||||
* @private
|
||||
*/
|
||||
_getExplorers(unitId) {
|
||||
if (!this.gameLoop?.unitManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (unitId === "all") {
|
||||
return this.gameLoop.unitManager.getUnitsByTeam("PLAYER");
|
||||
} else if (unitId === "first") {
|
||||
const units = this.gameLoop.unitManager.getUnitsByTeam("PLAYER");
|
||||
return units.length > 0 ? [units[0]] : [];
|
||||
} else {
|
||||
const unit = this.gameLoop.unitManager.getUnitById(unitId);
|
||||
return unit && unit.team === "PLAYER" ? [unit] : [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets enemy units by ID or special keywords.
|
||||
* @param {string} enemyId - Enemy ID or "all"
|
||||
* @returns {Array} Array of enemy units
|
||||
* @private
|
||||
*/
|
||||
_getEnemies(enemyId) {
|
||||
if (!this.gameLoop?.unitManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (enemyId === "all") {
|
||||
return this.gameLoop.unitManager.getUnitsByTeam("ENEMY");
|
||||
} else {
|
||||
const unit = this.gameLoop.unitManager.getUnitById(enemyId);
|
||||
return unit && unit.team === "ENEMY" ? [unit] : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const debugCommands = new DebugCommands();
|
||||
|
||||
// Make it globally available
|
||||
if (typeof window !== "undefined") {
|
||||
window.debugCommands = debugCommands;
|
||||
}
|
||||
|
||||
|
|
@ -27,12 +27,7 @@ import { InventoryContainer } from "../models/InventoryContainer.js";
|
|||
import { itemRegistry } from "../managers/ItemRegistry.js";
|
||||
import { narrativeManager } from "../managers/NarrativeManager.js";
|
||||
|
||||
// Import class definitions
|
||||
import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" };
|
||||
import weaverDef from "../assets/data/classes/aether_weaver.json" with { type: "json" };
|
||||
import scavengerDef from "../assets/data/classes/scavenger.json" with { type: "json" };
|
||||
import tinkerDef from "../assets/data/classes/tinker.json" with { type: "json" };
|
||||
import custodianDef from "../assets/data/classes/custodian.json" with { type: "json" };
|
||||
// Class definitions will be lazy-loaded when startLevel is called
|
||||
|
||||
/**
|
||||
* Main game loop managing rendering, input, and game state.
|
||||
|
|
@ -165,7 +160,11 @@ export class GameLoop {
|
|||
const runStash = new InventoryContainer("RUN_LOOT");
|
||||
const hubStash = new InventoryContainer("HUB_VAULT");
|
||||
// Initialize InventoryManager with itemRegistry (will load items in startLevel)
|
||||
this.inventoryManager = new InventoryManager(itemRegistry, runStash, hubStash);
|
||||
this.inventoryManager = new InventoryManager(
|
||||
itemRegistry,
|
||||
runStash,
|
||||
hubStash
|
||||
);
|
||||
|
||||
// --- SETUP INPUT MANAGER ---
|
||||
this.inputManager = new InputManager(
|
||||
|
|
@ -366,7 +365,9 @@ export class GameLoop {
|
|||
if (!activeUnit || activeUnit.team !== "PLAYER") {
|
||||
// If no active unit or not player unit, try to get first player unit
|
||||
if (this.unitManager) {
|
||||
const playerUnits = this.unitManager.getAllUnits().filter((u) => u.team === "PLAYER");
|
||||
const playerUnits = this.unitManager
|
||||
.getAllUnits()
|
||||
.filter((u) => u.team === "PLAYER");
|
||||
if (playerUnits.length > 0) {
|
||||
this._dispatchOpenCharacterSheet(playerUnits[0]);
|
||||
}
|
||||
|
|
@ -399,8 +400,9 @@ export class GameLoop {
|
|||
|
||||
// Determine if read-only (enemy turn or restricted)
|
||||
const activeUnit = this.turnSystem?.getActiveUnit();
|
||||
const isReadOnly = this.combatState === "TARGETING_SKILL" ||
|
||||
(activeUnit && activeUnit.team !== "PLAYER");
|
||||
const isReadOnly =
|
||||
this.combatState === "TARGETING_SKILL" ||
|
||||
(activeUnit && activeUnit.team !== "PLAYER");
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-character-sheet", {
|
||||
|
|
@ -531,7 +533,10 @@ export class GameLoop {
|
|||
if (!activeUnit || activeUnit.team !== "PLAYER") return;
|
||||
|
||||
// If clicking the same skill that's already active, cancel targeting
|
||||
if (this.combatState === "TARGETING_SKILL" && this.activeSkillId === skillId) {
|
||||
if (
|
||||
this.combatState === "TARGETING_SKILL" &&
|
||||
this.activeSkillId === skillId
|
||||
) {
|
||||
this.cancelSkillTargeting();
|
||||
return;
|
||||
}
|
||||
|
|
@ -558,8 +563,10 @@ export class GameLoop {
|
|||
const skillDef = this.skillTargetingSystem.getSkillDef(skillId);
|
||||
if (skillDef && this.voxelManager && this.skillTargetingSystem) {
|
||||
// Check if this is a teleport skill with unlimited range (range = -1)
|
||||
const isTeleportSkill = skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT");
|
||||
const hasUnlimitedRange = skillDef.range === -1 || skillDef.range === Infinity;
|
||||
const isTeleportSkill =
|
||||
skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT");
|
||||
const hasUnlimitedRange =
|
||||
skillDef.range === -1 || skillDef.range === Infinity;
|
||||
|
||||
let allTilesInRange = [];
|
||||
|
||||
|
|
@ -579,9 +586,21 @@ export class GameLoop {
|
|||
}
|
||||
} else {
|
||||
// For normal skills, get tiles within range
|
||||
for (let x = activeUnit.position.x - skillDef.range; x <= activeUnit.position.x + skillDef.range; x++) {
|
||||
for (let y = activeUnit.position.y - skillDef.range; y <= activeUnit.position.y + skillDef.range; y++) {
|
||||
for (let z = activeUnit.position.z - skillDef.range; z <= activeUnit.position.z + skillDef.range; z++) {
|
||||
for (
|
||||
let x = activeUnit.position.x - skillDef.range;
|
||||
x <= activeUnit.position.x + skillDef.range;
|
||||
x++
|
||||
) {
|
||||
for (
|
||||
let y = activeUnit.position.y - skillDef.range;
|
||||
y <= activeUnit.position.y + skillDef.range;
|
||||
y++
|
||||
) {
|
||||
for (
|
||||
let z = activeUnit.position.z - skillDef.range;
|
||||
z <= activeUnit.position.z + skillDef.range;
|
||||
z++
|
||||
) {
|
||||
const dist =
|
||||
Math.abs(x - activeUnit.position.x) +
|
||||
Math.abs(y - activeUnit.position.y) +
|
||||
|
|
@ -608,13 +627,16 @@ export class GameLoop {
|
|||
if (validation.valid) {
|
||||
validTilesWithObstruction.push({
|
||||
pos: tilePos,
|
||||
obstruction: validation.obstruction || 0
|
||||
obstruction: validation.obstruction || 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Highlight only valid targets with obstruction-based dimming
|
||||
this.voxelManager.highlightTilesWithObstruction(validTilesWithObstruction, "RED_OUTLINE");
|
||||
this.voxelManager.highlightTilesWithObstruction(
|
||||
validTilesWithObstruction,
|
||||
"RED_OUTLINE"
|
||||
);
|
||||
}
|
||||
|
||||
// Update combat state to refresh UI (show cancel button)
|
||||
|
|
@ -683,10 +705,14 @@ export class GameLoop {
|
|||
skillId
|
||||
);
|
||||
|
||||
console.log(`AoE found ${targets.length} targets at ${targetPos.x},${targetPos.y},${targetPos.z}`);
|
||||
console.log(
|
||||
`AoE found ${targets.length} targets at ${targetPos.x},${targetPos.y},${targetPos.z}`
|
||||
);
|
||||
if (targets.length > 0) {
|
||||
targets.forEach((t) => {
|
||||
console.log(` - Target: ${t.name} at ${t.position.x},${t.position.y},${t.position.z}`);
|
||||
console.log(
|
||||
` - Target: ${t.name} at ${t.position.x},${t.position.y},${t.position.z}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -696,7 +722,9 @@ export class GameLoop {
|
|||
const unitAtTarget = this.grid.getUnitAt(targetPos);
|
||||
if (unitAtTarget) {
|
||||
targets = [unitAtTarget];
|
||||
console.log(`Fallback: Added unit at target position: ${unitAtTarget.name}`);
|
||||
console.log(
|
||||
`Fallback: Added unit at target position: ${unitAtTarget.name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -727,7 +755,11 @@ export class GameLoop {
|
|||
const failureChance = losResult.obstruction || 0;
|
||||
if (Math.random() < failureChance) {
|
||||
console.warn(
|
||||
`${activeUnit.name}'s teleport failed due to obstructed line of sight! (${Math.round(failureChance * 100)}% obstruction)`
|
||||
`${
|
||||
activeUnit.name
|
||||
}'s teleport failed due to obstructed line of sight! (${Math.round(
|
||||
failureChance * 100
|
||||
)}% obstruction)`
|
||||
);
|
||||
// Teleport failed - unit stays in place, but AP was already deducted
|
||||
// Could optionally refund AP here, but for now we'll just log the failure
|
||||
|
|
@ -752,10 +784,18 @@ export class GameLoop {
|
|||
continue; // Skip teleport execution
|
||||
}
|
||||
}
|
||||
const teleportDestination = { x: targetPos.x, y: walkableY, z: targetPos.z };
|
||||
const teleportDestination = {
|
||||
x: targetPos.x,
|
||||
y: walkableY,
|
||||
z: targetPos.z,
|
||||
};
|
||||
|
||||
// Process teleport effect - source unit is teleported to destination
|
||||
const result = this.effectProcessor.process(effect, activeUnit, teleportDestination);
|
||||
const result = this.effectProcessor.process(
|
||||
effect,
|
||||
activeUnit,
|
||||
teleportDestination
|
||||
);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Update unit mesh position after teleport
|
||||
|
|
@ -781,7 +821,8 @@ export class GameLoop {
|
|||
for (const target of targets) {
|
||||
if (!target) continue;
|
||||
// Check if unit is alive
|
||||
if (typeof target.isAlive === "function" && !target.isAlive()) continue;
|
||||
if (typeof target.isAlive === "function" && !target.isAlive())
|
||||
continue;
|
||||
if (target.currentHealth <= 0) continue;
|
||||
|
||||
// Process ON_SKILL_HIT passive effects (before processing effect)
|
||||
|
|
@ -793,7 +834,11 @@ export class GameLoop {
|
|||
});
|
||||
|
||||
// Process effect through EffectProcessor
|
||||
const result = this.effectProcessor.process(effect, activeUnit, target);
|
||||
const result = this.effectProcessor.process(
|
||||
effect,
|
||||
activeUnit,
|
||||
target
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Log success messages based on effect type
|
||||
|
|
@ -844,7 +889,10 @@ export class GameLoop {
|
|||
console.log(
|
||||
`${activeUnit.name} dealt ${primaryResult.amount} damage to ${target.name} (chain lightning)`
|
||||
);
|
||||
if (result.data.chainTargets && result.data.chainTargets.length > 0) {
|
||||
if (
|
||||
result.data.chainTargets &&
|
||||
result.data.chainTargets.length > 0
|
||||
) {
|
||||
console.log(
|
||||
`Chain lightning bounced to ${result.data.chainTargets.length} additional targets`
|
||||
);
|
||||
|
|
@ -1014,6 +1062,26 @@ export class GameLoop {
|
|||
// Create a proper registry with actual class definitions
|
||||
const classRegistry = new Map();
|
||||
|
||||
// Lazy-load class definitions
|
||||
const [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef] =
|
||||
await Promise.all([
|
||||
import("../assets/data/classes/vanguard.json", {
|
||||
with: { type: "json" },
|
||||
}).then((m) => m.default),
|
||||
import("../assets/data/classes/aether_weaver.json", {
|
||||
with: { type: "json" },
|
||||
}).then((m) => m.default),
|
||||
import("../assets/data/classes/scavenger.json", {
|
||||
with: { type: "json" },
|
||||
}).then((m) => m.default),
|
||||
import("../assets/data/classes/tinker.json", {
|
||||
with: { type: "json" },
|
||||
}).then((m) => m.default),
|
||||
import("../assets/data/classes/custodian.json", {
|
||||
with: { type: "json" },
|
||||
}).then((m) => m.default),
|
||||
]);
|
||||
|
||||
// Register all class definitions
|
||||
const classDefs = [
|
||||
vanguardDef,
|
||||
|
|
@ -1093,10 +1161,24 @@ export class GameLoop {
|
|||
this.turnSystemAbortController = new AbortController();
|
||||
const signal = this.turnSystemAbortController.signal;
|
||||
|
||||
this.turnSystem.addEventListener("turn-start", (e) => this._onTurnStart(e.detail), { signal });
|
||||
this.turnSystem.addEventListener("turn-end", (e) => this._onTurnEnd(e.detail), { signal });
|
||||
this.turnSystem.addEventListener("combat-start", () => this._onCombatStart(), { signal });
|
||||
this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), { signal });
|
||||
this.turnSystem.addEventListener(
|
||||
"turn-start",
|
||||
(e) => this._onTurnStart(e.detail),
|
||||
{ signal }
|
||||
);
|
||||
this.turnSystem.addEventListener(
|
||||
"turn-end",
|
||||
(e) => this._onTurnEnd(e.detail),
|
||||
{ signal }
|
||||
);
|
||||
this.turnSystem.addEventListener(
|
||||
"combat-start",
|
||||
() => this._onCombatStart(),
|
||||
{ signal }
|
||||
);
|
||||
this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), {
|
||||
signal,
|
||||
});
|
||||
|
||||
this.highlightZones();
|
||||
|
||||
|
|
@ -1213,12 +1295,15 @@ export class GameLoop {
|
|||
|
||||
// Restore classMastery progression
|
||||
if (rosterUnit.classMastery) {
|
||||
unit.classMastery = JSON.parse(JSON.stringify(rosterUnit.classMastery));
|
||||
unit.classMastery = JSON.parse(
|
||||
JSON.stringify(rosterUnit.classMastery)
|
||||
);
|
||||
// Recalculate stats based on restored mastery and activeClassId
|
||||
if (unit.recalculateBaseStats && unit.activeClassId) {
|
||||
const classDef = typeof this.unitManager.registry.get === "function"
|
||||
? this.unitManager.registry.get(unit.activeClassId)
|
||||
: this.unitManager.registry[unit.activeClassId];
|
||||
const classDef =
|
||||
typeof this.unitManager.registry.get === "function"
|
||||
? this.unitManager.registry.get(unit.activeClassId)
|
||||
: this.unitManager.registry[unit.activeClassId];
|
||||
if (classDef) {
|
||||
unit.recalculateBaseStats(classDef);
|
||||
}
|
||||
|
|
@ -1226,10 +1311,18 @@ export class GameLoop {
|
|||
}
|
||||
|
||||
// Restore currentHealth from roster (preserve HP that was paid for)
|
||||
if (rosterUnit.currentHealth !== undefined && rosterUnit.currentHealth !== null) {
|
||||
if (
|
||||
rosterUnit.currentHealth !== undefined &&
|
||||
rosterUnit.currentHealth !== null
|
||||
) {
|
||||
// Ensure currentHealth doesn't exceed maxHealth (in case maxHealth increased)
|
||||
unit.currentHealth = Math.min(rosterUnit.currentHealth, unit.maxHealth || 100);
|
||||
console.log(`Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)`);
|
||||
unit.currentHealth = Math.min(
|
||||
rosterUnit.currentHealth,
|
||||
unit.maxHealth || 100
|
||||
);
|
||||
console.log(
|
||||
`Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1251,12 +1344,16 @@ export class GameLoop {
|
|||
// Get class definition from the registry
|
||||
let classDef = null;
|
||||
if (this.unitManager.registry) {
|
||||
classDef = typeof this.unitManager.registry.get === "function"
|
||||
? this.unitManager.registry.get(classId)
|
||||
: this.unitManager.registry[classId];
|
||||
classDef =
|
||||
typeof this.unitManager.registry.get === "function"
|
||||
? this.unitManager.registry.get(classId)
|
||||
: this.unitManager.registry[classId];
|
||||
}
|
||||
|
||||
if (classDef && typeof unit.initializeStartingEquipment === "function") {
|
||||
if (
|
||||
classDef &&
|
||||
typeof unit.initializeStartingEquipment === "function"
|
||||
) {
|
||||
unit.initializeStartingEquipment(
|
||||
this.inventoryManager.itemRegistry,
|
||||
classDef
|
||||
|
|
@ -1267,7 +1364,13 @@ export class GameLoop {
|
|||
// Ensure unit has valid health values
|
||||
// Only set to full health if currentHealth is invalid (0 or negative) and wasn't restored from roster
|
||||
// This preserves HP that was paid for in the barracks
|
||||
if (unit.currentHealth <= 0 && (!unit.rosterId || !this.gameStateManager?.rosterManager?.roster.find(r => r.id === unit.rosterId)?.currentHealth)) {
|
||||
if (
|
||||
unit.currentHealth <= 0 &&
|
||||
(!unit.rosterId ||
|
||||
!this.gameStateManager?.rosterManager?.roster.find(
|
||||
(r) => r.id === unit.rosterId
|
||||
)?.currentHealth)
|
||||
) {
|
||||
// Only set to full if we didn't restore from roster (new unit or roster had no saved HP)
|
||||
unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100;
|
||||
}
|
||||
|
|
@ -1289,7 +1392,7 @@ export class GameLoop {
|
|||
/**
|
||||
* Finalizes deployment phase and starts combat.
|
||||
*/
|
||||
finalizeDeployment() {
|
||||
async finalizeDeployment() {
|
||||
if (
|
||||
!this.gameStateManager ||
|
||||
this.gameStateManager.currentState !== "STATE_DEPLOYMENT"
|
||||
|
|
@ -1297,7 +1400,7 @@ export class GameLoop {
|
|||
return;
|
||||
|
||||
// Get enemy spawns from mission definition
|
||||
const missionDef = this.missionManager?.getActiveMission();
|
||||
const missionDef = await this.missionManager?.getActiveMission();
|
||||
const enemySpawns = missionDef?.enemy_spawns || [];
|
||||
|
||||
// If no enemy_spawns defined, fall back to default behavior
|
||||
|
|
@ -1313,7 +1416,10 @@ export class GameLoop {
|
|||
);
|
||||
if (walkableY !== null) {
|
||||
const walkablePos = { x: spot.x, y: walkableY, z: spot.z };
|
||||
if (!this.grid.isOccupied(walkablePos) && !this.grid.isSolid(walkablePos)) {
|
||||
if (
|
||||
!this.grid.isOccupied(walkablePos) &&
|
||||
!this.grid.isSolid(walkablePos)
|
||||
) {
|
||||
this.grid.placeUnit(enemy, walkablePos);
|
||||
this.createUnitMesh(enemy, walkablePos);
|
||||
}
|
||||
|
|
@ -1329,7 +1435,11 @@ export class GameLoop {
|
|||
let attempts = 0;
|
||||
const maxAttempts = availableSpots.length * 2;
|
||||
|
||||
for (let i = 0; i < count && attempts < maxAttempts && availableSpots.length > 0; attempts++) {
|
||||
for (
|
||||
let i = 0;
|
||||
i < count && attempts < maxAttempts && availableSpots.length > 0;
|
||||
attempts++
|
||||
) {
|
||||
const spotIndex = Math.floor(Math.random() * availableSpots.length);
|
||||
const spot = availableSpots[spotIndex];
|
||||
|
||||
|
|
@ -1385,7 +1495,7 @@ export class GameLoop {
|
|||
if (this.missionManager) {
|
||||
this.missionManager.setUnitManager(this.unitManager);
|
||||
this.missionManager.setTurnSystem(this.turnSystem);
|
||||
this.missionManager.setupActiveMission();
|
||||
await this.missionManager.setupActiveMission();
|
||||
}
|
||||
|
||||
// WIRING: Listen for mission events
|
||||
|
|
@ -1579,16 +1689,16 @@ export class GameLoop {
|
|||
|
||||
// Class-based color mapping for player units
|
||||
const CLASS_COLORS = {
|
||||
CLASS_VANGUARD: 0xff3333, // Red - Tank
|
||||
CLASS_TINKER: 0xffaa00, // Orange - Tech/Mechanical
|
||||
CLASS_SCAVENGER: 0xaa33ff, // Purple - Rogue/Stealth
|
||||
CLASS_CUSTODIAN: 0x33ffaa, // Teal - Healer/Support
|
||||
CLASS_BATTLE_MAGE: 0x3333ff, // Blue - Magic Fighter
|
||||
CLASS_SAPPER: 0xff6600, // Dark Orange - Explosive
|
||||
CLASS_FIELD_ENGINEER: 0x00ffff, // Cyan - Tech Support
|
||||
CLASS_WEAVER: 0xff33ff, // Magenta - Magic (Aether Weaver)
|
||||
CLASS_VANGUARD: 0xff3333, // Red - Tank
|
||||
CLASS_TINKER: 0xffaa00, // Orange - Tech/Mechanical
|
||||
CLASS_SCAVENGER: 0xaa33ff, // Purple - Rogue/Stealth
|
||||
CLASS_CUSTODIAN: 0x33ffaa, // Teal - Healer/Support
|
||||
CLASS_BATTLE_MAGE: 0x3333ff, // Blue - Magic Fighter
|
||||
CLASS_SAPPER: 0xff6600, // Dark Orange - Explosive
|
||||
CLASS_FIELD_ENGINEER: 0x00ffff, // Cyan - Tech Support
|
||||
CLASS_WEAVER: 0xff33ff, // Magenta - Magic (Aether Weaver)
|
||||
CLASS_AETHER_SENTINEL: 0x33aaff, // Light Blue - Defensive Magic
|
||||
CLASS_ARCANE_SCOURGE: 0x6600ff, // Dark Purple - Dark Magic
|
||||
CLASS_ARCANE_SCOURGE: 0x6600ff, // Dark Purple - Dark Magic
|
||||
};
|
||||
|
||||
let color = 0xcccccc; // Default gray
|
||||
|
|
@ -1714,10 +1824,7 @@ export class GameLoop {
|
|||
const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15);
|
||||
outerGlowGeometry.rotateX(-Math.PI / 2);
|
||||
const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry);
|
||||
const outerGlowLines = new THREE.LineSegments(
|
||||
outerGlowEdges,
|
||||
outerGlow
|
||||
);
|
||||
const outerGlowLines = new THREE.LineSegments(outerGlowEdges, outerGlow);
|
||||
outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z);
|
||||
this.scene.add(outerGlowLines);
|
||||
this.spawnZoneHighlights.add(outerGlowLines);
|
||||
|
|
@ -1726,10 +1833,7 @@ export class GameLoop {
|
|||
const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08);
|
||||
midGlowGeometry.rotateX(-Math.PI / 2);
|
||||
const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry);
|
||||
const midGlowLines = new THREE.LineSegments(
|
||||
midGlowEdges,
|
||||
midGlow
|
||||
);
|
||||
const midGlowLines = new THREE.LineSegments(midGlowEdges, midGlow);
|
||||
midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z);
|
||||
this.scene.add(midGlowLines);
|
||||
this.spawnZoneHighlights.add(midGlowLines);
|
||||
|
|
@ -1745,10 +1849,7 @@ export class GameLoop {
|
|||
|
||||
// Main bright outline (exact size, brightest)
|
||||
const edgesGeometry = new THREE.EdgesGeometry(baseGeometry);
|
||||
const lineSegments = new THREE.LineSegments(
|
||||
edgesGeometry,
|
||||
highlight
|
||||
);
|
||||
const lineSegments = new THREE.LineSegments(edgesGeometry, highlight);
|
||||
lineSegments.position.set(pos.x, floorSurfaceY, pos.z);
|
||||
this.scene.add(lineSegments);
|
||||
this.spawnZoneHighlights.add(lineSegments);
|
||||
|
|
@ -1888,7 +1989,10 @@ export class GameLoop {
|
|||
// Reset turn system state BEFORE ending combat to prevent event cascades
|
||||
if (this.turnSystem) {
|
||||
// End combat first to stop any ongoing turn advancement
|
||||
if (this.turnSystem.phase !== "INIT" && this.turnSystem.phase !== "COMBAT_END") {
|
||||
if (
|
||||
this.turnSystem.phase !== "INIT" &&
|
||||
this.turnSystem.phase !== "COMBAT_END"
|
||||
) {
|
||||
try {
|
||||
this.turnSystem.endCombat();
|
||||
} catch (e) {
|
||||
|
|
@ -1978,13 +2082,18 @@ export class GameLoop {
|
|||
|
||||
// Add unlocked skill tree skills for Explorer units
|
||||
if (
|
||||
(activeUnit.type === "EXPLORER" || activeUnit.constructor?.name === "Explorer") &&
|
||||
(activeUnit.type === "EXPLORER" ||
|
||||
activeUnit.constructor?.name === "Explorer") &&
|
||||
activeUnit.activeClassId &&
|
||||
activeUnit.classMastery &&
|
||||
this.classRegistry
|
||||
) {
|
||||
const mastery = activeUnit.classMastery[activeUnit.activeClassId];
|
||||
if (mastery && mastery.unlockedNodes && mastery.unlockedNodes.length > 0) {
|
||||
if (
|
||||
mastery &&
|
||||
mastery.unlockedNodes &&
|
||||
mastery.unlockedNodes.length > 0
|
||||
) {
|
||||
try {
|
||||
// Get class definition
|
||||
const classDef = this.classRegistry.get(activeUnit.activeClassId);
|
||||
|
|
@ -2015,7 +2124,10 @@ export class GameLoop {
|
|||
const skillMap = Object.fromEntries(skillRegistry.skills);
|
||||
|
||||
// Create factory and generate tree
|
||||
const factory = new SkillTreeFactory(templateRegistry, skillMap);
|
||||
const factory = new SkillTreeFactory(
|
||||
templateRegistry,
|
||||
skillMap
|
||||
);
|
||||
const skillTree = factory.createTree(classDef);
|
||||
|
||||
// Add speed boosts from unlocked nodes to effective speed
|
||||
|
|
@ -2037,7 +2149,11 @@ export class GameLoop {
|
|||
// Add unlocked ACTIVE_SKILL nodes to skills array
|
||||
for (const nodeId of mastery.unlockedNodes) {
|
||||
const nodeDef = skillTree.nodes?.[nodeId];
|
||||
if (nodeDef && nodeDef.type === "ACTIVE_SKILL" && nodeDef.data) {
|
||||
if (
|
||||
nodeDef &&
|
||||
nodeDef.type === "ACTIVE_SKILL" &&
|
||||
nodeDef.data
|
||||
) {
|
||||
const skillData = nodeDef.data;
|
||||
const skillId = skillData.id || nodeId;
|
||||
|
||||
|
|
@ -2047,8 +2163,10 @@ export class GameLoop {
|
|||
// Add skill to skills array (avoid duplicates)
|
||||
if (!skills.find((s) => s.id === skillId)) {
|
||||
// Get costAP from full skill definition
|
||||
const costAP = fullSkill?.costs?.ap || skillData.costAP || 3;
|
||||
const baseCooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0;
|
||||
const costAP =
|
||||
fullSkill?.costs?.ap || skillData.costAP || 3;
|
||||
const baseCooldown =
|
||||
fullSkill?.cooldown_turns || skillData.cooldown || 0;
|
||||
|
||||
// Ensure skill exists in unit.actions for cooldown tracking
|
||||
if (!activeUnit.actions) {
|
||||
|
|
@ -2062,7 +2180,10 @@ export class GameLoop {
|
|||
if (!existingAction) {
|
||||
existingAction = {
|
||||
id: skillId,
|
||||
name: skillData.name || fullSkill?.name || "Unknown Skill",
|
||||
name:
|
||||
skillData.name ||
|
||||
fullSkill?.name ||
|
||||
"Unknown Skill",
|
||||
icon: skillData.icon || fullSkill?.icon || "⚔",
|
||||
costAP: costAP,
|
||||
cooldown: 0, // Newly unlocked skills start ready to use
|
||||
|
|
@ -2080,7 +2201,8 @@ export class GameLoop {
|
|||
costAP: costAP,
|
||||
cooldown: currentCooldown,
|
||||
isAvailable:
|
||||
activeUnit.currentAP >= costAP && currentCooldown === 0,
|
||||
activeUnit.currentAP >= costAP &&
|
||||
currentCooldown === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2293,7 +2415,7 @@ export class GameLoop {
|
|||
if (this.missionManager && this.turnSystem) {
|
||||
const currentTurn = this.turnSystem.round || 0;
|
||||
this.missionManager.updateTurn(currentTurn);
|
||||
this.missionManager.onGameEvent('TURN_END', { turn: currentTurn });
|
||||
this.missionManager.onGameEvent("TURN_END", { turn: currentTurn });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2315,7 +2437,12 @@ export class GameLoop {
|
|||
* @private
|
||||
*/
|
||||
processPassiveItemEffects(unit, trigger, context = {}) {
|
||||
if (!unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager) {
|
||||
if (
|
||||
!unit ||
|
||||
!unit.loadout ||
|
||||
!this.effectProcessor ||
|
||||
!this.inventoryManager
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2332,7 +2459,9 @@ export class GameLoop {
|
|||
for (const itemInstance of equippedItems) {
|
||||
if (!itemInstance || !itemInstance.defId) continue;
|
||||
|
||||
const itemDef = this.inventoryManager.itemRegistry.get(itemInstance.defId);
|
||||
const itemDef = this.inventoryManager.itemRegistry.get(
|
||||
itemInstance.defId
|
||||
);
|
||||
if (!itemDef || !itemDef.passives) continue;
|
||||
|
||||
// Check each passive effect
|
||||
|
|
@ -2353,7 +2482,11 @@ export class GameLoop {
|
|||
|
||||
// Determine target based on passive action
|
||||
let target = context.target || context.source || unit;
|
||||
if (passive.params && passive.params.target === "SOURCE" && context.source) {
|
||||
if (
|
||||
passive.params &&
|
||||
passive.params.target === "SOURCE" &&
|
||||
context.source
|
||||
) {
|
||||
target = context.source;
|
||||
} else if (passive.params && passive.params.target === "SELF") {
|
||||
target = unit;
|
||||
|
|
@ -2363,7 +2496,9 @@ export class GameLoop {
|
|||
const result = this.effectProcessor.process(effectDef, unit, target);
|
||||
if (result.success && result.data) {
|
||||
console.log(
|
||||
`Passive effect ${passive.id || "unknown"} triggered on ${unit.name} (${trigger})`
|
||||
`Passive effect ${passive.id || "unknown"} triggered on ${
|
||||
unit.name
|
||||
} (${trigger})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2491,10 +2626,12 @@ export class GameLoop {
|
|||
if (condition.type === "SOURCE_IS_ADJACENT") {
|
||||
const source = context.source;
|
||||
const target = context.target || context.unit;
|
||||
if (!source || !target || !source.position || !target.position) return false;
|
||||
const dist = Math.abs(source.position.x - target.position.x) +
|
||||
Math.abs(source.position.y - target.position.y) +
|
||||
Math.abs(source.position.z - target.position.z);
|
||||
if (!source || !target || !source.position || !target.position)
|
||||
return false;
|
||||
const dist =
|
||||
Math.abs(source.position.x - target.position.x) +
|
||||
Math.abs(source.position.y - target.position.y) +
|
||||
Math.abs(source.position.z - target.position.z);
|
||||
if (dist > 1) return false; // Not adjacent (Manhattan distance > 1)
|
||||
}
|
||||
|
||||
|
|
@ -2502,10 +2639,12 @@ export class GameLoop {
|
|||
if (condition.type === "IS_ADJACENT") {
|
||||
const source = context.source || context.unit;
|
||||
const target = context.target;
|
||||
if (!source || !target || !source.position || !target.position) return false;
|
||||
const dist = Math.abs(source.position.x - target.position.x) +
|
||||
Math.abs(source.position.y - target.position.y) +
|
||||
Math.abs(source.position.z - target.position.z);
|
||||
if (!source || !target || !source.position || !target.position)
|
||||
return false;
|
||||
const dist =
|
||||
Math.abs(source.position.x - target.position.x) +
|
||||
Math.abs(source.position.y - target.position.y) +
|
||||
Math.abs(source.position.z - target.position.z);
|
||||
if (dist > 1) return false; // Not adjacent
|
||||
}
|
||||
|
||||
|
|
@ -2543,7 +2682,7 @@ export class GameLoop {
|
|||
if (mesh.geometry) mesh.geometry.dispose();
|
||||
if (mesh.material) {
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material.forEach(mat => {
|
||||
mesh.material.forEach((mat) => {
|
||||
if (mat.map) mat.map.dispose();
|
||||
mat.dispose();
|
||||
});
|
||||
|
|
@ -2556,12 +2695,12 @@ export class GameLoop {
|
|||
|
||||
// Dispatch death event to MissionManager
|
||||
if (this.missionManager) {
|
||||
const eventType = unit.team === 'ENEMY' ? 'ENEMY_DEATH' : 'PLAYER_DEATH';
|
||||
const eventType = unit.team === "ENEMY" ? "ENEMY_DEATH" : "PLAYER_DEATH";
|
||||
const unitDefId = unit.defId || unit.id;
|
||||
this.missionManager.onGameEvent(eventType, {
|
||||
unitId: unit.id,
|
||||
defId: unitDefId,
|
||||
team: unit.team
|
||||
team: unit.team,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2574,12 +2713,12 @@ export class GameLoop {
|
|||
*/
|
||||
_setupMissionEventListeners() {
|
||||
// Listen for mission victory
|
||||
window.addEventListener('mission-victory', (event) => {
|
||||
window.addEventListener("mission-victory", (event) => {
|
||||
this._handleMissionVictory(event.detail);
|
||||
});
|
||||
|
||||
// Listen for mission failure
|
||||
window.addEventListener('mission-failure', (event) => {
|
||||
window.addEventListener("mission-failure", (event) => {
|
||||
this._handleMissionFailure(event.detail);
|
||||
});
|
||||
}
|
||||
|
|
@ -2590,7 +2729,7 @@ export class GameLoop {
|
|||
* @private
|
||||
*/
|
||||
_handleMissionVictory(detail) {
|
||||
console.log('Mission Victory!', detail);
|
||||
console.log("Mission Victory!", detail);
|
||||
|
||||
// Save Explorer progression back to roster
|
||||
this._saveExplorerProgression();
|
||||
|
|
@ -2609,38 +2748,50 @@ export class GameLoop {
|
|||
// Wait for the outro narrative to complete before transitioning
|
||||
// The outro is played in MissionManager.completeActiveMission()
|
||||
// We'll listen for the narrative-end event to know when it's done
|
||||
const hasOutro = this.gameStateManager?.missionManager?.currentMissionDef?.narrative?.outro_success;
|
||||
const hasOutro =
|
||||
this.gameStateManager?.missionManager?.currentMissionDef?.narrative
|
||||
?.outro_success;
|
||||
|
||||
if (hasOutro) {
|
||||
console.log('GameLoop: Waiting for outro narrative to complete...');
|
||||
console.log("GameLoop: Waiting for outro narrative to complete...");
|
||||
const handleNarrativeEnd = () => {
|
||||
console.log('GameLoop: Narrative end event received, transitioning to hub');
|
||||
narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd);
|
||||
console.log(
|
||||
"GameLoop: Narrative end event received, transitioning to hub"
|
||||
);
|
||||
narrativeManager.removeEventListener(
|
||||
"narrative-end",
|
||||
handleNarrativeEnd
|
||||
);
|
||||
|
||||
// Small delay after narrative ends to let user see the final message
|
||||
setTimeout(() => {
|
||||
if (this.gameStateManager) {
|
||||
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
|
||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
narrativeManager.addEventListener('narrative-end', handleNarrativeEnd);
|
||||
narrativeManager.addEventListener("narrative-end", handleNarrativeEnd);
|
||||
|
||||
// Fallback timeout: if narrative doesn't end within 30 seconds, transition anyway
|
||||
setTimeout(() => {
|
||||
console.warn('GameLoop: Narrative end timeout - transitioning to hub anyway');
|
||||
narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd);
|
||||
console.warn(
|
||||
"GameLoop: Narrative end timeout - transitioning to hub anyway"
|
||||
);
|
||||
narrativeManager.removeEventListener(
|
||||
"narrative-end",
|
||||
handleNarrativeEnd
|
||||
);
|
||||
if (this.gameStateManager) {
|
||||
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
|
||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||
}
|
||||
}, 30000);
|
||||
} else {
|
||||
// No outro, transition immediately after a short delay
|
||||
console.log('GameLoop: No outro narrative, transitioning to hub');
|
||||
console.log("GameLoop: No outro narrative, transitioning to hub");
|
||||
setTimeout(() => {
|
||||
if (this.gameStateManager) {
|
||||
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
|
||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
|
@ -2653,8 +2804,9 @@ export class GameLoop {
|
|||
_saveExplorerProgression() {
|
||||
if (!this.unitManager || !this.gameStateManager) return;
|
||||
|
||||
const playerUnits = Array.from(this.unitManager.activeUnits.values())
|
||||
.filter(u => u.team === 'PLAYER' && u.type === 'EXPLORER');
|
||||
const playerUnits = Array.from(
|
||||
this.unitManager.activeUnits.values()
|
||||
).filter((u) => u.team === "PLAYER" && u.type === "EXPLORER");
|
||||
|
||||
for (const unit of playerUnits) {
|
||||
// Use rosterId if available, otherwise fall back to unit.id
|
||||
|
|
@ -2662,13 +2814,15 @@ export class GameLoop {
|
|||
if (!rosterId) continue;
|
||||
|
||||
const rosterUnit = this.gameStateManager.rosterManager.roster.find(
|
||||
r => r.id === rosterId
|
||||
(r) => r.id === rosterId
|
||||
);
|
||||
|
||||
if (rosterUnit) {
|
||||
// Save classMastery progression
|
||||
if (unit.classMastery) {
|
||||
rosterUnit.classMastery = JSON.parse(JSON.stringify(unit.classMastery));
|
||||
rosterUnit.classMastery = JSON.parse(
|
||||
JSON.stringify(unit.classMastery)
|
||||
);
|
||||
}
|
||||
// Save activeClassId
|
||||
if (unit.activeClassId) {
|
||||
|
|
@ -2685,7 +2839,9 @@ export class GameLoop {
|
|||
if (unit.currentHealth !== undefined) {
|
||||
rosterUnit.currentHealth = unit.currentHealth;
|
||||
}
|
||||
console.log(`Saved progression for ${unit.name} (roster ID: ${rosterId})`);
|
||||
console.log(
|
||||
`Saved progression for ${unit.name} (roster ID: ${rosterId})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2701,7 +2857,7 @@ export class GameLoop {
|
|||
* @private
|
||||
*/
|
||||
_handleMissionFailure(detail) {
|
||||
console.log('Mission Failed!', detail);
|
||||
console.log("Mission Failed!", detail);
|
||||
|
||||
// Save Explorer progression back to roster (even on failure, progression should persist)
|
||||
this._saveExplorerProgression();
|
||||
|
|
@ -2721,7 +2877,7 @@ export class GameLoop {
|
|||
// For now, just log and transition back to main menu after a delay
|
||||
setTimeout(() => {
|
||||
if (this.gameStateManager) {
|
||||
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
|
||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { RosterManager } from "../managers/RosterManager.js";
|
|||
import { MissionManager } from "../managers/MissionManager.js";
|
||||
import { narrativeManager } from "../managers/NarrativeManager.js";
|
||||
import { MarketManager } from "../managers/MarketManager.js";
|
||||
import { ResearchManager } from "../managers/ResearchManager.js";
|
||||
import { InventoryManager } from "../managers/InventoryManager.js";
|
||||
import { InventoryContainer } from "../models/InventoryContainer.js";
|
||||
import { itemRegistry } from "../managers/ItemRegistry.js";
|
||||
|
|
@ -74,6 +75,11 @@ class GameStateManagerClass {
|
|||
this.hubInventoryManager,
|
||||
this.missionManager
|
||||
);
|
||||
/** @type {ResearchManager} */
|
||||
this.researchManager = new ResearchManager(
|
||||
this.persistence,
|
||||
this.rosterManager
|
||||
);
|
||||
|
||||
this.handleEmbark = this.handleEmbark.bind(this);
|
||||
}
|
||||
|
|
@ -105,6 +111,11 @@ class GameStateManagerClass {
|
|||
setGameLoop(loop) {
|
||||
this.gameLoop = loop;
|
||||
this.#gameLoopInitialized.resolve();
|
||||
|
||||
// Update debug commands reference
|
||||
if (typeof window !== "undefined" && window.debugCommands) {
|
||||
window.debugCommands.setGameLoop(loop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,7 +168,10 @@ class GameStateManagerClass {
|
|||
// 3. Initialize Market Manager
|
||||
await this.marketManager.init();
|
||||
|
||||
// 4. Load Campaign Progress
|
||||
// 4. Initialize Research Manager
|
||||
await this.researchManager.load();
|
||||
|
||||
// 5. Load Campaign Progress
|
||||
const savedCampaignData = await this.persistence.loadCampaign();
|
||||
console.log("Loaded campaign data:", savedCampaignData);
|
||||
if (savedCampaignData) {
|
||||
|
|
@ -170,10 +184,10 @@ class GameStateManagerClass {
|
|||
console.log("No saved campaign data found");
|
||||
}
|
||||
|
||||
// 5. Set up mission rewards listener
|
||||
// 6. Set up mission rewards listener
|
||||
this._setupMissionRewardsListener();
|
||||
|
||||
// 6. Set up campaign data change listener
|
||||
// 7. Set up campaign data change listener
|
||||
this._setupCampaignDataListener();
|
||||
|
||||
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
|
||||
|
|
@ -326,8 +340,8 @@ class GameStateManagerClass {
|
|||
|
||||
// 1. Mission Logic: Setup
|
||||
// This resets objectives and prepares the logic for the new run
|
||||
this.missionManager.setupActiveMission();
|
||||
const missionDef = this.missionManager.getActiveMission();
|
||||
await this.missionManager.setupActiveMission();
|
||||
const missionDef = await this.missionManager.getActiveMission();
|
||||
|
||||
console.log(`Initializing Run for Mission: ${missionDef.config.title}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const MARKET_STORE = "Market";
|
|||
const CAMPAIGN_STORE = "Campaign";
|
||||
const HUB_STASH_STORE = "HubStash";
|
||||
const UNLOCKS_STORE = "Unlocks";
|
||||
const VERSION = 6; // Bumped version to add Campaign store
|
||||
const RESEARCH_STORE = "Research";
|
||||
const VERSION = 7; // Bumped version to add Research store
|
||||
|
||||
/**
|
||||
* Handles game data persistence using IndexedDB.
|
||||
|
|
@ -69,6 +70,11 @@ export class Persistence {
|
|||
if (!db.objectStoreNames.contains(UNLOCKS_STORE)) {
|
||||
db.createObjectStore(UNLOCKS_STORE, { keyPath: "id" });
|
||||
}
|
||||
|
||||
// Create Research Store if missing
|
||||
if (!db.objectStoreNames.contains(RESEARCH_STORE)) {
|
||||
db.createObjectStore(RESEARCH_STORE, { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (e) => {
|
||||
|
|
@ -233,6 +239,28 @@ export class Persistence {
|
|||
return result ? result.data : [];
|
||||
}
|
||||
|
||||
// --- RESEARCH DATA ---
|
||||
|
||||
/**
|
||||
* Saves research state.
|
||||
* @param {import("../managers/ResearchManager.js").ResearchState} researchState - Research state to save
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async saveResearchState(researchState) {
|
||||
if (!this.db) await this.init();
|
||||
return this._put(RESEARCH_STORE, { id: "research_state", data: researchState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads research state.
|
||||
* @returns {Promise<import("../managers/ResearchManager.js").ResearchState | null>}
|
||||
*/
|
||||
async loadResearchState() {
|
||||
if (!this.db) await this.init();
|
||||
const result = await this._get(RESEARCH_STORE, "research_state");
|
||||
return result ? result.data : null;
|
||||
}
|
||||
|
||||
// --- INTERNAL HELPERS ---
|
||||
|
||||
/**
|
||||
|
|
|
|||
90
src/index.js
90
src/index.js
|
|
@ -349,3 +349,93 @@ window.addEventListener("save-and-quit", async () => {
|
|||
|
||||
// Boot
|
||||
gameStateManager.init();
|
||||
|
||||
// Lazy-load debug commands - only load when first accessed
|
||||
// Creates async wrappers for all methods that load the module on first call
|
||||
if (typeof window !== "undefined") {
|
||||
let debugCommandsInstance = null;
|
||||
let debugCommandsLoading = null;
|
||||
|
||||
// List of all debug command methods (for creating async wrappers)
|
||||
const debugCommandMethods = [
|
||||
"addXP",
|
||||
"setLevel",
|
||||
"addSkillPoints",
|
||||
"unlockSkill",
|
||||
"unlockAllSkills",
|
||||
"addItem",
|
||||
"addCurrency",
|
||||
"killEnemy",
|
||||
"healUnit",
|
||||
"triggerVictory",
|
||||
"completeObjective",
|
||||
"triggerNarrative",
|
||||
"help",
|
||||
"listIds",
|
||||
"listPlayerIds",
|
||||
"listEnemyIds",
|
||||
"listItemIds",
|
||||
"listUnits",
|
||||
"listItems",
|
||||
"getState",
|
||||
];
|
||||
|
||||
// Create async wrapper functions for each method
|
||||
const createAsyncWrapper = (methodName) => {
|
||||
return function (...args) {
|
||||
// If already loaded, call directly
|
||||
if (debugCommandsInstance) {
|
||||
const method = debugCommandsInstance[methodName];
|
||||
if (typeof method === "function") {
|
||||
return method.apply(debugCommandsInstance, args);
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
// If currently loading, wait for it
|
||||
if (debugCommandsLoading) {
|
||||
return debugCommandsLoading.then(() => {
|
||||
const method = debugCommandsInstance[methodName];
|
||||
if (typeof method === "function") {
|
||||
return method.apply(debugCommandsInstance, args);
|
||||
}
|
||||
return method;
|
||||
});
|
||||
}
|
||||
|
||||
// Start loading the module
|
||||
debugCommandsLoading = import("./core/DebugCommands.js").then(
|
||||
(module) => {
|
||||
const { debugCommands } = module;
|
||||
if (!debugCommands._initialized) {
|
||||
debugCommands.init();
|
||||
debugCommands._initialized = true;
|
||||
}
|
||||
debugCommandsInstance = debugCommands;
|
||||
return debugCommands;
|
||||
}
|
||||
);
|
||||
|
||||
// Wait for load, then call the method
|
||||
return debugCommandsLoading.then(() => {
|
||||
const method = debugCommandsInstance[methodName];
|
||||
if (typeof method === "function") {
|
||||
return method.apply(debugCommandsInstance, args);
|
||||
}
|
||||
return method;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Create the debugCommands object with async wrappers
|
||||
window.debugCommands = {};
|
||||
debugCommandMethods.forEach((methodName) => {
|
||||
window.debugCommands[methodName] = createAsyncWrapper(methodName);
|
||||
});
|
||||
|
||||
// Add a note about async nature
|
||||
Object.defineProperty(window.debugCommands, "_note", {
|
||||
value: "Debug commands are lazy-loaded. First call may take a moment.",
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
import { Item } from "../items/Item.js";
|
||||
import tier1Gear from "../items/tier1_gear.json" with { type: "json" };
|
||||
|
||||
export class ItemRegistry {
|
||||
constructor() {
|
||||
|
|
@ -36,6 +35,9 @@ export class ItemRegistry {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _doLoadAll() {
|
||||
// Lazy-load tier1_gear.json
|
||||
const tier1Gear = await import("../items/tier1_gear.json", { with: { type: "json" } }).then(m => m.default);
|
||||
|
||||
// Load tier1_gear.json
|
||||
for (const itemDef of tier1Gear) {
|
||||
if (itemDef && itemDef.id) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
* @typedef {import("./types.js").GameEventData} GameEventData
|
||||
*/
|
||||
|
||||
import tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' };
|
||||
import { narrativeManager } from './NarrativeManager.js';
|
||||
|
||||
/**
|
||||
|
|
@ -45,8 +44,47 @@ export class MissionManager {
|
|||
/** @type {number} */
|
||||
this.currentTurn = 0;
|
||||
|
||||
// Register default missions
|
||||
this.registerMission(tutorialMission);
|
||||
/** @type {Promise<void> | null} */
|
||||
this._missionsLoadPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loads all mission definitions if not already loaded.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _ensureMissionsLoaded() {
|
||||
if (this._missionsLoadPromise) {
|
||||
return this._missionsLoadPromise;
|
||||
}
|
||||
|
||||
this._missionsLoadPromise = this._loadMissions();
|
||||
return this._missionsLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all mission definitions.
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _loadMissions() {
|
||||
// Only load if registry is empty (first time)
|
||||
if (this.missionRegistry.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [tutorialMission, story02Mission, story03Mission] = await Promise.all([
|
||||
import('../assets/data/missions/mission_tutorial_01.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/missions/mission_story_02.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/missions/mission_story_03.json', { with: { type: 'json' } }).then(m => m.default)
|
||||
]);
|
||||
|
||||
this.registerMission(tutorialMission);
|
||||
this.registerMission(story02Mission);
|
||||
this.registerMission(story03Mission);
|
||||
} catch (error) {
|
||||
console.error('Failed to load missions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -85,9 +123,11 @@ export class MissionManager {
|
|||
|
||||
/**
|
||||
* Gets the configuration for the currently selected mission.
|
||||
* @returns {MissionDefinition | undefined} - Active mission definition
|
||||
* Ensures missions are loaded before accessing.
|
||||
* @returns {Promise<MissionDefinition | undefined>} - Active mission definition
|
||||
*/
|
||||
getActiveMission() {
|
||||
async getActiveMission() {
|
||||
await this._ensureMissionsLoaded();
|
||||
if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01');
|
||||
return this.missionRegistry.get(this.activeMissionId);
|
||||
}
|
||||
|
|
@ -111,9 +151,11 @@ export class MissionManager {
|
|||
/**
|
||||
* Prepares the manager for a new run.
|
||||
* Resets objectives and prepares narrative hooks.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
setupActiveMission() {
|
||||
const mission = this.getActiveMission();
|
||||
async setupActiveMission() {
|
||||
await this._ensureMissionsLoaded();
|
||||
const mission = await this.getActiveMission();
|
||||
this.currentMissionDef = mission;
|
||||
this.currentTurn = 0;
|
||||
|
||||
|
|
@ -551,6 +593,11 @@ export class MissionManager {
|
|||
localStorage.setItem('aether_shards_unlocks', JSON.stringify(unlocks));
|
||||
console.log('Unlocked classes (localStorage fallback):', classIds);
|
||||
}
|
||||
|
||||
// Dispatch event so UI components can refresh
|
||||
window.dispatchEvent(new CustomEvent('classes-unlocked', {
|
||||
detail: { unlockedClasses: classIds, allUnlocks: unlocks }
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to save unlocks to storage:', e);
|
||||
}
|
||||
|
|
|
|||
397
src/managers/ResearchManager.js
Normal file
397
src/managers/ResearchManager.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/**
|
||||
* ResearchManager.js
|
||||
* Manages the Research system - tech trees, node unlocking, and passive effects.
|
||||
* @class
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ResearchNode
|
||||
* @property {string} id - Unique node ID (e.g. "RES_LOGISTICS_01")
|
||||
* @property {string} name - Display name
|
||||
* @property {string} description - Description text
|
||||
* @property {"LOGISTICS" | "INTEL" | "FIELD_OPS"} tree - Which tree this belongs to
|
||||
* @property {number} tier - Depth in the tree (1-5)
|
||||
* @property {number} cost - Ancient Cores cost
|
||||
* @property {string[]} prerequisites - IDs of parent nodes that must be unlocked first
|
||||
* @property {Object} effect - The actual effect applied to the game state
|
||||
* @property {string} effect.type - Effect type
|
||||
* @property {number | string} effect.value - Effect value
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ResearchState
|
||||
* @property {string[]} unlockedNodeIds - List of IDs player has bought
|
||||
* @property {number} availableCores - Available Ancient Cores (not persisted, read from hubStash)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Research node definitions based on Research.spec.md
|
||||
* @type {ResearchNode[]}
|
||||
*/
|
||||
const RESEARCH_NODES = [
|
||||
// LOGISTICS TREE
|
||||
{
|
||||
id: "RES_LOGISTICS_01",
|
||||
name: "Expanded Barracks I",
|
||||
description: "Increases Roster Limit by 2",
|
||||
tree: "LOGISTICS",
|
||||
tier: 1,
|
||||
cost: 2,
|
||||
prerequisites: [],
|
||||
effect: { type: "ROSTER_LIMIT", value: 2 },
|
||||
},
|
||||
{
|
||||
id: "RES_LOGISTICS_02",
|
||||
name: "Bulk Contracts",
|
||||
description: "Recruiting cost reduced by 10%",
|
||||
tree: "LOGISTICS",
|
||||
tier: 2,
|
||||
cost: 3,
|
||||
prerequisites: ["RES_LOGISTICS_01"],
|
||||
effect: { type: "RECRUIT_DISCOUNT", value: 0.1 },
|
||||
},
|
||||
{
|
||||
id: "RES_LOGISTICS_03",
|
||||
name: "Expanded Barracks II",
|
||||
description: "Increases Roster Limit by 4",
|
||||
tree: "LOGISTICS",
|
||||
tier: 3,
|
||||
cost: 5,
|
||||
prerequisites: ["RES_LOGISTICS_01"],
|
||||
effect: { type: "ROSTER_LIMIT", value: 4 },
|
||||
},
|
||||
{
|
||||
id: "RES_LOGISTICS_04",
|
||||
name: "Deep Pockets",
|
||||
description: "Market Buyback slots increased by 2",
|
||||
tree: "LOGISTICS",
|
||||
tier: 4,
|
||||
cost: 4,
|
||||
prerequisites: ["RES_LOGISTICS_02"],
|
||||
effect: { type: "MARKET_BUYBACK_SLOTS", value: 2 },
|
||||
},
|
||||
{
|
||||
id: "RES_LOGISTICS_05",
|
||||
name: "Salvage Protocol",
|
||||
description: "Sell prices at Market increased by 10%",
|
||||
tree: "LOGISTICS",
|
||||
tier: 5,
|
||||
cost: 6,
|
||||
prerequisites: ["RES_LOGISTICS_03", "RES_LOGISTICS_04"],
|
||||
effect: { type: "MARKET_SELL_BONUS", value: 0.1 },
|
||||
},
|
||||
|
||||
// INTELLIGENCE TREE
|
||||
{
|
||||
id: "RES_INTEL_01",
|
||||
name: "Scout Drone",
|
||||
description: "Reveals the biome type of Side Missions before accepting them",
|
||||
tree: "INTEL",
|
||||
tier: 1,
|
||||
cost: 2,
|
||||
prerequisites: [],
|
||||
effect: { type: "UI_UNLOCK", value: "SCOUT_DRONE" },
|
||||
},
|
||||
{
|
||||
id: "RES_INTEL_02",
|
||||
name: "Vital Sensors",
|
||||
description: "Enemy Health Bars show exact numbers instead of just bars",
|
||||
tree: "INTEL",
|
||||
tier: 2,
|
||||
cost: 3,
|
||||
prerequisites: ["RES_INTEL_01"],
|
||||
effect: { type: "UI_UNLOCK", value: "VITAL_SENSORS" },
|
||||
},
|
||||
{
|
||||
id: "RES_INTEL_03",
|
||||
name: "Threat Analysis",
|
||||
description: "Reveals Enemy Types (e.g. 'Mechanical') on the Mission Board dossier",
|
||||
tree: "INTEL",
|
||||
tier: 3,
|
||||
cost: 4,
|
||||
prerequisites: ["RES_INTEL_01"],
|
||||
effect: { type: "UI_UNLOCK", value: "THREAT_ANALYSIS" },
|
||||
},
|
||||
{
|
||||
id: "RES_INTEL_04",
|
||||
name: "Map Data",
|
||||
description: "Reveals the location of the Boss Room on the minimap at start of run",
|
||||
tree: "INTEL",
|
||||
tier: 4,
|
||||
cost: 5,
|
||||
prerequisites: ["RES_INTEL_02", "RES_INTEL_03"],
|
||||
effect: { type: "UI_UNLOCK", value: "MAP_DATA" },
|
||||
},
|
||||
|
||||
// FIELD OPS TREE
|
||||
{
|
||||
id: "RES_FIELD_OPS_01",
|
||||
name: "Supply Drop",
|
||||
description: "Start every run with 1 Free Potion in the shared stash",
|
||||
tree: "FIELD_OPS",
|
||||
tier: 1,
|
||||
cost: 2,
|
||||
prerequisites: [],
|
||||
effect: { type: "STARTING_ITEM", value: "ITEM_POTION" },
|
||||
},
|
||||
{
|
||||
id: "RES_FIELD_OPS_02",
|
||||
name: "Fast Deploy",
|
||||
description: "First turn of combat grants +1 AP to all units",
|
||||
tree: "FIELD_OPS",
|
||||
tier: 2,
|
||||
cost: 3,
|
||||
prerequisites: ["RES_FIELD_OPS_01"],
|
||||
effect: { type: "COMBAT_BONUS", value: "FAST_DEPLOY" },
|
||||
},
|
||||
{
|
||||
id: "RES_FIELD_OPS_03",
|
||||
name: "Emergency Beacon",
|
||||
description: "Retreat option becomes available (Escape with 50% loot retention instead of 0%)",
|
||||
tree: "FIELD_OPS",
|
||||
tier: 3,
|
||||
cost: 4,
|
||||
prerequisites: ["RES_FIELD_OPS_01"],
|
||||
effect: { type: "UI_UNLOCK", value: "EMERGENCY_BEACON" },
|
||||
},
|
||||
{
|
||||
id: "RES_FIELD_OPS_04",
|
||||
name: "Hardened Steel",
|
||||
description: "All crafted/bought weapons start with +1 Damage",
|
||||
tree: "FIELD_OPS",
|
||||
tier: 4,
|
||||
cost: 5,
|
||||
prerequisites: ["RES_FIELD_OPS_02", "RES_FIELD_OPS_03"],
|
||||
effect: { type: "WEAPON_BONUS", value: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
export class ResearchManager extends EventTarget {
|
||||
/**
|
||||
* @param {import("../core/Persistence.js").Persistence} persistence - Persistence manager
|
||||
* @param {import("../managers/RosterManager.js").RosterManager} rosterManager - Roster manager (for applying passive effects)
|
||||
*/
|
||||
constructor(persistence, rosterManager) {
|
||||
super();
|
||||
/** @type {import("../core/Persistence.js").Persistence} */
|
||||
this.persistence = persistence;
|
||||
/** @type {import("../managers/RosterManager.js").RosterManager} */
|
||||
this.rosterManager = rosterManager;
|
||||
|
||||
/** @type {ResearchState} */
|
||||
this.state = {
|
||||
unlockedNodeIds: [],
|
||||
availableCores: 0,
|
||||
};
|
||||
|
||||
/** @type {Map<string, ResearchNode>} */
|
||||
this.nodeMap = new Map();
|
||||
RESEARCH_NODES.forEach((node) => {
|
||||
this.nodeMap.set(node.id, node);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all research nodes.
|
||||
* @returns {ResearchNode[]}
|
||||
*/
|
||||
getAllNodes() {
|
||||
return Array.from(this.nodeMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a node by ID.
|
||||
* @param {string} nodeId - Node ID
|
||||
* @returns {ResearchNode | undefined}
|
||||
*/
|
||||
getNode(nodeId) {
|
||||
return this.nodeMap.get(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets nodes by tree type.
|
||||
* @param {"LOGISTICS" | "INTEL" | "FIELD_OPS"} treeType - Tree type
|
||||
* @returns {ResearchNode[]}
|
||||
*/
|
||||
getNodesByTree(treeType) {
|
||||
return RESEARCH_NODES.filter((node) => node.tree === treeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads research state from persistence.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async load() {
|
||||
const savedState = await this.persistence.loadResearchState();
|
||||
if (savedState) {
|
||||
this.state.unlockedNodeIds = savedState.unlockedNodeIds || [];
|
||||
} else {
|
||||
this.state.unlockedNodeIds = [];
|
||||
}
|
||||
// Apply passive effects after loading
|
||||
this.applyPassiveEffects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves research state to persistence.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save() {
|
||||
await this.persistence.saveResearchState({
|
||||
unlockedNodeIds: this.state.unlockedNodeIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates available cores from hub stash.
|
||||
* @param {number} cores - Available Ancient Cores
|
||||
*/
|
||||
updateAvailableCores(cores) {
|
||||
this.state.availableCores = cores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node is unlocked.
|
||||
* @param {string} nodeId - Node ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isUnlocked(nodeId) {
|
||||
return this.state.unlockedNodeIds.includes(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node is available (prerequisites met).
|
||||
* @param {string} nodeId - Node ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAvailable(nodeId) {
|
||||
const node = this.getNode(nodeId);
|
||||
if (!node) return false;
|
||||
if (this.isUnlocked(nodeId)) return false; // Already unlocked
|
||||
|
||||
// Check all prerequisites are unlocked
|
||||
return node.prerequisites.every((prereqId) => this.isUnlocked(prereqId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status of a node: "LOCKED", "AVAILABLE", or "RESEARCHED"
|
||||
* @param {string} nodeId - Node ID
|
||||
* @returns {"LOCKED" | "AVAILABLE" | "RESEARCHED"}
|
||||
*/
|
||||
getNodeStatus(nodeId) {
|
||||
if (this.isUnlocked(nodeId)) return "RESEARCHED";
|
||||
if (this.isAvailable(nodeId)) return "AVAILABLE";
|
||||
return "LOCKED";
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks a research node.
|
||||
* Validates cost, prerequisites, and deducts cores.
|
||||
* @param {string} nodeId - Node ID to unlock
|
||||
* @param {number} availableCores - Current available Ancient Cores (from hubStash)
|
||||
* @returns {Promise<{ success: boolean; error?: string }>}
|
||||
*/
|
||||
async unlockNode(nodeId, availableCores) {
|
||||
const node = this.getNode(nodeId);
|
||||
if (!node) {
|
||||
return { success: false, error: "Node not found" };
|
||||
}
|
||||
|
||||
// Check if already unlocked
|
||||
if (this.isUnlocked(nodeId)) {
|
||||
return { success: false, error: "Node already researched" };
|
||||
}
|
||||
|
||||
// Check prerequisites
|
||||
if (!this.isAvailable(nodeId)) {
|
||||
return { success: false, error: "Prerequisites not met" };
|
||||
}
|
||||
|
||||
// Check cost
|
||||
if (availableCores < node.cost) {
|
||||
return { success: false, error: "Insufficient Ancient Cores" };
|
||||
}
|
||||
|
||||
// Unlock the node
|
||||
this.state.unlockedNodeIds.push(nodeId);
|
||||
await this.save();
|
||||
|
||||
// Apply passive effects immediately
|
||||
this.applyPassiveEffects();
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("research-complete", {
|
||||
detail: { nodeId, node },
|
||||
})
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies passive effects from unlocked nodes.
|
||||
* Updates global constants like Roster Limit.
|
||||
*/
|
||||
applyPassiveEffects() {
|
||||
// Reset roster limit to base
|
||||
this.rosterManager.rosterLimit = 12;
|
||||
|
||||
// Apply all unlocked node effects
|
||||
for (const nodeId of this.state.unlockedNodeIds) {
|
||||
const node = this.getNode(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
const { type, value } = node.effect;
|
||||
|
||||
switch (type) {
|
||||
case "ROSTER_LIMIT":
|
||||
this.rosterManager.rosterLimit += Number(value);
|
||||
break;
|
||||
case "RECRUIT_DISCOUNT":
|
||||
// This will be applied in recruitment logic
|
||||
break;
|
||||
case "MARKET_BUYBACK_SLOTS":
|
||||
// This will be applied in MarketManager
|
||||
break;
|
||||
case "MARKET_SELL_BONUS":
|
||||
// This will be applied in MarketManager
|
||||
break;
|
||||
case "UI_UNLOCK":
|
||||
case "STARTING_ITEM":
|
||||
case "COMBAT_BONUS":
|
||||
case "WEAPON_BONUS":
|
||||
// These are runtime effects, handled elsewhere
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a research unlock is active (for runtime effects).
|
||||
* @param {string} unlockId - Unlock ID (e.g. "SCOUT_DRONE")
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasUnlock(unlockId) {
|
||||
return this.state.unlockedNodeIds.some((nodeId) => {
|
||||
const node = this.getNode(nodeId);
|
||||
return node && node.effect.type === "UI_UNLOCK" && node.effect.value === unlockId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all active starting items.
|
||||
* @returns {string[]} - Array of item IDs
|
||||
*/
|
||||
getStartingItems() {
|
||||
const items = [];
|
||||
for (const nodeId of this.state.unlockedNodeIds) {
|
||||
const node = this.getNode(nodeId);
|
||||
if (node && node.effect.type === "STARTING_ITEM") {
|
||||
items.push(String(node.effect.value));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +228,9 @@ export class MissionBoard extends LitElement {
|
|||
this._loadMissions();
|
||||
}
|
||||
|
||||
_loadMissions() {
|
||||
async _loadMissions() {
|
||||
// Ensure missions are loaded before accessing registry
|
||||
await gameStateManager.missionManager._ensureMissionsLoaded();
|
||||
// Get all registered missions from MissionManager
|
||||
const missionRegistry = gameStateManager.missionManager.missionRegistry;
|
||||
this.missions = Array.from(missionRegistry.values());
|
||||
|
|
@ -238,8 +240,41 @@ export class MissionBoard extends LitElement {
|
|||
|
||||
_isMissionAvailable(mission) {
|
||||
// Check if mission prerequisites are met
|
||||
// For now, all missions are available unless they have explicit prerequisites
|
||||
return true;
|
||||
const prerequisites = mission.config?.prerequisites || [];
|
||||
|
||||
// If no prerequisites, mission is available
|
||||
if (prerequisites.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if all prerequisites are completed
|
||||
return prerequisites.every(prereqId => this.completedMissions.has(prereqId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a mission should be shown in the mission board.
|
||||
* Story missions are hidden until available, other types show as locked.
|
||||
* @param {Object} mission - Mission definition
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldShowMission(mission) {
|
||||
const isAvailable = this._isMissionAvailable(mission);
|
||||
|
||||
// If available, always show
|
||||
if (isAvailable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check visibility setting (defaults based on mission type)
|
||||
const visibility = mission.config?.visibility_when_locked;
|
||||
|
||||
// Default behavior: STORY missions are hidden, others show as locked
|
||||
if (visibility === undefined) {
|
||||
return mission.type !== "STORY";
|
||||
}
|
||||
|
||||
// Explicit setting overrides default
|
||||
return visibility === "locked";
|
||||
}
|
||||
|
||||
_isMissionCompleted(missionId) {
|
||||
|
|
@ -321,7 +356,9 @@ export class MissionBoard extends LitElement {
|
|||
</div>
|
||||
|
||||
<div class="missions-grid">
|
||||
${this.missions.map((mission) => {
|
||||
${this.missions
|
||||
.filter(mission => this._shouldShowMission(mission))
|
||||
.map((mission) => {
|
||||
const isCompleted = this._isMissionCompleted(mission.id);
|
||||
const isAvailable = this._isMissionAvailable(mission);
|
||||
const rewards = this._formatRewards(mission.rewards);
|
||||
|
|
@ -358,6 +395,14 @@ export class MissionBoard extends LitElement {
|
|||
Difficulty: ${this._getDifficultyLabel(mission.config)}
|
||||
</span>
|
||||
${isCompleted ? html`<span style="color: var(--color-accent-green);">✓ Completed</span>` : ''}
|
||||
${!isAvailable && !isCompleted ? html`
|
||||
<span style="color: var(--color-text-muted); font-size: var(--font-size-xs);">
|
||||
🔒 Requires: ${mission.config?.prerequisites?.map(id => {
|
||||
const prereqMission = this.missions.find(m => m.id === id);
|
||||
return prereqMission?.config?.title || id;
|
||||
}).join(', ') || 'Previous missions'}
|
||||
</span>
|
||||
` : ''}
|
||||
${isAvailable && !isCompleted ? html`
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export class GameViewport extends LitElement {
|
|||
|
||||
#handleStartBattle() {
|
||||
if (gameStateManager.gameLoop) {
|
||||
gameStateManager.gameLoop.finalizeDeployment();
|
||||
gameStateManager.gameLoop.finalizeDeployment().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,9 +77,17 @@ export class GameViewport extends LitElement {
|
|||
// Don't set squad from rosterLoaded - that's the full roster, not the current mission squad
|
||||
// Squad will be set from activeRunData when transitioning to deployment state
|
||||
|
||||
// Get mission definition for deployment hints
|
||||
this.missionDef =
|
||||
gameStateManager.missionManager?.getActiveMission() || null;
|
||||
// Get mission definition for deployment hints (lazy-loaded)
|
||||
this.missionDef = null;
|
||||
if (gameStateManager.missionManager) {
|
||||
gameStateManager.missionManager
|
||||
.getActiveMission()
|
||||
.then((mission) => {
|
||||
this.missionDef = mission || null;
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
// Set up combat state updates
|
||||
this.#setupCombatStateUpdates();
|
||||
|
|
|
|||
712
src/ui/screens/ResearchScreen.js
Normal file
712
src/ui/screens/ResearchScreen.js
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
import { LitElement, html, css } from "lit";
|
||||
import { gameStateManager } from "../../core/GameStateManager.js";
|
||||
import { theme, buttonStyles, cardStyles } from "../styles/theme.js";
|
||||
|
||||
/**
|
||||
* ResearchScreen.js
|
||||
* The Ancient Archive - Research facility UI component.
|
||||
* @class
|
||||
*/
|
||||
export class ResearchScreen extends LitElement {
|
||||
static get styles() {
|
||||
return [
|
||||
theme,
|
||||
buttonStyles,
|
||||
cardStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
background: var(--color-bg-secondary);
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
padding: var(--spacing-xl);
|
||||
max-width: 1600px;
|
||||
max-height: 85vh;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-family);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"trees inspector";
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-width-medium) solid
|
||||
var(--color-border-default);
|
||||
padding-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: var(--color-accent-cyan);
|
||||
font-size: var(--font-size-4xl);
|
||||
}
|
||||
|
||||
.cores-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--color-accent-gold);
|
||||
}
|
||||
|
||||
.cores-icon {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
background: transparent;
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
color: var(--color-text-primary);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xl);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
border-color: var(--color-accent-red);
|
||||
color: var(--color-accent-red);
|
||||
background: rgba(255, 102, 102, 0.1);
|
||||
}
|
||||
|
||||
/* Trees Container */
|
||||
.trees-container {
|
||||
grid-area: trees;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
overflow-y: auto;
|
||||
padding-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tree-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-bg-card);
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.tree-header.logistics {
|
||||
border-color: var(--color-accent-green);
|
||||
}
|
||||
|
||||
.tree-header.intel {
|
||||
border-color: var(--color-accent-cyan);
|
||||
}
|
||||
|
||||
.tree-header.field-ops {
|
||||
border-color: var(--color-accent-red);
|
||||
}
|
||||
|
||||
.tree-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tree-title.logistics {
|
||||
color: var(--color-accent-green);
|
||||
}
|
||||
|
||||
.tree-title.intel {
|
||||
color: var(--color-accent-cyan);
|
||||
}
|
||||
|
||||
.tree-title.field-ops {
|
||||
color: var(--color-accent-red);
|
||||
}
|
||||
|
||||
/* Tree View */
|
||||
.tree-view {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
background: var(--color-bg-card);
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
padding: var(--spacing-lg);
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.tree-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tree-svg path {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.tree-svg path.connection-line.locked {
|
||||
stroke: var(--color-border-dark);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.tree-svg path.connection-line.available {
|
||||
stroke: var(--color-accent-cyan);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tree-svg path.connection-line.researched {
|
||||
stroke: var(--color-accent-gold);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tree-nodes {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--spacing-xl);
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-lg);
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.tier-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tier-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Node */
|
||||
.node {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
background: var(--color-bg-panel);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-normal);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.node.locked {
|
||||
border-color: var(--color-border-dark);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.node.available {
|
||||
border-color: var(--color-accent-cyan);
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
box-shadow: 0 0 10px rgba(0, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.node.researched {
|
||||
border-color: var(--color-accent-gold);
|
||||
background: rgba(255, 215, 0, 0.2);
|
||||
box-shadow: 0 0 15px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.node-cost {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-accent-gold);
|
||||
}
|
||||
|
||||
/* Inspector Panel */
|
||||
.inspector {
|
||||
grid-area: inspector;
|
||||
background: var(--color-bg-panel);
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.inspector-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
border-bottom: var(--border-width-medium) solid
|
||||
var(--color-border-default);
|
||||
padding-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.inspector-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
background: var(--color-bg-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-4xl);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.inspector-name {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-align: center;
|
||||
color: var(--color-accent-cyan);
|
||||
}
|
||||
|
||||
.inspector-description {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inspector-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-bg-card);
|
||||
border: var(--border-width-thin) solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-badge.locked {
|
||||
background: var(--color-border-dark);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-badge.available {
|
||||
background: var(--color-accent-cyan);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-badge.researched {
|
||||
background: var(--color-accent-gold);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.research-button {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
background: var(--color-accent-cyan);
|
||||
color: #000;
|
||||
border: var(--border-width-medium) solid var(--color-accent-cyan);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.research-button:hover:not(:disabled) {
|
||||
background: var(--color-accent-cyan-dark);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.research-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
selectedNodeId: { type: String },
|
||||
availableCores: { type: Number },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selectedNodeId = null;
|
||||
this.availableCores = 0;
|
||||
this.researchManager = null;
|
||||
this._boundHandleResearchComplete = this._handleResearchComplete.bind(this);
|
||||
this._boundHandleWalletUpdate = this._handleWalletUpdate.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._loadData();
|
||||
window.addEventListener("research-complete", this._boundHandleResearchComplete);
|
||||
window.addEventListener("wallet-updated", this._boundHandleWalletUpdate);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("research-complete", this._boundHandleResearchComplete);
|
||||
window.removeEventListener("wallet-updated", this._boundHandleWalletUpdate);
|
||||
}
|
||||
|
||||
async _loadData() {
|
||||
// Get research manager from gameStateManager
|
||||
if (gameStateManager.researchManager) {
|
||||
this.researchManager = gameStateManager.researchManager;
|
||||
await this.researchManager.load();
|
||||
this._updateAvailableCores();
|
||||
this.requestUpdate();
|
||||
// Update connections after render
|
||||
this.updateComplete.then(() => {
|
||||
this._updateConnections();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_updateAvailableCores() {
|
||||
if (gameStateManager.hubStash) {
|
||||
this.availableCores =
|
||||
gameStateManager.hubStash.currency.ancientCores || 0;
|
||||
if (this.researchManager) {
|
||||
this.researchManager.updateAvailableCores(this.availableCores);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_handleWalletUpdate() {
|
||||
this._updateAvailableCores();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_handleResearchComplete() {
|
||||
this._updateAvailableCores();
|
||||
this.requestUpdate();
|
||||
// Update connections after state change
|
||||
this.updateComplete.then(() => {
|
||||
this._updateConnections();
|
||||
});
|
||||
}
|
||||
|
||||
_selectNode(nodeId) {
|
||||
this.selectedNodeId = nodeId;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_dispatchClose() {
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
}
|
||||
|
||||
async _researchNode() {
|
||||
if (!this.selectedNodeId || !this.researchManager) return;
|
||||
|
||||
const result = await this.researchManager.unlockNode(
|
||||
this.selectedNodeId,
|
||||
this.availableCores
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Deduct cores from hub stash
|
||||
if (gameStateManager.hubStash) {
|
||||
const node = this.researchManager.getNode(this.selectedNodeId);
|
||||
gameStateManager.hubStash.currency.ancientCores -= node.cost;
|
||||
await gameStateManager._saveHubStash();
|
||||
this._updateAvailableCores();
|
||||
|
||||
// Dispatch event to update wallet display
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("wallet-updated", {
|
||||
detail: { wallet: gameStateManager.hubStash.currency },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatch research event
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("research-complete", {
|
||||
detail: { nodeId: this.selectedNodeId },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
alert(result.error || "Failed to research node");
|
||||
}
|
||||
}
|
||||
|
||||
_updateConnections() {
|
||||
if (!this.researchManager) return;
|
||||
|
||||
const trees = ["LOGISTICS", "INTEL", "FIELD_OPS"];
|
||||
trees.forEach((treeType) => {
|
||||
const treeView = this.shadowRoot?.querySelector(
|
||||
`.tree-view[data-tree="${treeType}"]`
|
||||
);
|
||||
if (!treeView) return;
|
||||
|
||||
const svg = treeView.querySelector(".tree-svg");
|
||||
if (!svg) return;
|
||||
|
||||
// Clear existing paths
|
||||
svg.innerHTML = "";
|
||||
|
||||
const nodes = this.researchManager.getNodesByTree(treeType);
|
||||
const containerRect = treeView.getBoundingClientRect();
|
||||
|
||||
// Draw connections for each node to its prerequisites
|
||||
nodes.forEach((node) => {
|
||||
if (node.prerequisites.length === 0) return;
|
||||
|
||||
const nodeElement = this.shadowRoot?.querySelector(
|
||||
`[data-node-id="${node.id}"]`
|
||||
);
|
||||
if (!nodeElement) return;
|
||||
|
||||
const nodeRect = nodeElement.getBoundingClientRect();
|
||||
const nodeCenter = {
|
||||
x: nodeRect.left + nodeRect.width / 2 - containerRect.left,
|
||||
y: nodeRect.top + nodeRect.height / 2 - containerRect.top,
|
||||
};
|
||||
|
||||
node.prerequisites.forEach((prereqId) => {
|
||||
const prereqElement = this.shadowRoot?.querySelector(
|
||||
`[data-node-id="${prereqId}"]`
|
||||
);
|
||||
if (!prereqElement) return;
|
||||
|
||||
const prereqRect = prereqElement.getBoundingClientRect();
|
||||
const prereqCenter = {
|
||||
x: prereqRect.left + prereqRect.width / 2 - containerRect.left,
|
||||
y: prereqRect.top + prereqRect.height / 2 - containerRect.top,
|
||||
};
|
||||
|
||||
// Determine line style based on node status
|
||||
const status = this.researchManager.getNodeStatus(node.id);
|
||||
const pathClass = `connection-line ${status.toLowerCase()}`;
|
||||
|
||||
// Create horizontal connection with vertical offset
|
||||
const midY = (prereqCenter.y + nodeCenter.y) / 2;
|
||||
const pathData = `M ${prereqCenter.x} ${prereqCenter.y} L ${prereqCenter.x} ${midY} L ${nodeCenter.x} ${midY} L ${nodeCenter.x} ${nodeCenter.y}`;
|
||||
|
||||
const path = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"path"
|
||||
);
|
||||
path.setAttribute("d", pathData);
|
||||
path.setAttribute("class", pathClass);
|
||||
svg.appendChild(path);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_renderTree(treeType) {
|
||||
if (!this.researchManager) return html``;
|
||||
|
||||
const nodes = this.researchManager.getNodesByTree(treeType);
|
||||
const nodesByTier = {};
|
||||
nodes.forEach((node) => {
|
||||
if (!nodesByTier[node.tier]) {
|
||||
nodesByTier[node.tier] = [];
|
||||
}
|
||||
nodesByTier[node.tier].push(node);
|
||||
});
|
||||
|
||||
const treeClass = treeType.toLowerCase().replace("_", "-");
|
||||
|
||||
return html`
|
||||
<div class="tree-section">
|
||||
<div class="tree-header ${treeClass}">
|
||||
<h3 class="tree-title ${treeClass}">${treeType.replace("_", " ")}</h3>
|
||||
</div>
|
||||
<div class="tree-view" data-tree="${treeType}">
|
||||
<svg class="tree-svg"></svg>
|
||||
<div class="tree-nodes">
|
||||
${Object.keys(nodesByTier)
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map(
|
||||
(tier) => html`
|
||||
<div class="tier-column">
|
||||
<div class="tier-label">Tier ${tier}</div>
|
||||
${nodesByTier[tier].map(
|
||||
(node) => this._renderNode(node, treeClass)
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderNode(node, treeClass) {
|
||||
const status = this.researchManager
|
||||
? this.researchManager.getNodeStatus(node.id)
|
||||
: "LOCKED";
|
||||
const isSelected = this.selectedNodeId === node.id;
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="node ${status.toLowerCase()} ${isSelected ? "selected" : ""}"
|
||||
data-node-id="${node.id}"
|
||||
@click=${() => this._selectNode(node.id)}
|
||||
?disabled=${status === "LOCKED"}
|
||||
>
|
||||
<div class="node-icon">⚙️</div>
|
||||
<div class="node-cost">${node.cost}</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderInspector() {
|
||||
if (!this.selectedNodeId || !this.researchManager) {
|
||||
return html`
|
||||
<div class="inspector">
|
||||
<div class="empty-state">
|
||||
<p>Select a research node to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const node = this.researchManager.getNode(this.selectedNodeId);
|
||||
if (!node) {
|
||||
return html`
|
||||
<div class="inspector">
|
||||
<div class="empty-state">
|
||||
<p>Node not found</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const status = this.researchManager.getNodeStatus(node.id);
|
||||
const canResearch =
|
||||
status === "AVAILABLE" && this.availableCores >= node.cost;
|
||||
|
||||
return html`
|
||||
<div class="inspector">
|
||||
<div class="inspector-header">
|
||||
<div class="inspector-icon">⚙️</div>
|
||||
<div class="inspector-name">${node.name}</div>
|
||||
<div class="inspector-description">${node.description}</div>
|
||||
</div>
|
||||
<div class="inspector-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Cost:</span>
|
||||
<span class="detail-value">${node.cost} Ancient Cores</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Status:</span>
|
||||
<span class="status-badge ${status.toLowerCase()}">${status}</span>
|
||||
</div>
|
||||
${node.prerequisites.length > 0
|
||||
? html`
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Requires:</span>
|
||||
<span class="detail-value"
|
||||
>${node.prerequisites.length} prerequisite(s)</span
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
class="research-button"
|
||||
@click=${this._researchNode}
|
||||
?disabled=${!canResearch}
|
||||
>
|
||||
${status === "RESEARCHED" ? "RESEARCHED" : "RESEARCH"}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>Ancient Archive</h2>
|
||||
<div class="cores-display">
|
||||
<span class="cores-icon">⚙️</span>
|
||||
<span>${this.availableCores} Ancient Cores</span>
|
||||
</div>
|
||||
<button class="close-button" @click=${this._dispatchClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="trees-container">
|
||||
${this._renderTree("LOGISTICS")} ${this._renderTree("INTEL")}
|
||||
${this._renderTree("FIELD_OPS")}
|
||||
</div>
|
||||
${this._renderInspector()}
|
||||
`;
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("selectedNodeId") || changedProperties.has("availableCores")) {
|
||||
this.updateComplete.then(() => {
|
||||
this._updateConnections();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("research-screen", ResearchScreen);
|
||||
|
||||
|
|
@ -371,18 +371,7 @@ export class HubScreen extends LitElement {
|
|||
break;
|
||||
case "RESEARCH":
|
||||
overlayComponent = html`
|
||||
<div
|
||||
style="background: rgba(20, 20, 30, 0.95); padding: 30px; border: 2px solid #555; max-width: 800px;"
|
||||
>
|
||||
<h2 style="margin-top: 0; color: #ffd700;">RESEARCH</h2>
|
||||
<p>Research coming soon...</p>
|
||||
<button
|
||||
@click=${this._closeOverlay}
|
||||
style="margin-top: 20px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<research-screen @close=${this._closeOverlay}></research-screen>
|
||||
`;
|
||||
break;
|
||||
case "SYSTEM":
|
||||
|
|
@ -432,6 +421,10 @@ export class HubScreen extends LitElement {
|
|||
if (this.activeOverlay === "BARRACKS") {
|
||||
import("./BarracksScreen.js").catch(console.error);
|
||||
}
|
||||
// Trigger async import when RESEARCH overlay is opened
|
||||
if (this.activeOverlay === "RESEARCH") {
|
||||
import("./ResearchScreen.js").catch(console.error);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="background"></div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { LitElement, html, css } from 'lit';
|
||||
import { theme, buttonStyles, cardStyles } from './styles/theme.js';
|
||||
import { gameStateManager } from '../core/GameStateManager.js';
|
||||
|
||||
// Import Tier 1 Class Definitions
|
||||
import vanguardDef from '../assets/data/classes/vanguard.json' with { type: 'json' };
|
||||
import weaverDef from '../assets/data/classes/aether_weaver.json' with { type: 'json' };
|
||||
import scavengerDef from '../assets/data/classes/scavenger.json' with { type: 'json' };
|
||||
import tinkerDef from '../assets/data/classes/tinker.json' with { type: 'json' };
|
||||
import custodianDef from '../assets/data/classes/custodian.json' with { type: 'json' };
|
||||
// Class definitions will be lazy-loaded when component connects
|
||||
|
||||
// UI Metadata Mapping
|
||||
const CLASS_METADATA = {
|
||||
|
|
@ -42,7 +38,8 @@ const CLASS_METADATA = {
|
|||
}
|
||||
};
|
||||
|
||||
const RAW_TIER_1_CLASSES = [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef];
|
||||
// Class definitions loaded lazily
|
||||
let RAW_TIER_1_CLASSES = null;
|
||||
|
||||
export class TeamBuilder extends LitElement {
|
||||
static get styles() {
|
||||
|
|
@ -272,9 +269,30 @@ export class TeamBuilder extends LitElement {
|
|||
this._poolExplicitlySet = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._initializeData();
|
||||
await this._initializeData();
|
||||
|
||||
// Listen for unlock changes to refresh the class list
|
||||
this._boundHandleUnlocksChanged = this._handleUnlocksChanged.bind(this);
|
||||
window.addEventListener('classes-unlocked', this._boundHandleUnlocksChanged);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._boundHandleUnlocksChanged) {
|
||||
window.removeEventListener('classes-unlocked', this._boundHandleUnlocksChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unlock changes by refreshing the class list.
|
||||
*/
|
||||
async _handleUnlocksChanged() {
|
||||
if (this.mode === 'DRAFT') {
|
||||
await this._initializeData();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -290,7 +308,7 @@ export class TeamBuilder extends LitElement {
|
|||
/**
|
||||
* Configures the component based on provided data.
|
||||
*/
|
||||
_initializeData() {
|
||||
async _initializeData() {
|
||||
// 1. If availablePool was explicitly set (from mission selection), use ROSTER mode.
|
||||
// This happens when opening from mission selection - we want to show roster even if all units are injured.
|
||||
if (this._poolExplicitlySet) {
|
||||
|
|
@ -300,13 +318,48 @@ export class TeamBuilder extends LitElement {
|
|||
}
|
||||
|
||||
// 2. Default: Draft Mode (New Game)
|
||||
// Populate with Tier 1 classes
|
||||
// Populate with Tier 1 classes and check unlock status
|
||||
this.mode = 'DRAFT';
|
||||
|
||||
// Lazy-load class definitions if not already loaded
|
||||
if (!RAW_TIER_1_CLASSES) {
|
||||
const [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef] = await Promise.all([
|
||||
import('../assets/data/classes/vanguard.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/classes/aether_weaver.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/classes/scavenger.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/classes/tinker.json', { with: { type: 'json' } }).then(m => m.default),
|
||||
import('../assets/data/classes/custodian.json', { with: { type: 'json' } }).then(m => m.default)
|
||||
]);
|
||||
RAW_TIER_1_CLASSES = [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef];
|
||||
}
|
||||
|
||||
// Load unlocked classes from persistence
|
||||
let unlockedClasses = [];
|
||||
try {
|
||||
if (gameStateManager?.persistence) {
|
||||
unlockedClasses = await gameStateManager.persistence.loadUnlocks();
|
||||
} else {
|
||||
// Fallback to localStorage if persistence not available
|
||||
const stored = localStorage.getItem('aether_shards_unlocks');
|
||||
if (stored) {
|
||||
unlockedClasses = JSON.parse(stored);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load unlocks:', e);
|
||||
}
|
||||
|
||||
// Define which classes are unlocked by default (starter classes)
|
||||
// Note: CLASS_TINKER is unlocked by the tutorial mission, so it's not in the default list
|
||||
const defaultUnlocked = ['CLASS_VANGUARD', 'CLASS_WEAVER'];
|
||||
|
||||
this.availablePool = RAW_TIER_1_CLASSES.map(cls => {
|
||||
const meta = CLASS_METADATA[cls.id] || {};
|
||||
return { ...cls, ...meta, unlocked: true };
|
||||
// Check if class is unlocked (either default or in unlocked list)
|
||||
const isUnlocked = defaultUnlocked.includes(cls.id) || unlockedClasses.includes(cls.id);
|
||||
return { ...cls, ...meta, unlocked: isUnlocked };
|
||||
});
|
||||
console.log("TeamBuilder: Initializing Draft Mode");
|
||||
console.log("TeamBuilder: Initializing Draft Mode", { unlockedClasses, availablePool: this.availablePool.map(c => ({ id: c.id, unlocked: c.unlocked })) });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ describe("Manager: MissionManager", () => {
|
|||
sinon.restore();
|
||||
});
|
||||
|
||||
it("CoA 1: Should initialize with tutorial mission registered", () => {
|
||||
it("CoA 1: Should initialize with tutorial mission registered", async () => {
|
||||
await manager._ensureMissionsLoaded();
|
||||
expect(manager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true;
|
||||
expect(manager.activeMissionId).to.be.null;
|
||||
expect(manager.completedMissions).to.be.instanceof(Set);
|
||||
|
|
@ -54,14 +55,15 @@ describe("Manager: MissionManager", () => {
|
|||
expect(manager.missionRegistry.get("MISSION_TEST_01")).to.equal(newMission);
|
||||
});
|
||||
|
||||
it("CoA 3: getActiveMission should return tutorial if no active mission", () => {
|
||||
const mission = manager.getActiveMission();
|
||||
it("CoA 3: getActiveMission should return tutorial if no active mission", async () => {
|
||||
await manager._ensureMissionsLoaded();
|
||||
const mission = await manager.getActiveMission();
|
||||
|
||||
expect(mission).to.exist;
|
||||
expect(mission.id).to.equal("MISSION_TUTORIAL_01");
|
||||
});
|
||||
|
||||
it("CoA 4: getActiveMission should return active mission if set", () => {
|
||||
it("CoA 4: getActiveMission should return active mission if set", async () => {
|
||||
const testMission = {
|
||||
id: "MISSION_TEST_01",
|
||||
config: { title: "Test" },
|
||||
|
|
@ -70,13 +72,14 @@ describe("Manager: MissionManager", () => {
|
|||
manager.registerMission(testMission);
|
||||
manager.activeMissionId = "MISSION_TEST_01";
|
||||
|
||||
const mission = manager.getActiveMission();
|
||||
const mission = await manager.getActiveMission();
|
||||
|
||||
expect(mission.id).to.equal("MISSION_TEST_01");
|
||||
});
|
||||
|
||||
it("CoA 5: setupActiveMission should initialize objectives", () => {
|
||||
const mission = manager.getActiveMission();
|
||||
it("CoA 5: setupActiveMission should initialize objectives", async () => {
|
||||
await manager._ensureMissionsLoaded();
|
||||
const mission = await manager.getActiveMission();
|
||||
mission.objectives = {
|
||||
primary: [
|
||||
{ type: "ELIMINATE_ALL", target_count: 5 },
|
||||
|
|
@ -84,7 +87,7 @@ describe("Manager: MissionManager", () => {
|
|||
],
|
||||
};
|
||||
|
||||
manager.setupActiveMission();
|
||||
await manager.setupActiveMission();
|
||||
|
||||
expect(manager.currentObjectives).to.have.length(2);
|
||||
expect(manager.currentObjectives[0].current).to.equal(0);
|
||||
|
|
@ -708,5 +711,49 @@ describe("Manager: MissionManager", () => {
|
|||
expect(manager.failureConditions[1].type).to.equal("VIP_DEATH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lazy Loading", () => {
|
||||
it("CoA 31: Should lazy-load missions on first access", async () => {
|
||||
// Create a fresh manager to test lazy loading
|
||||
const freshManager = new MissionManager(mockPersistence);
|
||||
|
||||
// Initially, registry should be empty (missions not loaded)
|
||||
expect(freshManager.missionRegistry.size).to.equal(0);
|
||||
|
||||
// Trigger lazy loading
|
||||
await freshManager._ensureMissionsLoaded();
|
||||
|
||||
// Now missions should be loaded
|
||||
expect(freshManager.missionRegistry.size).to.be.greaterThan(0);
|
||||
expect(freshManager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true;
|
||||
});
|
||||
|
||||
it("CoA 32: Should not reload missions if already loaded", async () => {
|
||||
// Load missions first time
|
||||
await manager._ensureMissionsLoaded();
|
||||
const firstSize = manager.missionRegistry.size;
|
||||
|
||||
// Load again - should not duplicate
|
||||
await manager._ensureMissionsLoaded();
|
||||
const secondSize = manager.missionRegistry.size;
|
||||
|
||||
expect(firstSize).to.equal(secondSize);
|
||||
});
|
||||
|
||||
it("CoA 33: Should handle lazy loading errors gracefully", async () => {
|
||||
// Create a manager with a failing persistence (if needed)
|
||||
const freshManager = new MissionManager(mockPersistence);
|
||||
|
||||
// Should not throw even if missions fail to load
|
||||
try {
|
||||
await freshManager._ensureMissionsLoaded();
|
||||
// If we get here, it handled gracefully
|
||||
expect(true).to.be.true;
|
||||
} catch (error) {
|
||||
// If error occurs, it should be handled
|
||||
expect(error).to.exist;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -395,5 +395,248 @@ describe("UI: MissionBoard", () => {
|
|||
expect(typeBadges[3].classList.contains("PROCEDURAL")).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mission Prerequisites", () => {
|
||||
it("should show mission as available when no prerequisites", async () => {
|
||||
const mission = {
|
||||
id: "MISSION_01",
|
||||
type: "STORY",
|
||||
config: { title: "Test Mission", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
mockMissionManager.missionRegistry.set(mission.id, mission);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCard = queryShadow(".mission-card");
|
||||
expect(missionCard).to.exist;
|
||||
expect(missionCard.classList.contains("locked")).to.be.false;
|
||||
});
|
||||
|
||||
it("should show mission as locked when prerequisites not met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "SIDE_QUEST",
|
||||
config: { title: "First Mission", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "SIDE_QUEST",
|
||||
config: {
|
||||
title: "Second Mission",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(2);
|
||||
|
||||
const mission2Card = Array.from(missionCards).find((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
||||
);
|
||||
expect(mission2Card).to.exist;
|
||||
expect(mission2Card.classList.contains("locked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should show mission as available when prerequisites are met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "SIDE_QUEST",
|
||||
config: { title: "First Mission", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "SIDE_QUEST",
|
||||
config: {
|
||||
title: "Second Mission",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
mockMissionManager.completedMissions.add("MISSION_01");
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
const mission2Card = Array.from(missionCards).find((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
||||
);
|
||||
expect(mission2Card).to.exist;
|
||||
expect(mission2Card.classList.contains("locked")).to.be.false;
|
||||
});
|
||||
|
||||
it("should display prerequisite requirements for locked missions", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "SIDE_QUEST",
|
||||
config: { title: "First Mission", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "SIDE_QUEST",
|
||||
config: {
|
||||
title: "Second Mission",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
const mission2Card = Array.from(missionCards).find((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.includes("Second Mission")
|
||||
);
|
||||
expect(mission2Card).to.exist;
|
||||
expect(mission2Card.textContent).to.include("Requires");
|
||||
expect(mission2Card.textContent).to.include("First Mission");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mission Visibility", () => {
|
||||
it("should hide STORY missions when prerequisites not met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "STORY",
|
||||
config: { title: "First Story", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "STORY",
|
||||
config: {
|
||||
title: "Second Story",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(1);
|
||||
const titles = Array.from(missionCards).map((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.trim()
|
||||
);
|
||||
expect(titles).to.include("First Story");
|
||||
expect(titles).to.not.include("Second Story");
|
||||
});
|
||||
|
||||
it("should show SIDE_QUEST missions as locked when prerequisites not met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "SIDE_QUEST",
|
||||
config: { title: "First Quest", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "SIDE_QUEST",
|
||||
config: {
|
||||
title: "Second Quest",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(2);
|
||||
const titles = Array.from(missionCards).map((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.trim()
|
||||
);
|
||||
expect(titles).to.include("First Quest");
|
||||
expect(titles).to.include("Second Quest");
|
||||
|
||||
const mission2Card = Array.from(missionCards).find((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.includes("Second Quest")
|
||||
);
|
||||
expect(mission2Card.classList.contains("locked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should show STORY mission when prerequisites are met", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "STORY",
|
||||
config: { title: "First Story", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "STORY",
|
||||
config: {
|
||||
title: "Second Story",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
mockMissionManager.completedMissions.add("MISSION_01");
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(2);
|
||||
const titles = Array.from(missionCards).map((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.trim()
|
||||
);
|
||||
expect(titles).to.include("First Story");
|
||||
expect(titles).to.include("Second Story");
|
||||
});
|
||||
|
||||
it("should respect explicit visibility_when_locked setting", async () => {
|
||||
const mission1 = {
|
||||
id: "MISSION_01",
|
||||
type: "STORY",
|
||||
config: { title: "First Story", description: "Test" },
|
||||
rewards: {},
|
||||
};
|
||||
const mission2 = {
|
||||
id: "MISSION_02",
|
||||
type: "STORY",
|
||||
config: {
|
||||
title: "Second Story",
|
||||
description: "Test",
|
||||
prerequisites: ["MISSION_01"],
|
||||
visibility_when_locked: "locked", // Override default hidden behavior
|
||||
},
|
||||
rewards: {},
|
||||
};
|
||||
|
||||
mockMissionManager.missionRegistry.set(mission1.id, mission1);
|
||||
mockMissionManager.missionRegistry.set(mission2.id, mission2);
|
||||
await waitForUpdate();
|
||||
|
||||
const missionCards = queryShadowAll(".mission-card");
|
||||
expect(missionCards.length).to.equal(2);
|
||||
const titles = Array.from(missionCards).map((card) =>
|
||||
card.querySelector(".mission-title")?.textContent.trim()
|
||||
);
|
||||
expect(titles).to.include("Second Story");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue