feat: Implement core game loop, state management, mission generation, and add initial mission data with debug commands.

This commit is contained in:
Matthew Mone 2026-01-02 22:02:27 -08:00
parent 2898701b46
commit 930b1c7438
32 changed files with 1022 additions and 315 deletions

View file

@ -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");

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<unknown>} */
#rosterLoaded = Promise.withResolvers();

View file

@ -341,7 +341,7 @@
<!-- HUB SCREEN -->
<hub-screen hidden aria-label="Hub Screen"></hub-screen>
<!-- GAME VIEWPORT CONTAINER -->
<game-viewport hidden aria-label="Game World"></game-viewport>
<!-- game-viewport is now dynamically created/removed by index.js -->
<!-- UI LAYER for modals and overlays -->
<div id="ui-layer" aria-live="polite"></div>

View file

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

View file

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

View file

@ -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,
})
);
}}
></mission-debrief>`

View file

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

View file

@ -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();

View file

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

View file

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

View file

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

View file

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

View file

@ -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", () => {
});
});
});