From 930b1c743804709225347e693cb0682a49316026 Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Fri, 2 Jan 2026 22:02:27 -0800 Subject: [PATCH] feat: Implement core game loop, state management, mission generation, and add initial mission data with debug commands. --- build.js | 5 + specs/Procedural_Missions.spec.md | 4 +- src/assets/data/manifest.json | 18 + src/assets/data/missions/mission-schema.md | 2 +- src/assets/data/missions/mission.d.ts | 2 +- .../data/missions/mission_story_11.json | 15 +- .../data/missions/mission_story_16.json | 15 +- .../data/missions/mission_story_17.json | 18 +- .../data/missions/mission_story_18.json | 15 +- .../data/missions/mission_story_20.json | 14 +- .../data/missions/mission_story_21.json | 18 +- .../data/missions/mission_story_22.json | 16 +- .../data/missions/mission_story_23.json | 14 +- .../data/missions/mission_story_25.json | 13 +- .../data/missions/mission_story_27.json | 14 +- .../data/missions/mission_story_28.json | 14 +- .../data/missions/mission_story_29.json | 16 +- src/assets/data/units/enemy_commander.json | 14 + src/core/DebugCommands.js | 359 +++++++++++++----- src/core/GameLoop.js | 231 ++++++----- src/core/GameStateManager.js | 9 + src/index.html | 2 +- src/index.js | 19 +- src/systems/MissionGenerator.js | 30 +- src/ui/game-viewport.js | 30 +- src/utils/generate_manifest.js | 37 ++ src/utils/verify_missions.js | 66 ++++ test/core/CombatStateSpec.test.js | 44 +++ test/core/GameLoop/helpers.js | 118 ++++++ .../GameStateManager/hub-integration.test.js | 17 +- test/systems/MissionGenerator.test.js | 62 +-- test/ui/mission-board.test.js | 86 +++-- 32 files changed, 1022 insertions(+), 315 deletions(-) create mode 100644 src/assets/data/manifest.json create mode 100644 src/assets/data/units/enemy_commander.json create mode 100644 src/utils/generate_manifest.js create mode 100644 src/utils/verify_missions.js diff --git a/build.js b/build.js index 04965bd..5256980 100644 --- a/build.js +++ b/build.js @@ -1,5 +1,7 @@ import { build } from "esbuild"; import { copyFileSync, mkdirSync, readdirSync } from "fs"; +import { generateManifest } from "./src/utils/generate_manifest.js"; + import { dirname, join } from "path"; import { fileURLToPath } from "url"; @@ -78,6 +80,9 @@ copyFileSync("src/index.html", "dist/index.html"); // Copy images copyImages("src", "dist"); +// Generate manifest +generateManifest(); + // Copy assets (JSON, markdown, TypeScript definitions) copyAssets("src/assets", "dist/assets"); diff --git a/specs/Procedural_Missions.spec.md b/specs/Procedural_Missions.spec.md index 6d7131f..0ad0adb 100644 --- a/specs/Procedural_Missions.spec.md +++ b/specs/Procedural_Missions.spec.md @@ -16,7 +16,7 @@ Missions use a context-aware "Operation: [Adjective] [Noun] [Numeral]" format. - **Skirmish Nouns:** _Thunder, Storm, Iron, Fury, Shield, Hammer._ - **Salvage Nouns:** _Cache, Vault, Echo, Spark, Harvest, Trove._ -- **Assassination Nouns:** _Viper, Dagger, Fang, Night, Razor, Sting._ +- **Eliminate Unit Nouns:** _Viper, Dagger, Fang, Night, Razor, Sting._ - **Recon Nouns:** _Eye, Watch, Path, Horizon, Whisper, Scope._ ### **B. Narrative Synthesis (Dynamic Context)** @@ -53,7 +53,7 @@ _The Generator creates these JSON blobs and registers them with the NarrativeMan - **Config:** High Cover Density. - **Reward Bonus:** Items/Materials. -#### **3. Assassination (Boss Focus)** +#### **3. Eliminate Unit (Boss Focus)** - **Objective:** ELIMINATE_UNIT (Named Elite). - **Icon:** assets/icons/mission_skull.png. diff --git a/src/assets/data/manifest.json b/src/assets/data/manifest.json new file mode 100644 index 0000000..4d3f239 --- /dev/null +++ b/src/assets/data/manifest.json @@ -0,0 +1,18 @@ +{ + "classes": [ + "classes/aether_sentinel.json", + "classes/aether_weaver.json", + "classes/arcane_scourge.json", + "classes/battle_mage.json", + "classes/custodian.json", + "classes/field_engineer.json", + "classes/sapper.json", + "classes/scavenger.json", + "classes/tinker.json", + "classes/vanguard.json" + ], + "units": [ + "units/enemy_commander.json", + "units/mule_bot.json" + ] +} \ No newline at end of file diff --git a/src/assets/data/missions/mission-schema.md b/src/assets/data/missions/mission-schema.md index 973d3a7..700d9d8 100644 --- a/src/assets/data/missions/mission-schema.md +++ b/src/assets/data/missions/mission-schema.md @@ -161,7 +161,7 @@ This example utilizes every capability of the system. ### **Procedural & Dynamic Fields** -- **boss_config** (in `config`): Used for Assassination missions to define the target. +- **boss_config** (in `config`): Used for Eliminate Unit missions to define the target. - **target_def_id**: Enemy Definition ID for the boss. - **name**: Specific name override for the boss. - **stats**: Object containing multipliers for hp and attack. diff --git a/src/assets/data/missions/mission.d.ts b/src/assets/data/missions/mission.d.ts index f3f8bb6..4023915 100644 --- a/src/assets/data/missions/mission.d.ts +++ b/src/assets/data/missions/mission.d.ts @@ -51,7 +51,7 @@ export interface MissionConfig { * - "locked": Mission shows as locked with requirements visible (default for SIDE_QUEST, PROCEDURAL) */ visibility_when_locked?: "hidden" | "locked"; - /** Boss configuration for Assassination missions (Procedural only) */ + /** Boss configuration for Eliminate Unit missions (Procedural only) */ boss_config?: BossConfig; } diff --git a/src/assets/data/missions/mission_story_11.json b/src/assets/data/missions/mission_story_11.json index 2db4fed..6714ac6 100644 --- a/src/assets/data/missions/mission_story_11.json +++ b/src/assets/data/missions/mission_story_11.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_RUSTING_WASTES", - "hazards": ["HAZARD_NEST_SPORES"] + "hazards": [ + "HAZARD_NEST_SPORES" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "HIGH" + } }, "narrative": { "intro_sequence": "NARRATIVE_11_INTRO", @@ -33,10 +39,13 @@ }, "rewards": { "guaranteed": { - "unlocks": ["BLUEPRINT_HEAVY_PLATE_MK2", "MISSION_STORY_12"] + "unlocks": [ + "BLUEPRINT_HEAVY_PLATE_MK2", + "MISSION_STORY_12" + ] }, "faction_reputation": { "IRON_LEGION": 50 } } -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_16.json b/src/assets/data/missions/mission_story_16.json index 2c53c3a..4155e9c 100644 --- a/src/assets/data/missions/mission_story_16.json +++ b/src/assets/data/missions/mission_story_16.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_FUNGAL_CAVES", - "hazards": ["HAZARD_REGROWING_VINES"] + "hazards": [ + "HAZARD_REGROWING_VINES" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "MEDIUM" + } }, "narrative": { "intro_sequence": "NARRATIVE_16_INTRO", @@ -29,7 +35,10 @@ }, "rewards": { "guaranteed": { - "unlocks": ["BLUEPRINT_REGEN_RING", "MISSION_STORY_17"] + "unlocks": [ + "BLUEPRINT_REGEN_RING", + "MISSION_STORY_17" + ] }, "faction_reputation": { "SILENT_SANCTUARY": 40 @@ -41,4 +50,4 @@ "placement_strategy": "random_walkable" } ] -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_17.json b/src/assets/data/missions/mission_story_17.json index 2abf691..5f77930 100644 --- a/src/assets/data/missions/mission_story_17.json +++ b/src/assets/data/missions/mission_story_17.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_CRYSTAL_SPIRES", - "hazards": ["HAZARD_FOG"] + "hazards": [ + "HAZARD_FOG" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "LOW" + } }, "narrative": { "intro_sequence": "NARRATIVE_17_INTRO", @@ -29,8 +35,12 @@ }, "rewards": { "guaranteed": { - "items": ["ITEM_SPIRIT_LANTERN"], - "unlocks": ["MISSION_STORY_18"] + "items": [ + "ITEM_SPIRIT_LANTERN" + ], + "unlocks": [ + "MISSION_STORY_18" + ] }, "faction_reputation": { "SILENT_SANCTUARY": 50 @@ -42,4 +52,4 @@ "placement_strategy": "random_walkable" } ] -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_18.json b/src/assets/data/missions/mission_story_18.json index 79bfeea..f50f26f 100644 --- a/src/assets/data/missions/mission_story_18.json +++ b/src/assets/data/missions/mission_story_18.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_VOID_SEEP", - "hazards": ["HAZARD_SPORE_VENTS"] + "hazards": [ + "HAZARD_SPORE_VENTS" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "ARENA" + } }, "narrative": { "intro_sequence": "NARRATIVE_18_INTRO", @@ -28,10 +34,13 @@ }, "rewards": { "guaranteed": { - "unlocks": ["CLASS_CUSTODIAN_MASTERY", "MISSION_STORY_19"] + "unlocks": [ + "CLASS_CUSTODIAN_MASTERY", + "MISSION_STORY_19" + ] }, "faction_reputation": { "SILENT_SANCTUARY": 75 } } -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_20.json b/src/assets/data/missions/mission_story_20.json index 6fbbb41..00ca283 100644 --- a/src/assets/data/missions/mission_story_20.json +++ b/src/assets/data/missions/mission_story_20.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_RUSTING_WASTES", - "hazards": ["HAZARD_LIVE_WIRES"] + "hazards": [ + "HAZARD_LIVE_WIRES" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "HIGH" + } }, "narrative": { "intro_sequence": "NARRATIVE_20_INTRO", @@ -31,7 +37,9 @@ "currency": { "aether_shards": 800 }, - "unlocks": ["MISSION_STORY_21"] + "unlocks": [ + "MISSION_STORY_21" + ] } }, "mission_objects": [ @@ -40,4 +48,4 @@ "placement_strategy": "random_walkable" } ] -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_21.json b/src/assets/data/missions/mission_story_21.json index 8937b2e..3b6c41a 100644 --- a/src/assets/data/missions/mission_story_21.json +++ b/src/assets/data/missions/mission_story_21.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_CRYSTAL_SPIRES", - "hazards": ["HAZARD_GRAVITY_FLUX"] + "hazards": [ + "HAZARD_GRAVITY_FLUX" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "LOW" + } }, "narrative": { "intro_sequence": "NARRATIVE_21_INTRO", @@ -28,8 +34,12 @@ }, "rewards": { "guaranteed": { - "items": ["ITEM_AETHER_LENS"], - "unlocks": ["MISSION_STORY_22"] + "items": [ + "ITEM_AETHER_LENS" + ], + "unlocks": [ + "MISSION_STORY_22" + ] } } -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_22.json b/src/assets/data/missions/mission_story_22.json index 2c17f94..ae2c712 100644 --- a/src/assets/data/missions/mission_story_22.json +++ b/src/assets/data/missions/mission_story_22.json @@ -9,7 +9,11 @@ "icon": "assets/icons/mission_skull.png" }, "biome": { - "type": "BIOME_FUNGAL_CAVES" + "type": "BIOME_FUNGAL_CAVES", + "generator_config": { + "seed_type": "RANDOM", + "density": "MEDIUM" + } }, "narrative": { "intro_sequence": "NARRATIVE_22_INTRO", @@ -27,8 +31,12 @@ }, "rewards": { "guaranteed": { - "items": ["ITEM_CORRUPTED_IDOL"], - "unlocks": ["MISSION_STORY_23"] + "items": [ + "ITEM_CORRUPTED_IDOL" + ], + "unlocks": [ + "MISSION_STORY_23" + ] } } -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_23.json b/src/assets/data/missions/mission_story_23.json index 838ecc5..5964809 100644 --- a/src/assets/data/missions/mission_story_23.json +++ b/src/assets/data/missions/mission_story_23.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_CONTESTED_FRONTIER", - "hazards": ["HAZARD_FALLING_DEBRIS"] + "hazards": [ + "HAZARD_FALLING_DEBRIS" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "EXTREME" + } }, "narrative": { "intro_sequence": "NARRATIVE_23_INTRO", @@ -40,7 +46,9 @@ "currency": { "aether_shards": 900 }, - "unlocks": ["MISSION_STORY_24"] + "unlocks": [ + "MISSION_STORY_24" + ] } } -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_25.json b/src/assets/data/missions/mission_story_25.json index a3babda..9a21624 100644 --- a/src/assets/data/missions/mission_story_25.json +++ b/src/assets/data/missions/mission_story_25.json @@ -9,7 +9,11 @@ "icon": "assets/icons/mission_elevator.png" }, "biome": { - "type": "BIOME_VOID_SEEP" + "type": "BIOME_VOID_SEEP", + "generator_config": { + "seed_type": "RANDOM", + "density": "ARENA" + } }, "narrative": { "intro_sequence": "NARRATIVE_25_INTRO", @@ -31,7 +35,10 @@ }, "rewards": { "guaranteed": { - "unlocks": ["ACCESS_ACT_4", "MISSION_STORY_26"] + "unlocks": [ + "ACCESS_ACT_4", + "MISSION_STORY_26" + ] } } -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_27.json b/src/assets/data/missions/mission_story_27.json index 382affd..57063a6 100644 --- a/src/assets/data/missions/mission_story_27.json +++ b/src/assets/data/missions/mission_story_27.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_CRYSTAL_SPIRES", - "hazards": ["HAZARD_GRAVITY_FLUX_HARD"] + "hazards": [ + "HAZARD_GRAVITY_FLUX_HARD" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "LOW" + } }, "narrative": { "intro_sequence": "NARRATIVE_27_INTRO", @@ -35,7 +41,9 @@ "currency": { "ancient_cores": 2 }, - "unlocks": ["MISSION_STORY_28"] + "unlocks": [ + "MISSION_STORY_28" + ] } } -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_28.json b/src/assets/data/missions/mission_story_28.json index 22a67f5..8bd80df 100644 --- a/src/assets/data/missions/mission_story_28.json +++ b/src/assets/data/missions/mission_story_28.json @@ -10,7 +10,13 @@ }, "biome": { "type": "BIOME_FUNGAL_CAVES", - "hazards": ["HAZARD_TOXIC_ATMOSPHERE"] + "hazards": [ + "HAZARD_TOXIC_ATMOSPHERE" + ], + "generator_config": { + "seed_type": "RANDOM", + "density": "MEDIUM" + } }, "narrative": { "intro_sequence": "NARRATIVE_28_INTRO", @@ -31,7 +37,9 @@ "currency": { "ancient_cores": 2 }, - "unlocks": ["MISSION_STORY_29"] + "unlocks": [ + "MISSION_STORY_29" + ] } } -} +} \ No newline at end of file diff --git a/src/assets/data/missions/mission_story_29.json b/src/assets/data/missions/mission_story_29.json index e13ac72..6d3ca13 100644 --- a/src/assets/data/missions/mission_story_29.json +++ b/src/assets/data/missions/mission_story_29.json @@ -9,7 +9,11 @@ "icon": "assets/icons/mission_shadow.png" }, "biome": { - "type": "BIOME_VOID_SEEP" + "type": "BIOME_VOID_SEEP", + "generator_config": { + "seed_type": "RANDOM", + "density": "ARENA" + } }, "narrative": { "intro_sequence": "NARRATIVE_29_INTRO", @@ -26,8 +30,12 @@ }, "rewards": { "guaranteed": { - "items": ["ITEM_VOID_ESSENCE"], - "unlocks": ["MISSION_STORY_30"] + "items": [ + "ITEM_VOID_ESSENCE" + ], + "unlocks": [ + "MISSION_STORY_30" + ] } } -} +} \ No newline at end of file diff --git a/src/assets/data/units/enemy_commander.json b/src/assets/data/units/enemy_commander.json new file mode 100644 index 0000000..160e4c3 --- /dev/null +++ b/src/assets/data/units/enemy_commander.json @@ -0,0 +1,14 @@ +{ + "id": "DYNAMIC_COMMANDER", + "type": "ENEMY", + "name": "Opposing Commander", + "model": "MODEL_ENEMY_COMMANDER", + "stats": { + "health": 200, + "attack": 15, + "defense": 5, + "speed": 6 + }, + "ai_archetype": "TACTICIAN", + "xp_value": 100 +} diff --git a/src/core/DebugCommands.js b/src/core/DebugCommands.js index 0164687..c77b242 100644 --- a/src/core/DebugCommands.js +++ b/src/core/DebugCommands.js @@ -61,13 +61,13 @@ export class DebugCommands { // Check for level up (simple XP curve: 100 * level^2) const mastery = unit.classMastery[unit.activeClassId]; - const xpForNextLevel = 100 * (mastery.level ** 2); - + 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); @@ -87,9 +87,9 @@ export class DebugCommands { 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!" : "") + `XP: ${mastery.xp}/${100 * newLevel ** 2}. ` + + `Skill Points: ${mastery.skillPoints}. ` + + (leveledUp ? "✨ LEVELED UP!" : "") ); } @@ -115,14 +115,14 @@ export class DebugCommands { 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); + 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); @@ -141,7 +141,7 @@ export class DebugCommands { results.push( `${unit.name} (${unit.id}): Level set to ${level} (was ${oldLevel}). ` + - `Skill Points: ${mastery.skillPoints}` + `Skill Points: ${mastery.skillPoints}` ); } @@ -200,7 +200,9 @@ export class DebugCommands { } if (mastery.unlockedNodes.includes(nodeId)) { - results.push(`${unit.name} (${unit.id}): Node ${nodeId} already unlocked`); + results.push( + `${unit.name} (${unit.id}): Node ${nodeId} already unlocked` + ); continue; } @@ -248,7 +250,9 @@ export class DebugCommands { const results = []; for (const unit of units) { if (!this.gameLoop?.classRegistry) { - results.push(`${unit.name}: Cannot unlock all skills - class registry not available`); + results.push( + `${unit.name}: Cannot unlock all skills - class registry not available` + ); continue; } @@ -262,8 +266,8 @@ export class DebugCommands { // 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.` + `Use unlockSkill(unitId, nodeId) with specific node IDs. ` + + `To see available nodes, open the character sheet for this unit.` ); } @@ -336,7 +340,9 @@ export class DebugCommands { this.gameStateManager._saveHubStash(); } - return `Added ${shards} Aether Shards${cores > 0 ? ` and ${cores} Ancient Cores` : ""} to hub stash`; + return `Added ${shards} Aether Shards${ + cores > 0 ? ` and ${cores} Ancient Cores` : "" + } to hub stash`; } // ============================================ @@ -355,7 +361,9 @@ export class DebugCommands { const enemies = this._getEnemies(enemyId); if (enemies.length === 0) { - return `No enemies found${enemyId !== "all" ? ` with ID: ${enemyId}` : ""}`; + return `No enemies found${ + enemyId !== "all" ? ` with ID: ${enemyId}` : "" + }`; } const results = []; @@ -385,9 +393,10 @@ export class DebugCommands { 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); + 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}` : ""}`; @@ -397,7 +406,11 @@ export class DebugCommands { 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})`); + results.push( + `${unit.name} (${unit.id}): Healed ${ + unit.maxHealth - oldHP + } HP (${oldHP} → ${unit.maxHealth})` + ); } return results.join("\n"); @@ -440,7 +453,8 @@ export class DebugCommands { return "Error: Mission manager not available"; } - const objectives = this.gameStateManager.missionManager.currentObjectives || []; + const objectives = + this.gameStateManager.missionManager.currentObjectives || []; let objective = null; if (typeof objectiveId === "number") { @@ -459,7 +473,9 @@ export class DebugCommands { // Check victory this.gameStateManager.missionManager.checkVictory(); - return `Objective completed: ${objective.type} (${objective.id || "unnamed"})`; + return `Objective completed: ${objective.type} (${ + objective.id || "unnamed" + })`; } /** @@ -485,7 +501,8 @@ export class DebugCommands { return `Triggered narrative: ${narrativeId}`; } else { // Try to trigger current mission's intro/outro - const missionDef = this.gameStateManager.missionManager?.currentMissionDef; + const missionDef = + this.gameStateManager.missionManager?.currentMissionDef; if (missionDef?.narrative) { const narrativeToUse = missionDef.narrative.intro_success || @@ -495,7 +512,9 @@ export class DebugCommands { this.gameStateManager.narrativeManager .loadSequence(narrativeToUse) .then(() => { - this.gameStateManager.narrativeManager.startSequence(narrativeToUse); + this.gameStateManager.narrativeManager.startSequence( + narrativeToUse + ); }) .catch((error) => { console.error("Error loading narrative:", error); @@ -522,17 +541,19 @@ export class DebugCommands { const missionManager = this.gameStateManager.missionManager; missionManager.refreshProceduralMissions(false); - - const proceduralCount = Array.from(missionManager.missionRegistry.values()) - .filter((m) => m.type === "SIDE_QUEST" && m.id?.startsWith("SIDE_OP_")) - .length; + + const proceduralCount = Array.from( + missionManager.missionRegistry.values() + ).filter( + (m) => m.type === "SIDE_QUEST" && m.id?.startsWith("SIDE_OP_") + ).length; return `Regenerated procedural missions. ${proceduralCount} missions available on board.`; } /** * Generates a specific mission type and adds it to the mission board. - * @param {string} archetype - Mission archetype: "SKIRMISH", "SALVAGE", "ASSASSINATION", or "RECON" + * @param {string} archetype - Mission archetype: "SKIRMISH", "SALVAGE", "ELIMINATE_UNIT", or "RECON" * @param {number} [tier=2] - Campaign tier (1-5), defaults to 2 * @param {string} [biomeType] - Biome type ID (optional, will pick random if not specified) * @returns {string} Result message @@ -542,11 +563,13 @@ export class DebugCommands { return "Error: MissionManager not available"; } - const validArchetypes = ["SKIRMISH", "SALVAGE", "ASSASSINATION", "RECON"]; + const validArchetypes = ["SKIRMISH", "SALVAGE", "ELIMINATE_UNIT", "RECON"]; const upperArchetype = archetype.toUpperCase(); - + if (!validArchetypes.includes(upperArchetype)) { - return `Error: Invalid archetype "${archetype}". Valid types: ${validArchetypes.join(", ")}`; + return `Error: Invalid archetype "${archetype}". Valid types: ${validArchetypes.join( + ", " + )}`; } if (tier < 1 || tier > 5) { @@ -554,19 +577,20 @@ export class DebugCommands { } const missionManager = this.gameStateManager.missionManager; - + // Get unlocked regions and history const unlockedRegions = missionManager._getUnlockedRegions ? missionManager._getUnlockedRegions() : ["BIOME_RUSTING_WASTES"]; - + const history = missionManager._getMissionHistory ? missionManager._getMissionHistory() : []; // Pick a biome if not specified if (!biomeType) { - biomeType = unlockedRegions[Math.floor(Math.random() * unlockedRegions.length)]; + biomeType = + unlockedRegions[Math.floor(Math.random() * unlockedRegions.length)]; } // Generate mission with specific archetype @@ -577,17 +601,21 @@ export class DebugCommands { const maxAttempts = 50; while (!mission && attempts < maxAttempts) { - const candidate = MissionGenerator.generateSideOp(tier, unlockedRegions, history); + const candidate = MissionGenerator.generateSideOp( + tier, + unlockedRegions, + history + ); // Check if this mission matches our desired archetype const primaryObj = candidate.objectives?.primary?.[0]; if (primaryObj) { const archetypeMap = { SKIRMISH: "ELIMINATE_ALL", SALVAGE: "INTERACT", - ASSASSINATION: "ELIMINATE_UNIT", + ELIMINATE_UNIT: "ELIMINATE_UNIT", RECON: "REACH_ZONE", }; - + if (primaryObj.type === archetypeMap[upperArchetype]) { mission = candidate; // Override biome if specified @@ -610,7 +638,11 @@ export class DebugCommands { // Register the mission missionManager.registerMission(mission); - return `Generated ${upperArchetype} mission: "${mission.config.title}" (Tier ${tier}, ${mission.biome?.type || biomeType}). Mission ID: ${mission.id}`; + return `Generated ${upperArchetype} mission: "${ + mission.config.title + }" (Tier ${tier}, ${mission.biome?.type || biomeType}). Mission ID: ${ + mission.id + }`; } // ============================================ @@ -627,85 +659,187 @@ export class DebugCommands { "font-weight: bold; font-size: 16px; color: #4CAF50;" ); + console.log("%cEXPLORER & LEVELING:", "font-weight: bold; color: #2196F3;"); console.log( - "%cEXPLORER & LEVELING:", - "font-weight: bold; color: #2196F3;" + " %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(" %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( - "%cSKILL TREE:", - "font-weight: bold; color: #2196F3;" + " %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(" %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( - "%cINVENTORY:", - "font-weight: bold; color: #2196F3;" + " %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(" %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( - "%cCOMBAT:", - "font-weight: bold; color: #2196F3;" + " %ckillEnemy(enemyId)%c - Kill enemy", + "color: #FF9800;", + "color: inherit;" ); - 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(' 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( - "%cMISSION & NARRATIVE:", - "font-weight: bold; color: #2196F3;" + " %cregenerateMissions()%c - Regenerate all procedural missions on board", + "color: #FF9800;", + "color: inherit;" + ); + console.log( + " %cgenerateMission(type, tier, biome)%c - Generate specific mission type", + "color: #FF9800;", + "color: inherit;" + ); + console.log( + ' type: "SKIRMISH", "SALVAGE", "ELIMINATE_UNIT", "RECON"' ); - console.log(" %cregenerateMissions()%c - Regenerate all procedural missions on board", "color: #FF9800;", "color: inherit;"); - console.log(" %cgenerateMission(type, tier, biome)%c - Generate specific mission type", "color: #FF9800;", "color: inherit;"); - console.log(" type: \"SKIRMISH\", \"SALVAGE\", \"ASSASSINATION\", \"RECON\""); console.log(" tier: 1-5 (default: 2)"); - console.log(" biome: optional biome ID (e.g., \"BIOME_RUSTING_WASTES\")"); + console.log( + ' biome: optional biome ID (e.g., "BIOME_RUSTING_WASTES")' + ); console.log(" Examples:"); - console.log(" %cgenerateMission(\"RECON\")%c - Generate RECON mission (for testing zone objectives)", "color: #4CAF50; font-family: monospace;", "color: inherit;"); - console.log(" %cgenerateMission(\"SKIRMISH\", 3)%c - Generate tier 3 SKIRMISH mission", "color: #4CAF50; font-family: monospace;", "color: inherit;"); - 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( + ' %cgenerateMission("RECON")%c - Generate RECON mission (for testing zone objectives)', + "color: #4CAF50; font-family: monospace;", + "color: inherit;" + ); + console.log( + ' %cgenerateMission("SKIRMISH", 3)%c - Generate tier 3 SKIRMISH mission', + "color: #4CAF50; font-family: monospace;", + "color: inherit;" + ); + 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( - "%cUTILITY:", - "font-weight: bold; color: #2196F3;" + " %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(" %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( - "%cQUICK EXAMPLES:", - "font-weight: bold; color: #9C27B0;" + ' %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.generateMission("RECON")', + "color: #4CAF50; font-family: monospace;" + ); + console.log( + " %cdebugCommands.regenerateMissions()", + "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(" %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.generateMission(\"RECON\")", "color: #4CAF50; font-family: monospace;"); - console.log(" %cdebugCommands.regenerateMissions()", "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( @@ -734,7 +868,7 @@ export class DebugCommands { 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) => { @@ -811,7 +945,10 @@ export class DebugCommands { return "No items found in registry"; } - let result = `Available Items (showing ${Math.min(limit, items.length)} of ${items.length}):\n\n`; + 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) { @@ -851,7 +988,9 @@ export class DebugCommands { }); if (items.length > limit) { - result += `\n... and ${items.length - limit} more items (use listItems() to see all)`; + result += `\n... and ${ + items.length - limit + } more items (use listItems() to see all)`; } result += `\nUse with: addItem("${items[0]?.id}", 1, "hub")`; @@ -902,7 +1041,9 @@ export class DebugCommands { result += "\n"; }); if (items.length > 20) { - result += ` ... and ${items.length - 20} more (use listItemIds() to see all)\n`; + result += ` ... and ${ + items.length - 20 + } more (use listItemIds() to see all)\n`; } result += "\n"; } @@ -913,7 +1054,9 @@ export class DebugCommands { if (objectives.length > 0) { result += "OBJECTIVE IDs:\n"; objectives.forEach((obj, idx) => { - result += ` ${idx} or "${obj.id || `objective_${idx}`}" - ${obj.type}\n`; + result += ` ${idx} or "${obj.id || `objective_${idx}`}" - ${ + obj.type + }\n`; }); result += "\n"; } @@ -926,16 +1069,21 @@ export class DebugCommands { // Narrative IDs (from current mission) if (this.gameStateManager?.missionManager?.currentMissionDef?.narrative) { - const narrative = 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`; + 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."; + result += + "Use listPlayerIds(), listEnemyIds(), or listItemIds() for detailed lists."; return result; } @@ -946,8 +1094,10 @@ export class DebugCommands { */ getState() { let result = "Game State:\n\n"; - - result += `Current State: ${this.gameStateManager?.currentState || "UNKNOWN"}\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`; @@ -957,7 +1107,9 @@ export class DebugCommands { 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`; + result += ` ${idx + 1}. ${obj.type} - ${obj.complete ? "✓" : "✗"} (${ + obj.current || 0 + }/${obj.target_count || 0})\n`; }); } } @@ -1025,4 +1177,3 @@ export const debugCommands = new DebugCommands(); if (typeof window !== "undefined") { window.debugCommands = debugCommands; } - diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 5bb10af..4dceb2f 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -1198,83 +1198,76 @@ export class GameLoop { // Create a proper registry with actual class definitions const classRegistry = new Map(); + // Dynamic Asset Loading + try { + const manifestRes = await fetch("./assets/data/manifest.json"); + const manifest = await manifestRes.json(); - // Lazy-load class definitions - const [ - vanguardDef, - weaverDef, - scavengerDef, - tinkerDef, - custodianDef, - muleBotDef, - ] = 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), - import("../assets/data/units/mule_bot.json", { - with: { type: "json" }, - }).then((m) => m.default), - ]); + const loadAssets = async (files) => { + return Promise.all( + files.map(async (file) => { + const res = await fetch(`./assets/data/${file}`); + return res.json(); + }) + ); + }; - // Register all class definitions - const classDefs = [ - vanguardDef, - weaverDef, - scavengerDef, - tinkerDef, - custodianDef, - muleBotDef, - ]; + const [classDefs, unitDefs] = await Promise.all([ + loadAssets(manifest.classes || []), + loadAssets(manifest.units || []), + ]); - for (const classDef of classDefs) { - if (classDef && classDef.id) { - // Add type field for compatibility - classRegistry.set(classDef.id, { - ...classDef, - type: "EXPLORER", - }); + // Register Classes + for (const classDef of classDefs) { + if (classDef && classDef.id) { + classRegistry.set(classDef.id, { + ...classDef, + type: classDef.type || "EXPLORER", + }); + } } + + // Create registry object with get method for UnitManager + const unitRegistry = { + get: (id) => { + // 1. Check Class Registry (Explorers) + if (classRegistry.has(id)) { + return classRegistry.get(id); + } + + // 2. Check Loaded Unit Definitions + const foundUnit = unitDefs.find((u) => u.id === id); + if (foundUnit) { + return foundUnit; + } + + // 3. Fallback for generic enemies (legacy support) + if (id.startsWith("ENEMY_")) { + return { + type: "ENEMY", + name: "Enemy", + stats: { health: 50, attack: 8, defense: 3, speed: 8 }, + ai_archetype: "BRUISER", + }; + } + + console.warn(`Unit definition not found: ${id}`); + return null; + }, + }; + + this.unitManager = new UnitManager(unitRegistry); + this.classRegistry = classRegistry; + + console.log( + `Loaded ${classDefs.length} classes and ${unitDefs.length} units.` + ); + } catch (err) { + console.error("Failed to load game assets:", err); + // Fallback or critical error handling + this.unitManager = new UnitManager(classRegistry); // fallback to empty/partial } - // Create registry object with get method for UnitManager - const unitRegistry = { - get: (id) => { - // Try to get from class registry first - if (classRegistry.has(id)) { - return classRegistry.get(id); - } - - // Fallback for enemy units - if (id.startsWith("ENEMY_")) { - return { - type: "ENEMY", - name: "Enemy", - stats: { health: 50, attack: 8, defense: 3, speed: 8 }, - ai_archetype: "BRUISER", - }; - } - - console.warn(`Unit definition not found: ${id}`); - return null; - }, - }; - - this.unitManager = new UnitManager(unitRegistry); - // Store classRegistry reference for accessing class definitions later - this.classRegistry = classRegistry; - // WIRING: Connect Systems to Data this.movementSystem.setContext(this.grid, this.unitManager); this.turnSystem.setContext(this.unitManager); @@ -1553,7 +1546,32 @@ export class GameLoop { // Get enemy spawns from mission definition const missionDef = await this.missionManager?.getActiveMission(); - const enemySpawns = missionDef?.enemy_spawns || []; + const enemySpawns = [...(missionDef?.enemy_spawns || [])]; + + // Auto-populate spawn for ELIMINATE_UNIT objectives if missing + // This unifies the logic so Story missions don't need explicit enemy_spawns for bosses + const allObjectives = [ + ...(missionDef.objectives?.primary || []), + ...(missionDef.objectives?.secondary || []), + ]; + + allObjectives.forEach((obj) => { + if (obj.type === "ELIMINATE_UNIT" && obj.target_def_id) { + // Check if already spawning + const alreadySpawning = enemySpawns.some( + (s) => s.enemy_def_id === obj.target_def_id + ); + if (!alreadySpawning) { + console.log( + `Auto-adding spawn for ELIMINATE_UNIT target: ${obj.target_def_id}` + ); + enemySpawns.push({ + enemy_def_id: obj.target_def_id, + count: obj.target_count || 1, + }); + } + } + }); // If no enemy_spawns defined, fall back to default behavior if (enemySpawns.length === 0) { @@ -1629,10 +1647,6 @@ export class GameLoop { // Spawn Escort/VIP Units defined in objectives // Check primary and secondary objectives for ESCORT type - const allObjectives = [ - ...(missionDef.objectives?.primary || []), - ...(missionDef.objectives?.secondary || []), - ]; const escortObjectives = allObjectives.filter( (obj) => obj.type === "ESCORT" @@ -2265,17 +2279,16 @@ export class GameLoop { } } } - } - // Custom Override from Unit Definition (e.g. Mule Bot) - if (unit.voxelModelID && unit.voxelModelID.color) { - // Handle both string hex "0xAAAAAA" and number - if (typeof unit.voxelModelID.color === "string") { - color = parseInt(unit.voxelModelID.color.replace("0x", ""), 16); - } else { - color = unit.voxelModelID.color; + // Custom Override from Unit Definition (e.g. Mule Bot) + if (unit.voxelModelID && unit.voxelModelID.color) { + // Handle both string hex "0xAAAAAA" and number + if (typeof unit.voxelModelID.color === "string") { + color = parseInt(unit.voxelModelID.color.replace("0x", ""), 16); + } else { + color = unit.voxelModelID.color; + } } - } const material = new THREE.MeshStandardMaterial({ color: color }); const mesh = new THREE.Mesh(geometry, material); @@ -2864,6 +2877,54 @@ export class GameLoop { } } + /** + * Completely disposes of the GameLoop and all its resources. + * This is used when destroying the game instance to return to the main menu. + */ + dispose() { + this.stop(); + + // Dispose Renderer + if (this.renderer) { + this.renderer.dispose(); + if (this.renderer.domElement && this.renderer.domElement.parentNode) { + this.renderer.domElement.parentNode.removeChild( + this.renderer.domElement + ); + } + this.renderer = null; + } + + // Dispose Scene + if (this.scene) { + this.scene.clear(); + this.scene = null; + } + + // Detach Input Manager + if (this.inputManager) { + this.inputManager.detach(); + this.inputManager = null; + } + + // Clear references + this.camera = null; + this.controls = null; + this.grid = null; + this.voxelManager = null; + this.unitManager = null; + this.turnSystem = null; + this.movementSystem = null; + this.skillTargetingSystem = null; + this.effectProcessor = null; + this.inventoryManager = null; + this.missionManager = null; + this.gameStateManager = null; + this.runData = null; + + console.log("GameLoop: Disposed completely."); + } + /** * Updates the combat state in GameStateManager. * Called when combat starts or when combat state changes (turn changes, etc.) diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 8c17cf9..86c78be 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -94,6 +94,15 @@ class GameStateManagerClass { return this.#gameLoopInitialized.promise; } + /** + * Disconnects the current game loop. + * Called when the game viewport is removed or the game is reset. + */ + disconnectGameLoop() { + this.gameLoop = null; + this.#gameLoopInitialized = Promise.withResolvers(); + } + /** @type {PromiseWithResolvers} */ #rosterLoaded = Promise.withResolvers(); diff --git a/src/index.html b/src/index.html index 339b0de..0a452b8 100644 --- a/src/index.html +++ b/src/index.html @@ -341,7 +341,7 @@ - +
diff --git a/src/index.js b/src/index.js index 27fc95c..69a5cf3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,8 @@ import { gameStateManager } from "./core/GameStateManager.js"; +// GameViewport is dynamic /** @type {HTMLElement | null} */ -const gameViewport = document.querySelector("game-viewport"); +let gameViewport = null; /** @type {HTMLElement | null} */ const teamBuilder = document.querySelector("team-builder"); /** @type {HTMLElement | null} */ @@ -235,7 +236,10 @@ window.addEventListener("gamestate-changed", async (e) => { loadingOverlay.toggleAttribute("hidden", false); mainMenu.toggleAttribute("hidden", true); - gameViewport.toggleAttribute("hidden", true); + mainMenu.toggleAttribute("hidden", true); + if (gameViewport) { + gameViewport.toggleAttribute("hidden", true); + } teamBuilder.toggleAttribute("hidden", true); if (hubScreen) { hubScreen.toggleAttribute("hidden", true); @@ -266,6 +270,12 @@ window.addEventListener("gamestate-changed", async (e) => { // Show main menu for new players mainMenu.toggleAttribute("hidden", false); } + // Clean up game viewport if it exists + if (gameViewport) { + console.log("Main Menu: Removing GameViewport to clean up resources"); + gameViewport.remove(); + gameViewport = null; + } break; case "STATE_TEAM_BUILDER": await import("./ui/team-builder.js"); @@ -299,6 +309,11 @@ window.addEventListener("gamestate-changed", async (e) => { case "STATE_DEPLOYMENT": case "STATE_COMBAT": await import("./ui/game-viewport.js"); + // Create new viewport if it doesn't exist + if (!gameViewport) { + gameViewport = document.createElement("game-viewport"); + document.body.appendChild(gameViewport); + } gameViewport.toggleAttribute("hidden", false); // Squad will be updated by game-viewport's #updateSquad() method // which listens to gamestate-changed events diff --git a/src/systems/MissionGenerator.js b/src/systems/MissionGenerator.js index 2626f22..ab4cd2b 100644 --- a/src/systems/MissionGenerator.js +++ b/src/systems/MissionGenerator.js @@ -63,9 +63,9 @@ export class MissionGenerator { ]; /** - * Nouns for Assassination (Kill) missions + * Nouns for Eliminate Unit missions */ - static NOUNS_ASSASSINATION = [ + static NOUNS_ELIMINATE_UNIT = [ "Viper", "Dagger", "Fang", @@ -307,7 +307,7 @@ export class MissionGenerator { const tierConfig = this.TIER_CONFIG[validTier]; // Select archetype - const archetypes = ["SKIRMISH", "SALVAGE", "ASSASSINATION", "RECON"]; + const archetypes = ["SKIRMISH", "SALVAGE", "ELIMINATE_UNIT", "RECON"]; const archetype = this.randomChoice(archetypes); // Select noun based on archetype @@ -319,8 +319,8 @@ export class MissionGenerator { case "SALVAGE": noun = this.randomChoice(this.NOUNS_SALVAGE); break; - case "ASSASSINATION": - noun = this.randomChoice(this.NOUNS_ASSASSINATION); + case "ELIMINATE_UNIT": + noun = this.randomChoice(this.NOUNS_ELIMINATE_UNIT); break; case "RECON": noun = this.randomChoice(this.NOUNS_RECON); @@ -398,8 +398,8 @@ export class MissionGenerator { expiresIn: 3, // Expires in 3 campaign days }; - // Add boss config for Assassination missions - if (archetype === "ASSASSINATION") { + // Add boss config for Eliminate Unit missions + if (archetype === "ELIMINATE_UNIT") { // Find target ID from objectives const targetObj = objectives.primary.find( (o) => o.type === "ELIMINATE_UNIT" @@ -454,7 +454,7 @@ export class MissionGenerator { case "RECON": verb = "scout"; break; - case "ASSASSINATION": + case "ELIMINATE_UNIT": verb = "assassinate"; break; } @@ -533,7 +533,7 @@ export class MissionGenerator { failure_conditions: [{ type: "SQUAD_WIPE" }], }; - case "ASSASSINATION": + case "ELIMINATE_UNIT": // Generate a random elite enemy ID const eliteEnemies = [ "ENEMY_ELITE_ECHO", @@ -600,8 +600,8 @@ export class MissionGenerator { const spawns = []; switch (archetype) { - case "ASSASSINATION": - // For ASSASSINATION, spawn the target enemy from the ELIMINATE_UNIT objective + case "ELIMINATE_UNIT": + // For ELIMINATE_UNIT, spawn the target enemy from the ELIMINATE_UNIT objective const eliminateUnitObj = objectives.primary?.find( (obj) => obj.type === "ELIMINATE_UNIT" ); @@ -699,7 +699,7 @@ export class MissionGenerator { density = "HIGH"; // High density for obstacles/cover break; - case "ASSASSINATION": + case "ELIMINATE_UNIT": size = { x: 22, y: 12, z: 22 }; roomCount = 7; density = "MEDIUM"; @@ -757,7 +757,7 @@ export class MissionGenerator { return `Clear the sector of hostile forces in ${biomeName}.`; case "SALVAGE": return `Recover lost supplies before the enemy secures them in ${biomeName}.`; - case "ASSASSINATION": + case "ELIMINATE_UNIT": return `A High-Value Target has been spotted in ${biomeName}. Eliminate them.`; case "RECON": return `Survey the designated coordinates in ${biomeName}.`; @@ -788,9 +788,9 @@ export class MissionGenerator { baseXP * multiplier * this.randomFloat(0.9, 1.1) ); - // Assassination missions get bonus currency + // Eliminate Unit missions get bonus currency let finalCurrency = currencyAmount; - if (archetype === "ASSASSINATION") { + if (archetype === "ELIMINATE_UNIT") { finalCurrency = Math.round(finalCurrency * 1.5); } diff --git a/src/ui/game-viewport.js b/src/ui/game-viewport.js index 7ed5fd3..7bd220c 100644 --- a/src/ui/game-viewport.js +++ b/src/ui/game-viewport.js @@ -74,10 +74,18 @@ export class GameViewport extends LitElement { } async firstUpdated() { + // Only init if connected + if (!this.isConnected) return; + + if (this.loop) { + console.warn("GameViewport: Loop already exists, skipping init"); + return; + } + const container = this.shadowRoot.getElementById("canvas-container"); - const loop = new GameLoop(); - loop.init(container); - gameStateManager.setGameLoop(loop); + this.loop = new GameLoop(); + this.loop.init(container); + gameStateManager.setGameLoop(this.loop); // 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 @@ -98,6 +106,17 @@ export class GameViewport extends LitElement { this.#updateSquad(); } + disconnectedCallback() { + super.disconnectedCallback(); + console.log("GameViewport: disconnectedCallback called"); + if (this.loop) { + console.log("GameViewport: Disposing GameLoop..."); + this.loop.dispose(); + this.loop = null; + } + gameStateManager.disconnectGameLoop(); + } + #setupCombatStateUpdates() { // Listen for combat state changes window.addEventListener("combat-state-changed", (e) => { @@ -208,7 +227,10 @@ export class GameViewport extends LitElement { .result=${this.debriefResult} @return-to-hub=${() => { window.dispatchEvent( - new CustomEvent("debrief-closed", { bubbles: true, composed: true }) + new CustomEvent("debrief-closed", { + bubbles: true, + composed: true, + }) ); }} >` diff --git a/src/utils/generate_manifest.js b/src/utils/generate_manifest.js new file mode 100644 index 0000000..765efc6 --- /dev/null +++ b/src/utils/generate_manifest.js @@ -0,0 +1,37 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const ASSETS_DIR = path.resolve(__dirname, "../assets/data"); +const OUTPUT_FILE = path.resolve(ASSETS_DIR, "manifest.json"); + +function getJsonFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => path.join(path.basename(dir), f).replace(/\\/g, "/")); +} + +export function generateManifest() { + console.log("Generating asset manifest..."); + + const classes = getJsonFiles(path.join(ASSETS_DIR, "classes")); + const units = getJsonFiles(path.join(ASSETS_DIR, "units")); + + const manifest = { + classes, + units, + }; + + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(manifest, null, 2)); + console.log(`Manifest written to ${OUTPUT_FILE}`); +} + +// Allow running directly +if (process.argv[1] === __filename) { + generateManifest(); +} diff --git a/src/utils/verify_missions.js b/src/utils/verify_missions.js new file mode 100644 index 0000000..3caf343 --- /dev/null +++ b/src/utils/verify_missions.js @@ -0,0 +1,66 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const MISSIONS_DIR = path.resolve(__dirname, "../assets/data/missions"); + +function verifyMissions() { + console.log("Verifying missions in:", MISSIONS_DIR); + const files = fs.readdirSync(MISSIONS_DIR).filter((f) => f.endsWith(".json")); + + let hasError = false; + + for (const file of files) { + try { + const filePath = path.join(MISSIONS_DIR, file); + const content = fs.readFileSync(filePath, "utf8"); + const mission = JSON.parse(content); + + const missing = []; + // Required root fields based on Mission.ts + if (!mission.id) missing.push("id"); + if (!mission.type) missing.push("type"); + if (!mission.config) missing.push("config"); + if (!mission.biome) missing.push("biome"); + if (!mission.objectives) missing.push("objectives"); + if (!mission.rewards) missing.push("rewards"); + + // Required children + if (mission.config && !mission.config.title) missing.push("config.title"); + if (mission.config && !mission.config.description) + missing.push("config.description"); + if (mission.config && mission.config.difficulty_tier === undefined) + missing.push("config.difficulty_tier"); + + if (mission.biome && !mission.biome.type) missing.push("biome.type"); + // generator_config is technically required in the interface `MissionBiome` + // although older files might be missing it. + if (mission.biome && !mission.biome.generator_config) + missing.push("biome.generator_config"); + + if (mission.objectives && !mission.objectives.primary) + missing.push("objectives.primary"); + if (mission.rewards && !mission.rewards.guaranteed) + missing.push("rewards.guaranteed"); + + if (missing.length > 0) { + console.log( + `Mission ${file} is missing required fields: ${missing.join(", ")}` + ); + hasError = true; + } + } catch (err) { + console.error(`Error parsing ${file}:`, err.message); + hasError = true; + } + } + + if (!hasError) { + console.log("All missions pass structure verification."); + } +} + +verifyMissions(); diff --git a/test/core/CombatStateSpec.test.js b/test/core/CombatStateSpec.test.js index 23bb0f3..51853e7 100644 --- a/test/core/CombatStateSpec.test.js +++ b/test/core/CombatStateSpec.test.js @@ -15,6 +15,47 @@ describe("Combat State Specification - CoA Tests", function () { let mockGameStateManager; beforeEach(async () => { + // Stub fetch explicitly for this test suite + if (!window.fetch.restore) { + sinon.stub(window, "fetch"); + } + + const mockResponse = (body) => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(body), + }); + + window.fetch.callsFake((url) => { + if (typeof url === "string" && url.includes("manifest.json")) { + return mockResponse({ + classes: ["classes/vanguard.json"], + units: [], + }); + } + if (typeof url === "string" && url.includes("vanguard.json")) { + return mockResponse({ + id: "CLASS_VANGUARD", + name: "Vanguard", + type: "EXPLORER", + base_stats: { + health: 120, + attack: 12, + defense: 8, + magic: 0, + speed: 8, + willpower: 5, + movement: 3, + }, + growth_rates: { health: 10, attack: 1, defense: 1 }, + starting_equipment: ["ITEM_RUSTY_BLADE", "ITEM_SCRAP_PLATE"], + skillTreeData: { active_skills: [], passive_skills: [] }, + model: "vanguard.glb", + }); + } + return Promise.reject(new Error(`Not Found: ${url}`)); + }); + container = document.createElement("div"); document.body.appendChild(container); @@ -48,6 +89,9 @@ describe("Combat State Specification - CoA Tests", function () { }); afterEach(() => { + if (window.fetch.restore) { + window.fetch.restore(); + } gameLoop.stop(); if (container.parentNode) { container.parentNode.removeChild(container); diff --git a/test/core/GameLoop/helpers.js b/test/core/GameLoop/helpers.js index 4ac3058..75f498e 100644 --- a/test/core/GameLoop/helpers.js +++ b/test/core/GameLoop/helpers.js @@ -1,11 +1,127 @@ import sinon from "sinon"; import { GameLoop } from "../../../src/core/GameLoop.js"; +/** + * Mock Data for Assets + */ +const MOCK_MANIFEST = { + classes: ["classes/vanguard.json"], + units: ["enemies/default.json"], +}; + +const MOCK_VANGUARD = { + id: "CLASS_VANGUARD", + name: "Vanguard", + baseStats: { + hp: 100, + maxHp: 100, + ap: 3, + maxAp: 3, + movement: 5, + defense: 10, + resistance: 5, + speed: 10, + accuracy: 85, + evasion: 5, + critRate: 5, + critDamage: 1.5, + }, + growths: { + hp: 1.0, + ap: 0, + movement: 0, + defense: 0.5, + resistance: 0.3, + speed: 0.4, + accuracy: 0.4, + evasion: 0.2, + critRate: 0.2, + }, + equipment: { + mainHand: "SWORD_BASIC", + armor: "ARMOR_BASIC", + }, + skills: ["STRIKE", "GUARD"], +}; + +const MOCK_ENEMY = { + id: "ENEMY_DEFAULT", + name: "Drone", + baseStats: { + hp: 50, + maxHp: 50, + ap: 3, + maxAp: 3, + movement: 4, + defense: 2, + resistance: 0, + speed: 8, + accuracy: 80, + evasion: 10, + critRate: 5, + critDamage: 1.5, + }, + equipment: { + mainHand: "BEAM_RIFLE", + }, +}; + +// Keep track of the stub to restore it +let fetchStub = null; + +/** + * Mocks window.fetch to return game assets. + */ +export function mockFetchAssets() { + if (fetchStub) return; // Already mocked + + fetchStub = sinon.stub(window, "fetch"); + + fetchStub.callsFake(async (url) => { + if (url.includes("manifest.json")) { + return { + ok: true, + json: async () => MOCK_MANIFEST, + }; + } + if (url.includes("classes/vanguard.json")) { + return { + ok: true, + json: async () => MOCK_VANGUARD, + }; + } + if (url.includes("enemies/default.json")) { + return { + ok: true, + json: async () => MOCK_ENEMY, + }; + } + // Return empty/default for others to prevent crashes + return { + ok: false, + status: 404, + json: async () => ({}), + text: async () => "Not Found", + }; + }); +} + +/** + * Restores window.fetch. + */ +export function restoreFetchAssets() { + if (fetchStub) { + fetchStub.restore(); + fetchStub = null; + } +} + /** * Creates a basic GameLoop setup for tests. * @returns {{ gameLoop: GameLoop; container: HTMLElement }} */ export function createGameLoopSetup() { + mockFetchAssets(); // Auto-mock assets const container = document.createElement("div"); document.body.appendChild(container); const gameLoop = new GameLoop(); @@ -28,6 +144,8 @@ export function cleanupGameLoop(gameLoop, container) { gameLoop.renderer.dispose(); gameLoop.renderer.forceContextLoss(); } + + restoreFetchAssets(); // Restore fetch } /** diff --git a/test/core/GameStateManager/hub-integration.test.js b/test/core/GameStateManager/hub-integration.test.js index 4269f80..edaf381 100644 --- a/test/core/GameStateManager/hub-integration.test.js +++ b/test/core/GameStateManager/hub-integration.test.js @@ -53,6 +53,9 @@ describe("Core: GameStateManager - Hub Integration", () => { load: sinon.stub(), save: sinon.stub().returns({ completedMissions: [] }), completedMissions: new Set(), + _ensureMissionsLoaded: sinon.stub().resolves(), + areProceduralMissionsUnlocked: sinon.stub().returns(false), + refreshProceduralMissions: sinon.stub(), }; }); @@ -70,7 +73,8 @@ describe("Core: GameStateManager - Hub Integration", () => { await gameStateManager.continueGame(); expect(mockPersistence.loadRun.called).to.be.true; - expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be.true; + expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be + .true; // Hub should show because roster exists transitionSpy.restore(); }); @@ -96,7 +100,9 @@ describe("Core: GameStateManager - Hub Integration", () => { }); it("should go to Hub when completed missions exist but no active run", async () => { - gameStateManager.missionManager.completedMissions.add("MISSION_TUTORIAL_01"); + gameStateManager.missionManager.completedMissions.add( + "MISSION_TUTORIAL_01" + ); mockPersistence.loadRun.resolves(null); const transitionSpy = sinon.spy(gameStateManager, "transitionTo"); @@ -104,7 +110,8 @@ describe("Core: GameStateManager - Hub Integration", () => { await gameStateManager.continueGame(); - expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be.true; + expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be + .true; // Hub should show because completed missions exist transitionSpy.restore(); }); @@ -164,10 +171,10 @@ describe("Core: GameStateManager - Hub Integration", () => { await gameStateManager.transitionTo(GameStateManager.STATES.MAIN_MENU); // Check that transitionTo was called with MAIN_MENU (could be from init or our call) - expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be.true; + expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be + .true; // Hub should be shown because roster exists transitionSpy.restore(); }); }); }); - diff --git a/test/systems/MissionGenerator.test.js b/test/systems/MissionGenerator.test.js index ca33228..d7533bf 100644 --- a/test/systems/MissionGenerator.test.js +++ b/test/systems/MissionGenerator.test.js @@ -13,12 +13,12 @@ describe("Systems: MissionGenerator", function () { it("should have type-specific noun arrays", () => { expect(MissionGenerator.NOUNS_SKIRMISH).to.be.an("array"); expect(MissionGenerator.NOUNS_SALVAGE).to.be.an("array"); - expect(MissionGenerator.NOUNS_ASSASSINATION).to.be.an("array"); + expect(MissionGenerator.NOUNS_ELIMINATE_UNIT).to.be.an("array"); expect(MissionGenerator.NOUNS_RECON).to.be.an("array"); expect(MissionGenerator.NOUNS_SKIRMISH).to.include("Thunder"); expect(MissionGenerator.NOUNS_SALVAGE).to.include("Cache"); - expect(MissionGenerator.NOUNS_ASSASSINATION).to.include("Viper"); + expect(MissionGenerator.NOUNS_ELIMINATE_UNIT).to.include("Viper"); expect(MissionGenerator.NOUNS_RECON).to.include("Eye"); }); }); @@ -151,7 +151,7 @@ describe("Systems: MissionGenerator", function () { } else if (primaryObj.type === "INTERACT") { archetypes.add("SALVAGE"); } else if (primaryObj.type === "ELIMINATE_UNIT") { - archetypes.add("ASSASSINATION"); + archetypes.add("ELIMINATE_UNIT"); } else if (primaryObj.type === "REACH_ZONE") { archetypes.add("RECON"); } @@ -276,16 +276,16 @@ describe("Systems: MissionGenerator", function () { } expect(foundSalvage).to.be.true; - // Test Assassination (ELIMINATE_UNIT) - let foundAssassination = false; - for (let i = 0; i < 30 && !foundAssassination; i++) { + // Test Eliminate Unit + let foundEliminateUnit = false; + for (let i = 0; i < 30 && !foundEliminateUnit; i++) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") { - foundAssassination = true; + foundEliminateUnit = true; expect(mission.objectives.primary[0]).to.have.property( "target_def_id" ); @@ -294,7 +294,7 @@ describe("Systems: MissionGenerator", function () { ); } } - expect(foundAssassination).to.be.true; + expect(foundEliminateUnit).to.be.true; // Test Recon (REACH_ZONE) let foundRecon = false; @@ -520,17 +520,17 @@ describe("Systems: MissionGenerator", function () { it("CoA 19: Should calculate currency with tier multiplier and random factor", () => { // Generate multiple missions to account for different archetypes - // Non-assassination missions: Base 50 * 2.5 (tier 3) * random(0.8, 1.2) = 100-150 range - // Assassination missions get 1.5x bonus: 150-225 range - let foundNonAssassination = false; - for (let i = 0; i < 20 && !foundNonAssassination; i++) { + // Non-Eliminate Unit missions: Base 50 * 2.5 (tier 3) * random(0.8, 1.2) = 100-150 range + // Eliminate Unit missions get 1.5x bonus: 150-225 range + let foundNonEliminateUnit = false; + for (let i = 0; i < 20 && !foundNonEliminateUnit; i++) { const mission = MissionGenerator.generateSideOp(3, unlockedRegions, []); const currency = mission.rewards.guaranteed.currency.aether_shards; - const isAssassination = + const isEliminateUnit = mission.objectives.primary[0].type === "ELIMINATE_UNIT"; - if (!isAssassination) { - foundNonAssassination = true; + if (!isEliminateUnit) { + foundNonEliminateUnit = true; expect(currency).to.be.at.least(100); // 50 * 2.5 * 0.8 = 100 expect(currency).to.be.at.most(150); // 50 * 2.5 * 1.2 = 150 } else { @@ -539,28 +539,28 @@ describe("Systems: MissionGenerator", function () { expect(currency).to.be.at.most(225); // 50 * 2.5 * 1.2 * 1.5 = 225 } } - expect(foundNonAssassination).to.be.true; + expect(foundNonEliminateUnit).to.be.true; }); - it("CoA 20: Should give bonus currency for Assassination missions", () => { - let foundAssassination = false; - let assassinationCurrency = 0; + it("CoA 20: Should give bonus currency for Eliminate Unit missions", () => { + let foundEliminateUnit = false; + let eliminateUnitCurrency = 0; let otherCurrency = 0; - for (let i = 0; i < 50 && !foundAssassination; i++) { + for (let i = 0; i < 50 && !foundEliminateUnit; i++) { const mission = MissionGenerator.generateSideOp(2, unlockedRegions, []); if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") { - foundAssassination = true; - assassinationCurrency = + foundEliminateUnit = true; + eliminateUnitCurrency = mission.rewards.guaranteed.currency.aether_shards; } else { otherCurrency = mission.rewards.guaranteed.currency.aether_shards; } } - if (foundAssassination) { - // Assassination should have higher currency (1.5x bonus) - expect(assassinationCurrency).to.be.greaterThan(otherCurrency * 0.9); + if (foundEliminateUnit) { + // Eliminate Unit should have higher currency (1.5x bonus) + expect(eliminateUnitCurrency).to.be.greaterThan(otherCurrency * 0.9); } }); @@ -632,18 +632,18 @@ describe("Systems: MissionGenerator", function () { .to.be.true; }); - it("CoA 24: Should generate boss config for Assassination missions", () => { - let foundAssassination = false; + it("CoA 24: Should generate boss config for Eliminate Unit missions", () => { + let foundEliminateUnit = false; let attempts = 0; - while (!foundAssassination && attempts < 50) { + while (!foundEliminateUnit && attempts < 50) { const mission = MissionGenerator.generateSideOp( 2, unlockedRegions, emptyHistory ); if (mission.config.boss_config) { - foundAssassination = true; + foundEliminateUnit = true; const config = mission.config.boss_config; expect(config).to.have.property("target_def_id"); @@ -655,8 +655,8 @@ describe("Systems: MissionGenerator", function () { attempts++; } expect( - foundAssassination, - "Should have generated an Assassination mission with boss config" + foundEliminateUnit, + "Should have generated an Eliminate Unit mission with boss config" ).to.be.true; }); diff --git a/test/ui/mission-board.test.js b/test/ui/mission-board.test.js index 4c8ae67..cf8e8e6 100644 --- a/test/ui/mission-board.test.js +++ b/test/ui/mission-board.test.js @@ -8,7 +8,11 @@ describe("UI: MissionBoard", () => { let container; let mockMissionManager; + let originalMissionManager; + beforeEach(() => { + originalMissionManager = gameStateManager.missionManager; + // Mock MissionManager - set up BEFORE creating element mockMissionManager = { missionRegistry: new Map(), @@ -27,6 +31,7 @@ describe("UI: MissionBoard", () => { }); afterEach(() => { + gameStateManager.missionManager = originalMissionManager; if (container && container.parentNode) { container.parentNode.removeChild(container); } @@ -92,9 +97,9 @@ describe("UI: MissionBoard", () => { mockMissionManager.missionRegistry.set(mission2.id, mission2); // Wait for initial load to complete, then trigger a reload - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); // Dispatch event to trigger reload - window.dispatchEvent(new CustomEvent('missions-updated')); + window.dispatchEvent(new CustomEvent("missions-updated")); await waitForUpdate(); const missionCards = queryShadowAll(".mission-card"); @@ -150,7 +155,9 @@ describe("UI: MissionBoard", () => { const missionCard = queryShadow(".mission-card"); const description = missionCard.querySelector(".mission-description"); expect(description).to.exist; - expect(description.textContent).to.include("This is a test mission description."); + expect(description.textContent).to.include( + "This is a test mission description." + ); }); it("should display difficulty information", async () => { @@ -273,12 +280,12 @@ describe("UI: MissionBoard", () => { }; mockMissionManager.missionRegistry.set(mission.id, mission); mockMissionManager.completedMissions.add(mission.id); - await new Promise(resolve => setTimeout(resolve, 100)); - window.dispatchEvent(new CustomEvent('missions-updated')); + await new Promise((resolve) => setTimeout(resolve, 100)); + window.dispatchEvent(new CustomEvent("missions-updated")); await waitForUpdate(); // Switch to completed tab - const completedTab = queryShadow('.tab-button:last-child'); + const completedTab = queryShadow(".tab-button:last-child"); if (completedTab) { completedTab.click(); await waitForUpdate(); @@ -299,12 +306,12 @@ describe("UI: MissionBoard", () => { }; mockMissionManager.missionRegistry.set(mission.id, mission); mockMissionManager.completedMissions.add(mission.id); - await new Promise(resolve => setTimeout(resolve, 100)); - window.dispatchEvent(new CustomEvent('missions-updated')); + await new Promise((resolve) => setTimeout(resolve, 100)); + window.dispatchEvent(new CustomEvent("missions-updated")); await waitForUpdate(); // Switch to completed tab - const completedTab = queryShadow('.tab-button:last-child'); + const completedTab = queryShadow(".tab-button:last-child"); if (completedTab) { completedTab.click(); await waitForUpdate(); @@ -403,10 +410,30 @@ describe("UI: MissionBoard", () => { describe("Mission Type Styling", () => { it("should apply correct styling for different mission types", async () => { const missions = [ - { id: "M1", type: "STORY", config: { title: "Story", description: "Test" }, rewards: {} }, - { id: "M2", type: "SIDE_QUEST", config: { title: "Side", description: "Test" }, rewards: {} }, - { id: "M3", type: "TUTORIAL", config: { title: "Tutorial", description: "Test" }, rewards: {} }, - { id: "M4", type: "PROCEDURAL", config: { title: "Proc", description: "Test" }, rewards: {} }, + { + id: "M1", + type: "STORY", + config: { title: "Story", description: "Test" }, + rewards: {}, + }, + { + id: "M2", + type: "SIDE_QUEST", + config: { title: "Side", description: "Test" }, + rewards: {}, + }, + { + id: "M3", + type: "TUTORIAL", + config: { title: "Tutorial", description: "Test" }, + rewards: {}, + }, + { + id: "M4", + type: "PROCEDURAL", + config: { title: "Proc", description: "Test" }, + rewards: {}, + }, ]; missions.forEach((m) => mockMissionManager.missionRegistry.set(m.id, m)); @@ -468,7 +495,9 @@ describe("UI: MissionBoard", () => { expect(missionCards.length).to.equal(2); const mission2Card = Array.from(missionCards).find((card) => - card.querySelector(".mission-title")?.textContent.includes("Second Mission") + card + .querySelector(".mission-title") + ?.textContent.includes("Second Mission") ); expect(mission2Card).to.exist; expect(mission2Card.classList.contains("locked")).to.be.true; @@ -499,7 +528,9 @@ describe("UI: MissionBoard", () => { const missionCards = queryShadowAll(".mission-card"); const mission2Card = Array.from(missionCards).find((card) => - card.querySelector(".mission-title")?.textContent.includes("Second Mission") + card + .querySelector(".mission-title") + ?.textContent.includes("Second Mission") ); expect(mission2Card).to.exist; expect(mission2Card.classList.contains("locked")).to.be.false; @@ -529,7 +560,9 @@ describe("UI: MissionBoard", () => { const missionCards = queryShadowAll(".mission-card"); const mission2Card = Array.from(missionCards).find((card) => - card.querySelector(".mission-title")?.textContent.includes("Second Mission") + card + .querySelector(".mission-title") + ?.textContent.includes("Second Mission") ); expect(mission2Card).to.exist; expect(mission2Card.textContent).to.include("Requires"); @@ -600,7 +633,9 @@ describe("UI: MissionBoard", () => { expect(titles).to.include("Second Quest"); const mission2Card = Array.from(missionCards).find((card) => - card.querySelector(".mission-title")?.textContent.includes("Second Quest") + card + .querySelector(".mission-title") + ?.textContent.includes("Second Quest") ); expect(mission2Card.classList.contains("locked")).to.be.true; }); @@ -626,27 +661,31 @@ describe("UI: MissionBoard", () => { mockMissionManager.missionRegistry.set(mission1.id, mission1); mockMissionManager.missionRegistry.set(mission2.id, mission2); mockMissionManager.completedMissions.add("MISSION_01"); - await new Promise(resolve => setTimeout(resolve, 100)); - window.dispatchEvent(new CustomEvent('missions-updated')); + await new Promise((resolve) => setTimeout(resolve, 100)); + window.dispatchEvent(new CustomEvent("missions-updated")); await waitForUpdate(); // Wait a bit more for the component to process the update - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); await waitForUpdate(); // Check active tab - should show MISSION_02 (prerequisites met) const activeCards = queryShadowAll(".missions-grid .mission-card"); expect(activeCards.length).to.equal(1); - const activeTitle = activeCards[0].querySelector(".mission-title")?.textContent.trim(); + const activeTitle = activeCards[0] + .querySelector(".mission-title") + ?.textContent.trim(); expect(activeTitle).to.equal("Second Story"); // Check completed tab - should show MISSION_01 (completed) - const completedTab = queryShadow('.tab-button:last-child'); + const completedTab = queryShadow(".tab-button:last-child"); if (completedTab) { completedTab.click(); await waitForUpdate(); const completedCards = queryShadowAll(".missions-grid .mission-card"); expect(completedCards.length).to.equal(1); - const completedTitle = completedCards[0].querySelector(".mission-title")?.textContent.trim(); + const completedTitle = completedCards[0] + .querySelector(".mission-title") + ?.textContent.trim(); expect(completedTitle).to.equal("First Story"); } }); @@ -683,4 +722,3 @@ describe("UI: MissionBoard", () => { }); }); }); -