diff --git a/specs/Mission_flow.md b/specs/Mission_flow.md
new file mode 100644
index 0000000..7255451
--- /dev/null
+++ b/specs/Mission_flow.md
@@ -0,0 +1,71 @@
+```mermaid
+graph TD
+ %% ACT I: Awakening
+ Start((Start Game)) --> M01[M01: First Descent
Unlock: Tinker, Barracks]
+ M01 --> M02[M02: The Signal
Unlock: Marketplace, Scavenger]
+ M02 --> M03[M03: The Foundation
Unlock: Research, Vanguard]
+
+ %% ACT II: Faction Agendas (Parallel)
+ M03 --> Hub{The Hub
Unlock: Side Ops}
+
+ Hub --> ChainA[Arcane Dominion Arc
Crystal Spires]
+ Hub --> ChainB[Cogwork Concord Arc
Rusting Wastes]
+ Hub --> ChainC[Iron Legion Arc
Frontier/Void]
+ Hub --> ChainD[Golden Exchange Arc
Mixed]
+ Hub --> ChainE[Silent Sanctuary Arc
Fungal Caves]
+
+ %% Chain A
+ ChainA --> M04[M04: Unstable Aether]
+ M04 --> M05[M05: Rogue Mage]
+ M05 --> M06[M06: Reality Tear
Unlock: Battle Mage]
+
+ %% Chain B
+ ChainB --> M07[M07: Factory Reset]
+ M07 --> M08[M08: The Construct
Boss Fight]
+ M08 --> M09[M09: Data Recovery
Unlock: Field Engineer]
+
+ %% Chain C
+ ChainC --> M10[M10: Hold the Line]
+ M10 --> M11[M11: Breach & Clear]
+ M11 --> M12[M12: The Warlord
Unlock: Warlord]
+
+ %% Chain D
+ ChainD --> M13[M13: Supply Run]
+ M13 --> M14[M14: Hostile Takeover]
+ M14 --> M15[M15: The Auction
Unlock: Platinum Pass]
+
+ %% Chain E
+ ChainE --> M16[M16: Cleansing]
+ M16 --> M17[M17: Lost Spirits]
+ M17 --> M18[M18: Source of Rot
Unlock: Custodian Mastery]
+
+ %% ACT III: The Fracture
+ M06 & M09 & M12 & M15 & M18 --> Fracture{Civil War Begins}
+
+ Fracture --> M19[M19: Diplomatic Immunity]
+ M19 --> M20[M20: Sabotage
Choice: Legion vs Concord]
+ M20 --> M21[M21: Resource War]
+ M21 --> M22[M22: False Prophet]
+ M22 --> M23[M23: Spirequake]
+ M23 --> M24[M24: The Ultimatum
Invasion Event]
+ M24 --> M25[M25: Into the Dark
Unlock: Act IV]
+
+ %% ACT IV: The Descent (Linear Gauntlet)
+ M25 --> M26[M26: Iron Shell]
+ M26 --> M27[M27: Crystal Heart]
+ M27 --> M28[M28: Rotting Soul]
+ M28 --> M29[M29: The Void]
+ M29 --> M30[M30: Gatekeeper
Boss: Void Brute Omega]
+ M30 --> M31[M31: Ascension]
+ M31 --> M32[M32: The Origin
Final Boss]
+ M32 --> End((Game Complete
New Game+))
+
+ %% Styling
+ classDef story fill:#f9f,stroke:#333,stroke-width:2px;
+ classDef boss fill:#b00,stroke:#333,stroke-width:4px,color:#fff;
+ classDef unlock fill:#0b0,stroke:#333,stroke-width:2px,color:#fff;
+
+ class M01,M02,M03 story;
+ class M08,M12,M18,M30,M32 boss;
+ class M06,M09,M15 unlock;
+```
diff --git a/specs/Procedural_Missions.spec.md b/specs/Procedural_Missions.spec.md
index 77c3c87..6d7131f 100644
--- a/specs/Procedural_Missions.spec.md
+++ b/specs/Procedural_Missions.spec.md
@@ -1,102 +1,92 @@
# **Procedural Mission Specification: Side Ops**
-This document defines the logic for generating "Filler" missions (Side Ops). These missions provide infinite replayability, resource grinding, and recovery options for the player.
+This document defines the logic for generating "Side Ops" that match the fidelity of Story Missions.
## **1. System Architecture**
Class: MissionGenerator
-Responsibility: Factory that produces temporary Mission objects adhering to the Mission.ts interface.
-Triggers:
-
-- **Daily Reset:** When the campaign day advances.
-- **Mission Complete:** Replenish the board after a run.
+Responsibility: Factory that produces temporary Mission objects.
+Triggers: Daily Reset, Mission Complete.
## **2. Generation Logic**
-To generate a Side Op, the system inputs the **Campaign Tier** (1-5) and **Unlocked Regions**.
-
-### **A. Naming Convention**
+### **A. Naming & Flavor (The Hook)**
Missions use a context-aware "Operation: [Adjective] [Noun] [Numeral]" format.
-Noun Selection (Context-Aware):
-The noun is selected based on the Mission Archetype to imply the goal.
+- **Skirmish Nouns:** _Thunder, Storm, Iron, Fury, Shield, Hammer._
+- **Salvage Nouns:** _Cache, Vault, Echo, Spark, Harvest, Trove._
+- **Assassination Nouns:** _Viper, Dagger, Fang, Night, Razor, Sting._
+- **Recon Nouns:** _Eye, Watch, Path, Horizon, Whisper, Scope._
-- **Skirmish (Combat):** _Thunder, Storm, Iron, Fury, Shield, Hammer, Wrath, Wall, Strike, Anvil._
-- **Salvage (Loot):** _Cache, Vault, Echo, Spark, Core, Grip, Harvest, Trove, Fragment, Salvage._
-- **Assassination (Kill):** _Viper, Dagger, Fang, Night, Shadow, End, Hunt, Razor, Ghost, Sting._
-- **Recon (Explore):** _Eye, Watch, Path, Horizon, Whisper, Dawn, Light, Step, Vision, Scope._
+### **B. Narrative Synthesis (Dynamic Context)**
-**Adjective Selection (General Flavor):**
+Unlike Story missions which link to static files, Side Ops generate a **Narrative Sequence** on the fly to give context.
-- _Silent, Broken, Red, Crimson, Shattered, Frozen, Burning, Dark, Blind, Hidden, Lost, Ancient, Hollow, Swift._
+- **Intro Template:**
+ - _Speaker:_ Faction Representative (e.g., General Kael for Iron Legion requests).
+ - _Text:_ "Commander, [Faction] scouts report [EnemyType] activity in the [Biome]. We need you to [ObjectiveVerb] them immediately."
+- **Outro Template:**
+ - _Speaker:_ Faction Representative.
+ - _Text:_ "Target neutralized. Funds transferred. Good hunting, Explorer."
-Legacy Logic (Series Generation):
-The generator checks the player's MissionHistory.
+_The Generator creates these JSON blobs and registers them with the NarrativeManager using a generated ID (e.g., NARRATIVE_SIDE_123_INTRO)._
-- If "Operation: Silent Viper" was completed previously, the new mission is named "Operation: Silent Viper **II**".
-- This creates the illusion of persistent, ongoing military campaigns.
+### **C. Biome & Hazards**
-### **B. Biome Selection**
+- Select Unlocked Region.
+- **Roll Density:** Low (Scouting), Medium (Standard), High (Warzone).
+- **Roll Hazards:** 30% chance to add a Biome-specific hazard (e.g., HAZARD_POISON_SPORES for Fungal Caves) to make the tactical layer interesting.
-- Randomly select from **Unlocked Regions**.
-- _Weighting:_ 40% chance for the most recently unlocked region (to keep content relevant).
+### **D. Mission Archetypes (Objectives)**
-### **C. Mission Archetypes (Objectives)**
-
-The generator picks one of four templates and hydrates it with specific data.
-
-#### **1. Skirmish (Standard Combat)**
+#### **1. Skirmish (Combat Focus)**
- **Objective:** ELIMINATE_ALL.
-- **Description:** "Clear the sector of hostile forces."
-- **Config:** Standard room count, Medium density.
-- **Turn Limit:** None.
+- **Icon:** assets/icons/mission_sword.png.
+- **Config:** Medium Density, Standard Rewards.
-#### **2. Salvage (Loot Run)**
+#### **2. Salvage (Resource Focus)**
- **Objective:** INTERACT with 3-5 "Supply Crates".
-- **Description:** "Recover lost supplies before the enemy secures them."
-- **Config:** High density of obstacles/cover.
-- **Reward Bonus:** Higher chance for ITEMS or MATERIALS.
+- **Icon:** assets/icons/mission_coin.png.
+- **Config:** High Cover Density.
+- **Reward Bonus:** Items/Materials.
-#### **3. Assassination (Elite Hunt)**
+#### **3. Assassination (Boss Focus)**
-- **Objective:** ELIMINATE_UNIT (Specific Target ID).
-- **Description:** "A High-Value Target has been spotted. Eliminate them."
-- **Config:** Spawns a named Elite Unit (e.g., "Krag the Breaker") with +50% Stats.
-- **Reward Bonus:** High CURRENCY payout.
+- **Objective:** ELIMINATE_UNIT (Named Elite).
+- **Icon:** assets/icons/mission_skull.png.
+- **Config:** Spawns a Unit with stats: { hp: 200%, attack: 150% } and a unique name (e.g., "Gorgon the Rot").
+- **Reward Bonus:** High Currency.
-#### **4. Recon (Scouting)**
+#### **4. Recon (Speed Focus)**
-- **Objective:** REACH_ZONE (3 separate zones on the map).
-- **Description:** "Survey the designated coordinates."
-- **Config:** Large map size, Low enemy density (Mobility focus).
-- **Turn Limit:** Tight (Speed is key).
+- **Objective:** REACH_ZONE (3 Zones).
+- **Icon:** assets/icons/mission_search.png.
+- **Config:** Large Map, Low Density.
+- **Turn Limit:** 8 Turns.
-## **3. Scaling & Rewards**
-
-### **Difficulty Tiers**
-
-The generator adjusts difficulty_tier in the config, which the GameLoop uses to scale enemy stats.
-
-| Tier | Name | Enemy Lvl | Reward Multiplier |
-| :--- | :------- | :-------- | :---------------- |
-| 1 | Recon | 1-2 | 1.0x |
-| 2 | Patrol | 3-4 | 1.5x |
-| 3 | Conflict | 5-6 | 2.5x |
-| 4 | War | 7-8 | 4.0x |
-| 5 | Suicide | 9-10 | 6.0x |
-
-### **Reward Generation**
-
-Rewards are calculated dynamically:
+## **3. Rewards Scaling**
- **Currency:** Base (50) _ TierMultiplier _ Random(0.8, 1.2).
-- **Items:** 20% chance per Tier to drop a Chest Key or Item.
-- **Reputation:** +10 Reputation with the Region's owner (e.g., Missions in Rusting Wastes give +Cogwork Rep).
+- **Reputation:** +10 to Patron Faction.
+- **Loot:** 20% chance for a Chest Key or Consumable Bundle.
-## **4. Example Generated JSON**
+## **4. Implementation Prompt**
+
+"Create src/systems/MissionGenerator.js.
+
+1. **Templates:** Define text templates for Intros/Outros for each Faction Leader.
+2. **Generate:** Implement generateSideOp(tier, regionId).
+ - Construct the Mission object.
+ - Construct the Narrative objects (Intro/Outro) dynamically.
+ - Register Narratives to NarrativeManager (or return them to be registered).
+3. **Hazards:** Map Biomes to valid Hazards and roll for inclusion."
+
+## **5. Detailed Example (Generated Output)**
+
+This is what the output JSON looks like—indistinguishable from a Story Mission to the Game Engine.
```json
{
@@ -104,35 +94,74 @@ Rewards are calculated dynamically:
"type": "SIDE_QUEST",
"config": {
"title": "Operation: Crimson Viper II",
- "description": "The target escaped last time. Finish the job in the Crystal Spires.",
+ "description": "General Kael requests the elimination of a high-value target in the Crystal Spires.",
"difficulty_tier": 2,
- "recommended_level": 3
+ "recommended_level": 3,
+ "icon": "assets/icons/mission_skull.png"
},
"biome": {
"type": "BIOME_CRYSTAL_SPIRES",
"generator_config": {
"seed_type": "RANDOM",
- "size": { "x": 20, "y": 12, "z": 20 },
- "room_count": 6
+ "seed": 982374,
+ "size": { "x": 22, "y": 12, "z": 22 },
+ "room_count": 0,
+ "density": "MEDIUM"
+ },
+ "hazards": ["HAZARD_GRAVITY_FLUX"]
+ },
+ "deployment": {
+ "squad_size_limit": 4
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_SIDE_170932_INTRO",
+ "outro_success": "NARRATIVE_SIDE_170932_OUTRO",
+
+ "_dynamic_data": {
+ "intro": {
+ "id": "NARRATIVE_SIDE_170932_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "portrait": "assets/images/portraits/vanguard.png",
+ "text": "Scouts have spotted 'Krag the Breaker' in this sector. He's dangerous. Put him down.",
+ "next": "END"
+ }
+ ]
+ },
+ "outro": {
+ "id": "NARRATIVE_SIDE_170932_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "portrait": "assets/images/portraits/vanguard.png",
+ "text": "Target confirmed KIA. Good work, soldier. Funds transferred.",
+ "next": "END"
+ }
+ ]
+ }
}
},
- "deployment": { "squad_size_limit": 4 },
"objectives": {
"primary": [
{
"id": "OBJ_HUNT",
"type": "ELIMINATE_UNIT",
- "target_def_id": "ENEMY_ELITE_ECHO",
- "description": "Eliminate the Aether Echo Prime."
+ "target_def_id": "ENEMY_ELITE_ECHO_KRAG",
+ "description": "Eliminate Krag the Breaker."
}
]
},
"rewards": {
"guaranteed": {
- "xp": 300,
- "currency": { "aether_shards": 120 }
+ "xp": 350,
+ "currency": { "aether_shards": 150 }
},
- "faction_reputation": { "ARCANE_DOMINION": 15 }
+ "faction_reputation": { "IRON_LEGION": 15 }
},
"expiresIn": 3
}
diff --git a/specs/initial-mission-registry.json b/specs/initial-mission-registry.json
new file mode 100644
index 0000000..7324a48
--- /dev/null
+++ b/specs/initial-mission-registry.json
@@ -0,0 +1,2010 @@
+{
+ "comment": "Complete High-Fidelity Campaign Registry containing Missions 01-32 and associated Narratives.",
+ "missions": [
+ {
+ "id": "MISSION_ACT1_01",
+ "type": "STORY",
+ "config": {
+ "title": "Protocol: First Descent",
+ "description": "Establish a foothold in the Rusting Wastes. Director Vorn is monitoring your progress.",
+ "difficulty_tier": 1,
+ "recommended_level": 1,
+ "icon": "assets/icons/mission_sword.png"
+ },
+ "biome": {
+ "type": "BIOME_RUSTING_WASTES",
+ "generator_config": {
+ "seed_type": "FIXED",
+ "seed": 12345,
+ "size": { "x": 20, "y": 6, "z": 20 }
+ }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_01_INTRO",
+ "outro_success": "NARRATIVE_01_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_2",
+ "type": "ELIMINATE_ENEMIES",
+ "target_count": 2,
+ "description": "Eliminate 2 Shardborn Sentinels."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": {
+ "unlocks": ["CLASS_TINKER"],
+ "currency": { "aether_shards": 100 }
+ }
+ }
+ },
+ {
+ "id": "MISSION_ACT1_02",
+ "type": "STORY",
+ "config": {
+ "title": "The Signal",
+ "description": "A subspace relay in the Fungal Caves is jamming trade routes. Clear the interference.",
+ "difficulty_tier": 1,
+ "recommended_level": 2,
+ "icon": "assets/icons/mission_signal.png"
+ },
+ "biome": {
+ "type": "BIOME_FUNGAL_CAVES",
+ "generator_config": {
+ "seed_type": "RANDOM",
+ "room_count": 5,
+ "density": "MEDIUM"
+ },
+ "hazards": ["HAZARD_POISON_SPORES"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_02_INTRO",
+ "outro_success": "NARRATIVE_02_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_FIX_RELAY",
+ "type": "INTERACT",
+ "target_object_id": "OBJ_SIGNAL_RELAY",
+ "description": "Reboot the Ancient Signal Relay."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": {
+ "unlocks": ["CLASS_SCAVENGER"],
+ "currency": { "aether_shards": 150 }
+ },
+ "faction_reputation": { "GOLDEN_EXCHANGE": 25 }
+ }
+ },
+ {
+ "id": "MISSION_ACT1_03",
+ "type": "STORY",
+ "config": {
+ "title": "The Foundation",
+ "description": "Shardborn sappers are undermining the Hub's cliffside foundation. Defend the perimeter.",
+ "difficulty_tier": 2,
+ "recommended_level": 3,
+ "icon": "assets/icons/mission_shield.png"
+ },
+ "biome": {
+ "type": "BIOME_CONTESTED_FRONTIER",
+ "generator_config": { "seed_type": "RANDOM", "density": "CHOKE_POINT" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_03_INTRO",
+ "outro_success": "NARRATIVE_03_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_HOLD",
+ "type": "SURVIVE",
+ "turn_limit": 5,
+ "description": "Prevent the Shardborn from reaching the cliff edge for 5 turns."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": {
+ "unlocks": ["CLASS_VANGUARD"],
+ "currency": { "aether_shards": 200 }
+ },
+ "faction_reputation": { "IRON_LEGION": 25 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_04",
+ "type": "STORY",
+ "config": {
+ "title": "The First Truth",
+ "description": "Deep scans indicate a survivor in the Void-Seep. Retrieval is mandatory. Extreme Caution advised.",
+ "difficulty_tier": 3,
+ "recommended_level": 4,
+ "icon": "assets/icons/mission_void.png"
+ },
+ "biome": {
+ "type": "BIOME_VOID_SEEP",
+ "generator_config": {
+ "seed_type": "RANDOM",
+ "size": { "x": 20, "y": 8, "z": 20 },
+ "room_count": 1,
+ "density": "ARENA"
+ },
+ "hazards": ["HAZARD_VOID_RIFTS"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_04_INTRO",
+ "outro_success": "NARRATIVE_04_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_SURVIVE",
+ "type": "SURVIVE",
+ "turn_limit": 8,
+ "description": "Survive the Shardborn assault."
+ },
+ {
+ "id": "OBJ_EXTRACT",
+ "type": "REACH_ZONE",
+ "zone_coords": { "x": 10, "y": 1, "z": 10 },
+ "description": "Reach the Extraction Elevator."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": {
+ "unlocks": ["CLASS_BATTLE_MAGE"],
+ "currency": { "ancient_cores": 1 }
+ }
+ }
+ },
+ {
+ "id": "MISSION_STORY_05",
+ "type": "STORY",
+ "config": {
+ "title": "Line in the Sand",
+ "description": "Tensions between the Iron Legion and Golden Exchange have boiled over. Pick a side and intervene.",
+ "difficulty_tier": 3,
+ "recommended_level": 5,
+ "icon": "assets/icons/mission_conflict.png"
+ },
+ "biome": {
+ "type": "BIOME_CONTESTED_FRONTIER",
+ "generator_config": { "seed_type": "RANDOM", "density": "SKIRMISH" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_05_INTRO",
+ "outro_success": "NARRATIVE_05_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_DEFEAT_COMMANDER",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "DYNAMIC_COMMANDER",
+ "description": "Defeat the opposing Commander."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "currency": { "aether_shards": 500 } } }
+ },
+ {
+ "id": "MISSION_STORY_06",
+ "type": "STORY",
+ "config": {
+ "title": "Unstable Aether",
+ "description": "Arch-Librarian Elara needs raw samples from the Crystal Spires. Warning: The crystals are harmonizing and will detonate soon.",
+ "difficulty_tier": 3,
+ "recommended_level": 6,
+ "icon": "assets/icons/mission_crystal.png"
+ },
+ "biome": {
+ "type": "BIOME_CRYSTAL_SPIRES",
+ "hazards": ["HAZARD_EXPLOSION_WARNING"],
+ "generator_config": {
+ "seed_type": "RANDOM",
+ "size": { "x": 18, "y": 15, "z": 18 },
+ "density": "LOW"
+ }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_06_INTRO",
+ "outro_success": "NARRATIVE_06_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_COLLECT",
+ "type": "INTERACT",
+ "target_object_id": "OBJ_VOLATILE_CRYSTAL",
+ "target_count": 3,
+ "description": "Stabilize 3 Volatile Crystals before Turn 6."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": {
+ "unlocks": ["MASTERY_AETHER_WEAVER"],
+ "currency": { "aether_shards": 400, "ancient_cores": 1 }
+ },
+ "faction_reputation": { "ARCANE_DOMINION": 50 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_07",
+ "type": "STORY",
+ "config": {
+ "title": "Factory Reset",
+ "description": "Director Vorn has located a dormant munitions factory. Reboot the main generator while holding off the scavengers.",
+ "difficulty_tier": 3,
+ "recommended_level": 6,
+ "icon": "assets/icons/mission_gear.png"
+ },
+ "biome": {
+ "type": "BIOME_RUSTING_WASTES",
+ "generator_config": {
+ "seed_type": "RANDOM",
+ "room_count": 6,
+ "density": "HIGH"
+ }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_07_INTRO",
+ "outro_success": "NARRATIVE_07_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_CONSOLE",
+ "type": "INTERACT",
+ "target_object_id": "OBJ_GENERATOR_CONSOLE",
+ "target_count": 3,
+ "description": "Activate the 3 Power Consoles."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": {
+ "unlocks": ["MASTERY_TINKER"],
+ "currency": { "aether_shards": 500, "ancient_cores": 2 }
+ },
+ "faction_reputation": { "COGWORK_CONCORD": 50 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_08",
+ "type": "STORY",
+ "config": {
+ "title": "The Construct",
+ "description": "Vorn's factory reset woke up something big. A prototype siege engine is rampaging through Sector 9.",
+ "difficulty_tier": 4,
+ "recommended_level": 7,
+ "icon": "assets/icons/mission_boss_tank.png"
+ },
+ "biome": {
+ "type": "BIOME_RUSTING_WASTES",
+ "generator_config": { "density": "ARENA" },
+ "hazards": ["HAZARD_OIL_SLICKS"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_08_INTRO",
+ "outro_success": "NARRATIVE_08_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_BOSS",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "ENEMY_BOSS_CONSTRUCT",
+ "description": "Destroy the Siege Construct."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": {
+ "items": ["ITEM_TITAN_PLATING"],
+ "currency": { "ancient_cores": 3 }
+ },
+ "faction_reputation": { "COGWORK_CONCORD": 75 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_09",
+ "type": "STORY",
+ "config": {
+ "title": "Data Recovery",
+ "description": "A Cogwork airship carrying factory logs crashed in the Frontier. Secure the drive before bandits do.",
+ "difficulty_tier": 3,
+ "recommended_level": 7,
+ "icon": "assets/icons/mission_intel.png"
+ },
+ "biome": {
+ "type": "BIOME_CONTESTED_FRONTIER",
+ "generator_config": {
+ "seed_type": "RANDOM",
+ "size": { "x": 30, "y": 4, "z": 30 }
+ }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_09_INTRO",
+ "outro_success": "NARRATIVE_09_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_THIEF",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "ENEMY_BANDIT_LEADER",
+ "description": "Retrieve the drive from the Bandit Leader."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "items": ["ITEM_DATA_DRIVE_RELIC"] } }
+ },
+ {
+ "id": "MISSION_STORY_10",
+ "type": "STORY",
+ "config": {
+ "title": "Hold the Line",
+ "description": "General Kael has ordered a defense of Bridge 4. The Shardborn are pushing hard. Do not let them cross.",
+ "difficulty_tier": 3,
+ "recommended_level": 7,
+ "icon": "assets/icons/mission_shield.png"
+ },
+ "biome": {
+ "type": "BIOME_CONTESTED_FRONTIER",
+ "generator_config": { "density": "CHOKE_POINT" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_10_INTRO",
+ "outro_success": "NARRATIVE_10_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_SURVIVE",
+ "type": "SURVIVE",
+ "turn_limit": 8,
+ "description": "Hold the bridge for 8 Turns."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "items": ["ITEM_IRON_TOWER_SHIELD"] },
+ "faction_reputation": { "IRON_LEGION": 40 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_11",
+ "type": "STORY",
+ "config": {
+ "title": "Breach and Clear",
+ "description": "We've tracked the assault force back to a nest in the Rusting Wastes. Burn it out.",
+ "difficulty_tier": 3,
+ "recommended_level": 8,
+ "icon": "assets/icons/mission_sword.png"
+ },
+ "biome": {
+ "type": "BIOME_RUSTING_WASTES",
+ "hazards": ["HAZARD_NEST_SPORES"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_11_INTRO",
+ "outro_success": "NARRATIVE_11_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_CLEAR",
+ "type": "ELIMINATE_ALL",
+ "description": "Eliminate all hostiles."
+ },
+ {
+ "id": "OBJ_SPAWNERS",
+ "type": "DESTROY_OBJECTS",
+ "tag": "NEST_SPAWNER",
+ "description": "Destroy 3 Nest Spawners."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "unlocks": ["BLUEPRINT_HEAVY_PLATE_MK2"] },
+ "faction_reputation": { "IRON_LEGION": 50 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_12",
+ "type": "STORY",
+ "config": {
+ "title": "The Warlord",
+ "description": "General Kael has identified the Shardborn command unit. We are cutting off the head of the snake.",
+ "difficulty_tier": 4,
+ "recommended_level": 8,
+ "icon": "assets/icons/mission_boss_skull.png"
+ },
+ "biome": {
+ "type": "BIOME_VOID_SEEP",
+ "generator_config": { "density": "ARENA" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_12_INTRO",
+ "outro_success": "NARRATIVE_12_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_BOSS",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "ENEMY_BOSS_WARLORD",
+ "description": "Eliminate the Shardborn Warlord."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "unlocks": ["MASTERY_VANGUARD"] },
+ "faction_reputation": { "IRON_LEGION": 75 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_13",
+ "type": "STORY",
+ "config": {
+ "title": "Supply Run",
+ "description": "Baroness Seraphina has a 'priority shipment' moving through the Spires. Ensure the Mule Bot reaches the extraction point.",
+ "difficulty_tier": 2,
+ "recommended_level": 6,
+ "icon": "assets/icons/mission_escort.png"
+ },
+ "biome": {
+ "type": "BIOME_CRYSTAL_SPIRES",
+ "generator_config": { "density": "LINEAR_PATH" },
+ "hazards": ["HAZARD_UNSTABLE_PLATFORMS"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_13_INTRO",
+ "outro_success": "NARRATIVE_13_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_ESCORT",
+ "type": "ESCORT",
+ "target_def_id": "UNIT_MULE_BOT",
+ "description": "Protect the Mule Bot."
+ },
+ {
+ "id": "OBJ_EXIT",
+ "type": "REACH_ZONE",
+ "zone_coords": { "x": 22, "y": 5, "z": 5 },
+ "description": "Guide the Mule Bot to the Sky-Dock."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "items": ["ITEM_MERCENARY_CONTRACT"] },
+ "faction_reputation": { "GOLDEN_EXCHANGE": 40 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_14",
+ "type": "STORY",
+ "config": {
+ "title": "Hostile Takeover",
+ "description": "Red Vulture bandits have seized a lucrative mine. The Baroness wants it back intact.",
+ "difficulty_tier": 3,
+ "recommended_level": 7,
+ "icon": "assets/icons/mission_coin.png"
+ },
+ "biome": {
+ "type": "BIOME_FUNGAL_CAVES",
+ "generator_config": { "density": "MEDIUM" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_14_INTRO",
+ "outro_success": "NARRATIVE_14_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_CLEAR",
+ "type": "ELIMINATE_ALL",
+ "description": "Remove the Red Vulture presence."
+ }
+ ],
+ "secondary": [
+ {
+ "id": "OBJ_PRESERVE",
+ "type": "SQUAD_SURVIVAL",
+ "min_alive": 3,
+ "description": "Preserve Mining Equipment (Do not destroy)."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "currency": { "aether_shards": 700 } },
+ "faction_reputation": { "GOLDEN_EXCHANGE": 45 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_15",
+ "type": "STORY",
+ "config": {
+ "title": "The Auction",
+ "description": "An assassination attempt is planned during Seraphina's auction. Protect the VIP.",
+ "difficulty_tier": 4,
+ "recommended_level": 8,
+ "icon": "assets/icons/mission_vip.png"
+ },
+ "biome": {
+ "type": "BIOME_CONTESTED_FRONTIER",
+ "generator_config": { "density": "OPEN_FIELD" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_15_INTRO",
+ "outro_success": "NARRATIVE_15_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_PROTECT",
+ "type": "ESCORT",
+ "target_def_id": "UNIT_NPC_SERAPHINA",
+ "description": "Protect Baroness Seraphina."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "items": ["ITEM_MARKET_PASS_PLATINUM"] },
+ "faction_reputation": { "GOLDEN_EXCHANGE": 75 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_16",
+ "type": "STORY",
+ "config": {
+ "title": "Cleansing the Grove",
+ "description": "Elder Fira senses corruption in the Mycelial Heart. Purge the rot before it spreads.",
+ "difficulty_tier": 3,
+ "recommended_level": 7,
+ "icon": "assets/icons/mission_leaf.png"
+ },
+ "biome": {
+ "type": "BIOME_FUNGAL_CAVES",
+ "hazards": ["HAZARD_REGROWING_VINES"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_16_INTRO",
+ "outro_success": "NARRATIVE_16_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_PURIFY",
+ "type": "INTERACT",
+ "target_object_id": "OBJ_CORRUPTED_ROOT",
+ "target_count": 5,
+ "description": "Purify 5 Corrupted Roots."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "unlocks": ["BLUEPRINT_REGEN_RING"] },
+ "faction_reputation": { "SILENT_SANCTUARY": 40 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_17",
+ "type": "STORY",
+ "config": {
+ "title": "Lost Spirits",
+ "description": "Sanctuary pilgrims are lost in the mist. Find them before the Echoes do.",
+ "difficulty_tier": 3,
+ "recommended_level": 7,
+ "icon": "assets/icons/mission_search.png"
+ },
+ "biome": { "type": "BIOME_CRYSTAL_SPIRES", "hazards": ["HAZARD_FOG"] },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_17_INTRO",
+ "outro_success": "NARRATIVE_17_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_FIND",
+ "type": "INTERACT",
+ "target_object_id": "OBJ_LOST_PILGRIM",
+ "target_count": 3,
+ "description": "Locate 3 Pilgrims."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "items": ["ITEM_SPIRIT_LANTERN"] },
+ "faction_reputation": { "SILENT_SANCTUARY": 50 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_18",
+ "type": "STORY",
+ "config": {
+ "title": "The Source of Rot",
+ "description": "Destroy the massive Corruption Tumor feeding on the Void to save the forest.",
+ "difficulty_tier": 4,
+ "recommended_level": 8,
+ "icon": "assets/icons/mission_boss_plant.png"
+ },
+ "biome": { "type": "BIOME_VOID_SEEP", "hazards": ["HAZARD_SPORE_VENTS"] },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_18_INTRO",
+ "outro_success": "NARRATIVE_18_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_BOSS",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "ENEMY_BOSS_TUMOR",
+ "description": "Destroy the Corruption Heart."
+ }
+ ]
+ },
+ "rewards": {
+ "guaranteed": { "unlocks": ["MASTERY_CUSTODIAN"] },
+ "faction_reputation": { "SILENT_SANCTUARY": 75 }
+ }
+ },
+ {
+ "id": "MISSION_STORY_19",
+ "type": "STORY",
+ "config": {
+ "title": "Diplomatic Immunity",
+ "description": "Peace talks have collapsed. Escort Ambassador sol'Ria through the crossfire.",
+ "difficulty_tier": 4,
+ "recommended_level": 9,
+ "icon": "assets/icons/mission_diplomat.png"
+ },
+ "biome": {
+ "type": "BIOME_CONTESTED_FRONTIER",
+ "generator_config": { "density": "WARZONE" },
+ "hazards": ["HAZARD_ARTILLERY_STRIKES"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_19_INTRO",
+ "outro_success": "NARRATIVE_19_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_ESCORT",
+ "type": "ESCORT",
+ "target_def_id": "UNIT_NPC_DIPLOMAT",
+ "description": "Protect Ambassador sol'Ria."
+ },
+ {
+ "id": "OBJ_EXIT",
+ "type": "REACH_ZONE",
+ "zone_coords": { "x": 15, "y": 1, "z": 28 },
+ "description": "Reach the Neutral Dropship."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "items": ["ITEM_PEACEKEEPER_BADGE"] } }
+ },
+ {
+ "id": "MISSION_STORY_20",
+ "type": "STORY",
+ "config": {
+ "title": "Sabotage",
+ "description": "General Kael plans to bomb the Concord factory. Vorn wants you to disarm it. Choose a side.",
+ "difficulty_tier": 4,
+ "recommended_level": 9,
+ "icon": "assets/icons/mission_bomb.png"
+ },
+ "biome": {
+ "type": "BIOME_RUSTING_WASTES",
+ "hazards": ["HAZARD_LIVE_WIRES"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_20_INTRO",
+ "outro_success": "NARRATIVE_20_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_CHOICE",
+ "type": "CUSTOM_CHECK",
+ "description": "Arm (Legion) OR Disarm (Concord) 3 Bomb Sites."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "currency": { "aether_shards": 800 } } }
+ },
+ {
+ "id": "MISSION_STORY_21",
+ "type": "STORY",
+ "config": {
+ "title": "Resource War",
+ "description": "Dominion and Exchange forces are fighting over a pure Aether Geode. Capture and hold it.",
+ "difficulty_tier": 4,
+ "recommended_level": 9,
+ "icon": "assets/icons/mission_flag.png"
+ },
+ "biome": {
+ "type": "BIOME_CRYSTAL_SPIRES",
+ "hazards": ["HAZARD_GRAVITY_FLUX"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_21_INTRO",
+ "outro_success": "NARRATIVE_21_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KOTH",
+ "type": "KING_OF_THE_HILL",
+ "target_score": 100,
+ "description": "Control the Geode Platform."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "items": ["ITEM_AETHER_LENS"] } }
+ },
+ {
+ "id": "MISSION_STORY_22",
+ "type": "STORY",
+ "config": {
+ "title": "The False Prophet",
+ "description": "A charlatan is corrupting the Sanctuary monks. Silence him before he poisons their minds.",
+ "difficulty_tier": 4,
+ "recommended_level": 9,
+ "icon": "assets/icons/mission_skull.png"
+ },
+ "biome": { "type": "BIOME_FUNGAL_CAVES" },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_22_INTRO",
+ "outro_success": "NARRATIVE_22_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_LEADER",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "ENEMY_CULTIST_LEADER",
+ "description": "Eliminate the False Prophet."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "items": ["ITEM_CORRUPTED_IDOL"] } }
+ },
+ {
+ "id": "MISSION_STORY_23",
+ "type": "STORY",
+ "config": {
+ "title": "Spirequake",
+ "description": "The Spire is pulsing, causing massive earthquakes. Evacuate civilians from the falling debris.",
+ "difficulty_tier": 4,
+ "recommended_level": 10,
+ "icon": "assets/icons/mission_run.png"
+ },
+ "biome": {
+ "type": "BIOME_CONTESTED_FRONTIER",
+ "hazards": ["HAZARD_FALLING_DEBRIS"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_23_INTRO",
+ "outro_success": "NARRATIVE_23_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_EVAC",
+ "type": "REACH_ZONE",
+ "zone_coords": { "x": 25, "y": 1, "z": 5 },
+ "description": "Reach the Evac Zone."
+ },
+ { "id": "OBJ_SURVIVE", "type": "SURVIVE", "turn_limit": 6 }
+ ]
+ },
+ "rewards": { "guaranteed": { "currency": { "aether_shards": 900 } } }
+ },
+ {
+ "id": "MISSION_STORY_24",
+ "type": "STORY",
+ "config": {
+ "title": "The Ultimatum",
+ "description": "The Shardborn are launching a massive surface invasion. Hold the line at all costs.",
+ "difficulty_tier": 5,
+ "recommended_level": 10,
+ "icon": "assets/icons/mission_shield.png"
+ },
+ "biome": {
+ "type": "BIOME_CONTESTED_FRONTIER",
+ "generator_config": { "density": "EXTREME" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_24_INTRO",
+ "outro_success": "NARRATIVE_24_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_ALL",
+ "type": "ELIMINATE_ALL",
+ "description": "Defeat the invasion force."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "currency": { "ancient_cores": 3 } } }
+ },
+ {
+ "id": "MISSION_STORY_25",
+ "type": "STORY",
+ "config": {
+ "title": "Into the Dark",
+ "description": "The Alliance is reforged. The way to the core is open. Secure the elevator shaft.",
+ "difficulty_tier": 5,
+ "recommended_level": 11,
+ "icon": "assets/icons/mission_elevator.png"
+ },
+ "biome": { "type": "BIOME_VOID_SEEP" },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_25_INTRO",
+ "outro_success": "NARRATIVE_25_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_SECURE_ELEVATOR",
+ "type": "REACH_ZONE",
+ "zone_coords": { "x": 10, "y": 1, "z": 18 },
+ "description": "Reach the Ancient Elevator."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "unlocks": ["ACCESS_ACT_4"] } }
+ },
+ {
+ "id": "MISSION_STORY_26",
+ "type": "STORY",
+ "config": {
+ "title": "Layer 1: The Iron Shell",
+ "description": "Breach the Spire's outer defenses. Destroy the Shield Generators.",
+ "difficulty_tier": 5,
+ "recommended_level": 11,
+ "icon": "assets/icons/mission_target.png"
+ },
+ "biome": {
+ "type": "BIOME_RUSTING_WASTES",
+ "generator_config": { "density": "BOSS_RUSH" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_26_INTRO",
+ "outro_success": "NARRATIVE_26_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_DESTROY_GENS",
+ "type": "DESTROY_OBJECTS",
+ "tag": "SHIELD_GENERATOR",
+ "target_count": 4,
+ "description": "Destroy 4 Shield Generators."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "currency": { "ancient_cores": 2 } } }
+ },
+ {
+ "id": "MISSION_STORY_27",
+ "type": "STORY",
+ "config": {
+ "title": "Layer 2: The Crystal Heart",
+ "description": "Navigate a vertical maze of shifting platforms to reach the inner sanctum.",
+ "difficulty_tier": 5,
+ "recommended_level": 12,
+ "icon": "assets/icons/mission_climb.png"
+ },
+ "biome": {
+ "type": "BIOME_CRYSTAL_SPIRES",
+ "hazards": ["HAZARD_GRAVITY_FLUX_HARD"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_27_INTRO",
+ "outro_success": "NARRATIVE_27_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_ASCEND",
+ "type": "REACH_ZONE",
+ "zone_coords": { "x": 15, "y": 8, "z": 15 },
+ "description": "Reach the Upper Platform."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "currency": { "ancient_cores": 2 } } }
+ },
+ {
+ "id": "MISSION_STORY_28",
+ "type": "STORY",
+ "config": {
+ "title": "Layer 3: The Rotting Soul",
+ "description": "Survive the toxic atmosphere and hunt down the Elite Spire Titan.",
+ "difficulty_tier": 5,
+ "recommended_level": 12,
+ "icon": "assets/icons/mission_skull_poison.png"
+ },
+ "biome": {
+ "type": "BIOME_FUNGAL_CAVES",
+ "hazards": ["HAZARD_TOXIC_ATMOSPHERE"]
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_28_INTRO",
+ "outro_success": "NARRATIVE_28_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_TITAN",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "ENEMY_ELITE_SPORE_TITAN",
+ "description": "Eliminate the Spore Titan."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "currency": { "ancient_cores": 2 } } }
+ },
+ {
+ "id": "MISSION_STORY_29",
+ "type": "STORY",
+ "config": {
+ "title": "Layer 4: The Void",
+ "description": "Face your shadows. Defeat the dopplegangers of your own squad.",
+ "difficulty_tier": 5,
+ "recommended_level": 13,
+ "icon": "assets/icons/mission_shadow.png"
+ },
+ "biome": { "type": "BIOME_VOID_SEEP" },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_29_INTRO",
+ "outro_success": "NARRATIVE_29_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_SHADOWS",
+ "type": "ELIMINATE_ALL",
+ "description": "Defeat the Shadow Squad."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "items": ["ITEM_VOID_ESSENCE"] } }
+ },
+ {
+ "id": "MISSION_STORY_30",
+ "type": "STORY",
+ "config": {
+ "title": "The Gatekeeper",
+ "description": "The Void Brute Omega stands between you and the Core. It ends here.",
+ "difficulty_tier": 5,
+ "recommended_level": 14,
+ "icon": "assets/icons/mission_boss_gate.png"
+ },
+ "biome": {
+ "type": "BIOME_VOID_SEEP",
+ "generator_config": { "density": "ARENA" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_30_INTRO",
+ "outro_success": "NARRATIVE_30_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_OMEGA",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "ENEMY_BOSS_VOID_BRUTE_OMEGA",
+ "description": "Destroy the Gatekeeper."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "currency": { "ancient_cores": 5 } } }
+ },
+ {
+ "id": "MISSION_STORY_31",
+ "type": "STORY",
+ "config": {
+ "title": "Ascension",
+ "description": "Escort the Synchronization Device to the center of the Core. Don't let it be destroyed.",
+ "difficulty_tier": 5,
+ "recommended_level": 15,
+ "icon": "assets/icons/mission_core.png"
+ },
+ "biome": {
+ "type": "BIOME_CRYSTAL_SPIRES",
+ "generator_config": { "size": { "x": 30, "y": 20, "z": 30 } }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_31_INTRO",
+ "outro_success": "NARRATIVE_31_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_ESCORT_SYNC",
+ "type": "ESCORT",
+ "target_def_id": "UNIT_SYNC_DEVICE",
+ "description": "Protect the Sync Device."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "unlocks": ["LIMIT_BREAK_MAX_LEVEL"] } }
+ },
+ {
+ "id": "MISSION_STORY_32",
+ "type": "STORY",
+ "config": {
+ "title": "The Origin",
+ "description": "The final battle. Defeat the Arch-Corruptor and decide the fate of Aethelgard.",
+ "difficulty_tier": 6,
+ "recommended_level": 15,
+ "icon": "assets/icons/mission_boss_final.png"
+ },
+ "biome": {
+ "type": "BIOME_VOID_SEEP",
+ "generator_config": { "density": "BOSS_ARENA_FINAL" }
+ },
+ "narrative": {
+ "intro_sequence": "NARRATIVE_32_INTRO",
+ "outro_success": "NARRATIVE_32_OUTRO"
+ },
+ "objectives": {
+ "primary": [
+ {
+ "id": "OBJ_KILL_ARCH",
+ "type": "ELIMINATE_UNIT",
+ "target_def_id": "ENEMY_ARCH_CORRUPTOR",
+ "description": "Defeat the Arch-Corruptor."
+ }
+ ]
+ },
+ "rewards": { "guaranteed": { "unlocks": ["NEW_GAME_PLUS"] } }
+ }
+ ],
+ "narratives": [
+ {
+ "id": "NARRATIVE_01_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Explorer. You made it. Good. My sensors are bleeding red in Sector 4.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "TUTORIAL",
+ "speaker": "System",
+ "text": "This is the Deployment Phase. Place your units in the Green Zone.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_01_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Efficient. Take these schematics—you'll need an engineer for what comes next.",
+ "trigger": { "type": "UNLOCK_CLASS", "class_id": "CLASS_TINKER" },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_02_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "Something in the Fungal Caves is scattering our comms signals. Fix it, and I'll open my shop to you.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_02_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "Signal green. You're useful. The Marketplace is now open to you.",
+ "trigger": { "type": "UNLOCK_CLASS", "class_id": "CLASS_SCAVENGER" },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_03_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "The Hub's foundation is cracking. Shardborn sappers are undermining the cliff. Stop them.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_03_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "Cliff secured. The Vanguard stands ready to join your roster.",
+ "trigger": { "type": "UNLOCK_CLASS", "class_id": "CLASS_VANGUARD" },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_04_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Entering Void-Seep Layer. Reality stability: 40%.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "Vanguard",
+ "text": "Command, the Shardborn here... they are wearing torn uniforms. Like ours.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_04_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Arch-Librarian Elara",
+ "text": "The data confirms our fears. The Shardborn are the mutated remnants of the Ancients. But we can learn from them.",
+ "trigger": {
+ "type": "UNLOCK_CLASS",
+ "class_id": "CLASS_BATTLE_MAGE"
+ },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_05_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Iron Legion soldiers have barricaded the road. Merchant mercenaries are preparing to breach.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "CHOICE",
+ "speaker": "Tactical Decision",
+ "text": "Who do you support?",
+ "choices": [
+ {
+ "text": "Side with Legion",
+ "trigger": {
+ "type": "SET_MISSION_VARIANT",
+ "variant": "VS_EXCHANGE"
+ },
+ "next": "END"
+ },
+ {
+ "text": "Side with Exchange",
+ "trigger": {
+ "type": "SET_MISSION_VARIANT",
+ "variant": "VS_LEGION"
+ },
+ "next": "END"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_05_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "The opposing force retreats. The sector is secured.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_06_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Arch-Librarian Elara",
+ "text": "The resonance frequency is spiking. Get the crystals before they explode.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_06_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Arch-Librarian Elara",
+ "text": "Magnificent. I've shared some advanced casting techniques with your Weavers.",
+ "trigger": { "type": "UNLOCK_MASTERY", "class_id": "CLASS_WEAVER" },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_07_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "The factory controls are scattered. Hit three consoles to bypass the safety lock.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_07_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Power levels stabilizing at 200%. Haha! We're back in business!",
+ "trigger": { "type": "UNLOCK_MASTERY", "class_id": "CLASS_TINKER" },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_08_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Slight miscalculation. The factory printed a Series-9 Siege Engine. Dismantle it.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_08_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Take a piece of the plating. It's lighter than steel and twice as strong.",
+ "trigger": { "type": "GIVE_ITEM", "item_id": "ITEM_TITAN_PLATING" },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_09_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "My courier ship went down. Bandits are picking it clean. Get that drive back.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_09_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Drive secure. Encryption intact. Good work.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_10_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "They are coming in force. Dig in. Shields up. Hold until relieved.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_10_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "You held. Take this shield. It stopped a Void Brute once.",
+ "trigger": {
+ "type": "GIVE_ITEM",
+ "item_id": "ITEM_IRON_TOWER_SHIELD"
+ },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_11_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "Defense is just delayed defeat. Now we attack. Burn the nest out.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_11_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "Smoke and scrap. I'm authorizing your squad to use the Mk2 prints.",
+ "trigger": {
+ "type": "UNLOCK_BLUEPRINT",
+ "item_id": "ITEM_HEAVY_PLATE_MK2"
+ },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_12_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "The beast organizing the raids is here. We strike as one iron fist.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_12_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "Target destroyed. I am granting you the rank of Warlord within the Legion.",
+ "trigger": { "type": "UNLOCK_MASTERY", "class_id": "CLASS_VANGUARD" },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_13_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "I have a delicate package moving on a Mule Bot. Get it to the Sky-Dock. Do not drop it.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_13_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "Wonderful! The cargo is intact. Take this mercenary contract as a bonus.",
+ "trigger": {
+ "type": "GIVE_ITEM",
+ "item_id": "ITEM_MERCENARY_CONTRACT"
+ },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_14_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "Bandits have moved into *my* mine. Evict them, but don't damage the equipment.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_14_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "Mine secured. I've deposited your fee. Don't spend it all in one place.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_15_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "Someone wants this Core for free. Keep them off me while I close the deal.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_15_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "Consider us partners. I'm giving you Platinum status at the exchange.",
+ "trigger": {
+ "type": "GIVE_ITEM",
+ "item_id": "ITEM_MARKET_PASS_PLATINUM"
+ },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_16_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "The roots of the Great Tree are drinking poison. Cleanse the nodes.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_16_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "The sap flows clear again. Take this seed; it will mend your wounds.",
+ "trigger": {
+ "type": "UNLOCK_BLUEPRINT",
+ "item_id": "ITEM_REGEN_RING_BLUEPRINT"
+ },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_17_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "My children are lost in the white void. Bring them home before they dissolve.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_17_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "They are shaken, but safe. Light reveals truth; take this lantern.",
+ "trigger": { "type": "GIVE_ITEM", "item_id": "ITEM_SPIRIT_LANTERN" },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_18_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "There. The corruption heart. Burn the rot so the green may grow again.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_18_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "I have taught your healers the ancient rites. They are true Custodians now.",
+ "trigger": {
+ "type": "UNLOCK_MASTERY",
+ "class_id": "CLASS_CUSTODIAN"
+ },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_19_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Ambassador sol'Ria",
+ "text": "This is madness! Get me out of this insanity and the Exchange will pay double!",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_19_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Ambassador sol'Ria",
+ "text": "Neutrality is getting expensive, Explorer. You'll have to pick a side soon.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_20_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "We need to cut the power to the factory.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Don't! That factory is keeping us alive! Help me disarm the charges!",
+ "next": "3"
+ },
+ {
+ "id": "3",
+ "type": "CHOICE",
+ "speaker": "Tactical Decision",
+ "text": "Arm or Disarm?",
+ "choices": [
+ {
+ "text": "Arm (Legion)",
+ "trigger": {
+ "type": "SET_MISSION_VARIANT",
+ "variant": "LEGION_SIDE"
+ },
+ "next": "END"
+ },
+ {
+ "text": "Disarm (Concord)",
+ "trigger": {
+ "type": "SET_MISSION_VARIANT",
+ "variant": "CONCORD_SIDE"
+ },
+ "next": "END"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_20_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "The operation is complete. Your allegiance has been noted.",
+ "trigger": {
+ "type": "MODIFY_RELATION",
+ "faction": "DYNAMIC_WINNER",
+ "amount": 100
+ },
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_21_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Arch-Librarian Elara",
+ "text": "This Geode belongs in a museum. Secure it.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "Baroness Seraphina",
+ "text": "Finders keepers. My boys are already there.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_21_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Arch-Librarian Elara",
+ "text": "The resonance... it's a map. It points directly to the bottom of the Abyss.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_22_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "A charlatan wearing our robes spreads lies in the lower caves. He claims the rot is a blessing.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "Silence him before he poisons the minds of the young.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_22_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "The false voice is stilled. We found this idol on him... it reeks of the Void.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_23_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Seismic warning! The Spire is pulsing. Debris is raining down on the Frontier.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Get our people out of there! Avoid the red zones!",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_23_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "Casualties minimal. Good work. The readings from that quake... the Spire is waking up.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_24_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "This is it. They are pouring out of every rift. It's an invasion.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "No retreat. No surrender. Hold until the last shell is spent.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_24_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "The wave is broken. But we can't sustain this. We have to take the fight to them.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_25_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Arch-Librarian Elara",
+ "text": "The alliance stands united, for now. The elevator to the Core is ahead.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "I've hacked the locking mechanism. Just get us there alive.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_25_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Access Granted. Descending to Layer 1.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_26_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "Layer 1. The Iron Shell. This is where they build the machines.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_26_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Shield Generators offline. Path to Layer 2 open.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_27_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Arch-Librarian Elara",
+ "text": "Layer 2. The Crystal Heart. The magic here is so dense it's solidifying the air.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_27_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Navigation complete. Path to Layer 3 open.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_28_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Elder Fira",
+ "text": "Layer 3. The Rotting Soul. Do not breathe too deeply here.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_28_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Titan destroyed. Path to Layer 4 open.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_29_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Layer 4. The Void. Warning: Mirror Entities detected.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_29_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Shadows dispersed. Entering Antechamber.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_30_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "General Kael",
+ "text": "The Gatekeeper. Look at the size of that thing.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_30_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Gatekeeper neutralized. The Core is ahead.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_31_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "Director Vorn",
+ "text": "This is it. Get the Sync Device to the center. If it breaks, we fail.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_31_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Synchronization complete. The Arch-Corruptor is manifesting.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_32_INTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Approaching Singularity. Reality integrity critical.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "The Arch-Corruptor",
+ "text": "YOU CANNOT STOP THE STILLNESS. IT IS PEACE.",
+ "next": "END"
+ }
+ ]
+ },
+ {
+ "id": "NARRATIVE_32_OUTRO",
+ "nodes": [
+ {
+ "id": "1",
+ "type": "DIALOGUE",
+ "speaker": "System",
+ "text": "Target destroyed. Spire energy stabilizing.",
+ "next": "2"
+ },
+ {
+ "id": "2",
+ "type": "DIALOGUE",
+ "speaker": "Narrator",
+ "text": "And so, the Explorer stood at the heart of the world, and made their choice...",
+ "trigger": { "type": "GAME_ENDING" },
+ "next": "END"
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/assets/data/missions/mission-schema.md b/src/assets/data/missions/mission-schema.md
index b079090..973d3a7 100644
--- a/src/assets/data/missions/mission-schema.md
+++ b/src/assets/data/missions/mission-schema.md
@@ -28,7 +28,12 @@ This example utilizes every capability of the system.
"title": "Operation: Broken Sky",
"description": "The Iron Legion demands we silence the Shardborn Artillery. Expect heavy resistance.",
"difficulty_tier": 3,
- "recommended_level": 5
+ "recommended_level": 5,
+ "boss_config": {
+ "target_def_id": "ENEMY_BOSS_ARTILLERY",
+ "name": "The Great Cannoneer",
+ "stats": { "hp_multiplier": 1.5 }
+ }
},
"biome": {
"type": "BIOME_RUSTING_WASTES",
@@ -62,7 +67,10 @@ This example utilizes every capability of the system.
"action": "PLAY_SEQUENCE",
"sequence_id": "NARRATIVE_BOSS_PHASE_2"
}
- ]
+ ],
+ "_dynamic_data": {
+ "NARRATIVE_ACT1_FINAL_INTRO": { "id": "...", "nodes": [] }
+ }
},
"enemy_spawns": [
{
@@ -151,6 +159,15 @@ This example utilizes every capability of the system.
- **suggested_units**: (Optional) Array of class/unit IDs recommended for this mission. Useful for tutorials to guide player selection. The UI should highlight or recommend these units.
- **tutorial_hint**: (Optional) Text to display as a tutorial overlay during the deployment phase. Should point to UI elements (e.g., "Drag units from the bench to the Green Zone.").
+### **Procedural & Dynamic Fields**
+
+- **boss_config** (in `config`): Used for Assassination 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.
+- **hazards** (in `biome`): Array of hazard IDs (e.g., "HAZARD_SPORES") active in the level.
+- **\_dynamic_data** (in `narrative`): Dictionary containing generated narrative sequences. Keys match IDs in `intro_sequence`, etc. Used to avoid creating 1000s of tiny JSON files for procedural dialogue.
+
### **Enemy Spawns**
- **enemy_spawns**: Array of enemy spawn definitions. Each entry specifies an enemy definition ID and count.
diff --git a/src/assets/data/missions/mission.d.ts b/src/assets/data/missions/mission.d.ts
index 4c2b517..f3f8bb6 100644
--- a/src/assets/data/missions/mission.d.ts
+++ b/src/assets/data/missions/mission.d.ts
@@ -51,6 +51,20 @@ 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_config?: BossConfig;
+}
+
+export interface BossConfig {
+ /** Target Definition ID */
+ target_def_id: string;
+ /** Display Name override */
+ name?: string;
+ /** Stat multipliers */
+ stats?: {
+ hp_multiplier?: number;
+ attack_multiplier?: number;
+ };
}
// --- BIOME / WORLD GEN ---
@@ -133,6 +147,8 @@ export interface MissionNarrative {
outro_failure?: string;
/** Triggers that fire during gameplay */
scripted_events?: ScriptedEvent[];
+ /** Dynamic narrative data generated procedurally */
+ _dynamic_data?: Record;
}
export interface ScriptedEvent {
diff --git a/src/assets/data/missions/mission_act1_01.json b/src/assets/data/missions/mission_act1_01.json
index dfb09b0..49aca23 100644
--- a/src/assets/data/missions/mission_act1_01.json
+++ b/src/assets/data/missions/mission_act1_01.json
@@ -28,7 +28,7 @@
"primary": [
{
"id": "OBJ_KILL_2",
- "type": "ELIMINATE_ENEMIES",
+ "type": "ELIMINATE_ALL",
"target_count": 2,
"description": "Eliminate 2 Shardborn Sentinels."
}
@@ -36,12 +36,10 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "CLASS_TINKER"
- ],
+ "unlocks": ["CLASS_TINKER", "MISSION_ACT1_02"],
"currency": {
"aether_shards": 100
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_act1_02.json b/src/assets/data/missions/mission_act1_02.json
index 3dc3803..954d5a2 100644
--- a/src/assets/data/missions/mission_act1_02.json
+++ b/src/assets/data/missions/mission_act1_02.json
@@ -15,9 +15,7 @@
"room_count": 5,
"density": "MEDIUM"
},
- "hazards": [
- "HAZARD_POISON_SPORES"
- ]
+ "hazards": ["HAZARD_POISON_SPORES"]
},
"narrative": {
"intro_sequence": "NARRATIVE_02_INTRO",
@@ -35,9 +33,7 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "CLASS_SCAVENGER"
- ],
+ "unlocks": ["CLASS_SCAVENGER", "MISSION_ACT1_03"],
"currency": {
"aether_shards": 150
}
@@ -45,5 +41,11 @@
"faction_reputation": {
"GOLDEN_EXCHANGE": 25
}
- }
-}
\ No newline at end of file
+ },
+ "mission_objects": [
+ {
+ "object_id": "OBJ_SIGNAL_RELAY",
+ "placement_strategy": "random_walkable"
+ }
+ ]
+}
diff --git a/src/assets/data/missions/mission_act1_03.json b/src/assets/data/missions/mission_act1_03.json
index edcbf4d..91140a3 100644
--- a/src/assets/data/missions/mission_act1_03.json
+++ b/src/assets/data/missions/mission_act1_03.json
@@ -32,7 +32,13 @@
"rewards": {
"guaranteed": {
"unlocks": [
- "CLASS_VANGUARD"
+ "CLASS_VANGUARD",
+ "UNLOCK_PROCEDURAL_MISSIONS",
+ "MISSION_STORY_04",
+ "MISSION_STORY_07",
+ "MISSION_STORY_10",
+ "MISSION_STORY_13",
+ "MISSION_STORY_16"
],
"currency": {
"aether_shards": 200
@@ -42,4 +48,4 @@
"IRON_LEGION": 25
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_02.json b/src/assets/data/missions/mission_story_02.json
deleted file mode 100644
index 3cfee09..0000000
--- a/src/assets/data/missions/mission_story_02.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "id": "MISSION_STORY_02",
- "type": "STORY",
- "config": {
- "title": "The Signal",
- "description": "A subspace relay in the Fungal Caves is jamming trade routes. Clear the interference.",
- "difficulty_tier": 1,
- "recommended_level": 2,
- "icon": "assets/icons/mission_signal.png"
- },
- "biome": {
- "type": "BIOME_FUNGAL_CAVES",
- "generator_config": {
- "seed_type": "RANDOM",
- "size": { "x": 22, "y": 8, "z": 22 },
- "room_count": 5,
- "density": "MEDIUM"
- },
- "hazards": ["HAZARD_POISON_SPORES"]
- },
- "deployment": {
- "squad_size_limit": 4
- },
- "mission_objects": [
- {
- "object_id": "OBJ_SIGNAL_RELAY",
- "placement_strategy": "center_of_enemy_room"
- }
- ],
- "narrative": {
- "intro_sequence": "NARRATIVE_STORY_02_INTRO",
- "outro_success": "NARRATIVE_STORY_02_OUTRO"
- },
- "objectives": {
- "primary": [
- {
- "id": "OBJ_FIX_RELAY",
- "type": "INTERACT",
- "target_object_id": "OBJ_SIGNAL_RELAY",
- "description": "Reboot the Ancient Signal Relay."
- }
- ],
- "secondary": [
- {
- "id": "OBJ_CLEAR_INFESTATION",
- "type": "ELIMINATE_ALL",
- "description": "Clear all Shardborn from the relay chamber."
- }
- ]
- },
- "rewards": {
- "guaranteed": {
- "xp": 200,
- "currency": { "aether_shards": 150 },
- "unlocks": ["CLASS_SCAVENGER"]
- },
- "faction_reputation": {
- "GOLDEN_EXCHANGE": 25
- }
- }
-}
diff --git a/src/assets/data/missions/mission_story_03.json b/src/assets/data/missions/mission_story_03.json
deleted file mode 100644
index 51e43d4..0000000
--- a/src/assets/data/missions/mission_story_03.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
- "id": "MISSION_STORY_03",
- "type": "STORY",
- "config": {
- "title": "The Buried Library",
- "description": "Recover ancient data from the Crystal Spires. Beware of unstable platforms.",
- "difficulty_tier": 2,
- "recommended_level": 3,
- "icon": "assets/icons/mission_library.png",
- "prerequisites": ["MISSION_STORY_02"]
- },
- "biome": {
- "type": "BIOME_CRYSTAL_SPIRES",
- "generator_config": {
- "seed_type": "RANDOM",
- "size": { "x": 16, "y": 12, "z": 16 },
- "room_count": 0,
- "density": "LOW"
- },
- "hazards": ["HAZARD_GRAVITY_FLUX"]
- },
- "deployment": {
- "squad_size_limit": 4
- },
- "narrative": {
- "intro_sequence": "NARRATIVE_STORY_03_INTRO",
- "outro_success": "NARRATIVE_STORY_03_OUTRO",
- "scripted_events": [
- {
- "trigger": "ON_TURN_START",
- "turn_index": 2,
- "action": "PLAY_SEQUENCE",
- "sequence_id": "NARRATIVE_STORY_03_MID"
- }
- ]
- },
- "objectives": {
- "primary": [
- {
- "id": "OBJ_RECOVER_DATA",
- "type": "INTERACT",
- "target_object_id": "OBJ_DATA_TERMINAL",
- "target_count": 3,
- "description": "Recover 3 Data Fragments from the floating islands."
- }
- ],
- "failure_conditions": [{ "type": "SQUAD_WIPE" }]
- },
- "rewards": {
- "guaranteed": {
- "xp": 350,
- "currency": { "aether_shards": 200, "ancient_cores": 2 },
- "unlocks": ["CLASS_CUSTODIAN", "UNLOCK_PROCEDURAL_MISSIONS"]
- },
- "faction_reputation": {
- "ARCANE_DOMINION": 30
- }
- }
-}
diff --git a/src/assets/data/missions/mission_story_04.json b/src/assets/data/missions/mission_story_04.json
index 0704472..9bd26f1 100644
--- a/src/assets/data/missions/mission_story_04.json
+++ b/src/assets/data/missions/mission_story_04.json
@@ -20,9 +20,7 @@
"room_count": 1,
"density": "ARENA"
},
- "hazards": [
- "HAZARD_VOID_RIFTS"
- ]
+ "hazards": ["HAZARD_VOID_RIFTS"]
},
"narrative": {
"intro_sequence": "NARRATIVE_04_INTRO",
@@ -50,12 +48,10 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "CLASS_BATTLE_MAGE"
- ],
+ "unlocks": ["CLASS_BATTLE_MAGE", "MISSION_STORY_05"],
"currency": {
"ancient_cores": 1
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_05.json b/src/assets/data/missions/mission_story_05.json
index 19ca1eb..7d792ff 100644
--- a/src/assets/data/missions/mission_story_05.json
+++ b/src/assets/data/missions/mission_story_05.json
@@ -33,7 +33,8 @@
"guaranteed": {
"currency": {
"aether_shards": 500
- }
+ },
+ "unlocks": ["MISSION_STORY_06"]
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_06.json b/src/assets/data/missions/mission_story_06.json
index 4063a8e..aed2aaa 100644
--- a/src/assets/data/missions/mission_story_06.json
+++ b/src/assets/data/missions/mission_story_06.json
@@ -51,5 +51,11 @@
"faction_reputation": {
"ARCANE_DOMINION": 50
}
- }
+ },
+ "mission_objects": [
+ {
+ "object_id": "OBJ_VOLATILE_CRYSTAL",
+ "placement_strategy": "random_walkable"
+ }
+ ]
}
\ No newline at end of file
diff --git a/src/assets/data/missions/mission_story_07.json b/src/assets/data/missions/mission_story_07.json
index 66ea268..5d396ba 100644
--- a/src/assets/data/missions/mission_story_07.json
+++ b/src/assets/data/missions/mission_story_07.json
@@ -33,9 +33,7 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "MASTERY_TINKER"
- ],
+ "unlocks": ["MASTERY_TINKER", "MISSION_STORY_08"],
"currency": {
"aether_shards": 500,
"ancient_cores": 2
@@ -44,5 +42,11 @@
"faction_reputation": {
"COGWORK_CONCORD": 50
}
- }
-}
\ No newline at end of file
+ },
+ "mission_objects": [
+ {
+ "object_id": "OBJ_GENERATOR_CONSOLE",
+ "placement_strategy": "random_walkable"
+ }
+ ]
+}
diff --git a/src/assets/data/missions/mission_story_08.json b/src/assets/data/missions/mission_story_08.json
index f06bc12..1d26ed4 100644
--- a/src/assets/data/missions/mission_story_08.json
+++ b/src/assets/data/missions/mission_story_08.json
@@ -13,9 +13,7 @@
"generator_config": {
"density": "ARENA"
},
- "hazards": [
- "HAZARD_OIL_SLICKS"
- ]
+ "hazards": ["HAZARD_OIL_SLICKS"]
},
"narrative": {
"intro_sequence": "NARRATIVE_08_INTRO",
@@ -33,15 +31,14 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_TITAN_PLATING"
- ],
+ "items": ["ITEM_TITAN_PLATING"],
"currency": {
"ancient_cores": 3
- }
+ },
+ "unlocks": ["MISSION_STORY_09"]
},
"faction_reputation": {
"COGWORK_CONCORD": 75
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_09.json b/src/assets/data/missions/mission_story_09.json
index 9a1aa77..fabfad0 100644
--- a/src/assets/data/missions/mission_story_09.json
+++ b/src/assets/data/missions/mission_story_09.json
@@ -35,9 +35,8 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_DATA_DRIVE_RELIC"
- ]
+ "items": ["ITEM_DATA_DRIVE_RELIC"],
+ "unlocks": ["CLASS_FIELD_ENGINEER", "MISSION_STORY_19"]
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_10.json b/src/assets/data/missions/mission_story_10.json
index 066ef27..e21b723 100644
--- a/src/assets/data/missions/mission_story_10.json
+++ b/src/assets/data/missions/mission_story_10.json
@@ -30,12 +30,11 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_IRON_TOWER_SHIELD"
- ]
+ "items": ["ITEM_IRON_TOWER_SHIELD"],
+ "unlocks": ["MISSION_STORY_11"]
},
"faction_reputation": {
"IRON_LEGION": 40
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_11.json b/src/assets/data/missions/mission_story_11.json
index 5e0e1cf..2db4fed 100644
--- a/src/assets/data/missions/mission_story_11.json
+++ b/src/assets/data/missions/mission_story_11.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_RUSTING_WASTES",
- "hazards": [
- "HAZARD_NEST_SPORES"
- ]
+ "hazards": ["HAZARD_NEST_SPORES"]
},
"narrative": {
"intro_sequence": "NARRATIVE_11_INTRO",
@@ -27,20 +25,18 @@
},
{
"id": "OBJ_SPAWNERS",
- "type": "DESTROY_OBJECTS",
- "tag": "NEST_SPAWNER",
- "description": "Destroy 3 Nest Spawners."
+ "type": "ELIMINATE_UNIT",
+ "description": "Destroy 3 Nest Spawners.",
+ "target_def_id": "ENEMY_NEST_SPAWNER"
}
]
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "BLUEPRINT_HEAVY_PLATE_MK2"
- ]
+ "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_12.json b/src/assets/data/missions/mission_story_12.json
index f727e3c..6d88d70 100644
--- a/src/assets/data/missions/mission_story_12.json
+++ b/src/assets/data/missions/mission_story_12.json
@@ -30,12 +30,10 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "MASTERY_VANGUARD"
- ]
+ "unlocks": ["CLASS_WARLORD", "MISSION_STORY_19"]
},
"faction_reputation": {
"IRON_LEGION": 75
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_13.json b/src/assets/data/missions/mission_story_13.json
index c339964..d72238f 100644
--- a/src/assets/data/missions/mission_story_13.json
+++ b/src/assets/data/missions/mission_story_13.json
@@ -13,9 +13,7 @@
"generator_config": {
"density": "LINEAR_PATH"
},
- "hazards": [
- "HAZARD_UNSTABLE_PLATFORMS"
- ]
+ "hazards": ["HAZARD_UNSTABLE_PLATFORMS"]
},
"narrative": {
"intro_sequence": "NARRATIVE_13_INTRO",
@@ -43,12 +41,11 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_MERCENARY_CONTRACT"
- ]
+ "items": ["ITEM_MERCENARY_CONTRACT"],
+ "unlocks": ["MISSION_STORY_14"]
},
"faction_reputation": {
"GOLDEN_EXCHANGE": 40
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_14.json b/src/assets/data/missions/mission_story_14.json
index 90d778c..3ef4969 100644
--- a/src/assets/data/missions/mission_story_14.json
+++ b/src/assets/data/missions/mission_story_14.json
@@ -39,10 +39,11 @@
"guaranteed": {
"currency": {
"aether_shards": 700
- }
+ },
+ "unlocks": ["MISSION_STORY_15"]
},
"faction_reputation": {
"GOLDEN_EXCHANGE": 45
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_15.json b/src/assets/data/missions/mission_story_15.json
index b93521b..934d531 100644
--- a/src/assets/data/missions/mission_story_15.json
+++ b/src/assets/data/missions/mission_story_15.json
@@ -30,12 +30,11 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_MARKET_PASS_PLATINUM"
- ]
+ "items": ["ITEM_MARKET_PASS_PLATINUM"],
+ "unlocks": ["MISSION_STORY_19"]
},
"faction_reputation": {
"GOLDEN_EXCHANGE": 75
}
}
-}
\ 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 3b7a280..2c53c3a 100644
--- a/src/assets/data/missions/mission_story_16.json
+++ b/src/assets/data/missions/mission_story_16.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_FUNGAL_CAVES",
- "hazards": [
- "HAZARD_REGROWING_VINES"
- ]
+ "hazards": ["HAZARD_REGROWING_VINES"]
},
"narrative": {
"intro_sequence": "NARRATIVE_16_INTRO",
@@ -31,12 +29,16 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "BLUEPRINT_REGEN_RING"
- ]
+ "unlocks": ["BLUEPRINT_REGEN_RING", "MISSION_STORY_17"]
},
"faction_reputation": {
"SILENT_SANCTUARY": 40
}
- }
-}
\ No newline at end of file
+ },
+ "mission_objects": [
+ {
+ "object_id": "OBJ_CORRUPTED_ROOT",
+ "placement_strategy": "random_walkable"
+ }
+ ]
+}
diff --git a/src/assets/data/missions/mission_story_17.json b/src/assets/data/missions/mission_story_17.json
index da17a2e..2abf691 100644
--- a/src/assets/data/missions/mission_story_17.json
+++ b/src/assets/data/missions/mission_story_17.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_CRYSTAL_SPIRES",
- "hazards": [
- "HAZARD_FOG"
- ]
+ "hazards": ["HAZARD_FOG"]
},
"narrative": {
"intro_sequence": "NARRATIVE_17_INTRO",
@@ -31,12 +29,17 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_SPIRIT_LANTERN"
- ]
+ "items": ["ITEM_SPIRIT_LANTERN"],
+ "unlocks": ["MISSION_STORY_18"]
},
"faction_reputation": {
"SILENT_SANCTUARY": 50
}
- }
-}
\ No newline at end of file
+ },
+ "mission_objects": [
+ {
+ "object_id": "OBJ_LOST_PILGRIM",
+ "placement_strategy": "random_walkable"
+ }
+ ]
+}
diff --git a/src/assets/data/missions/mission_story_18.json b/src/assets/data/missions/mission_story_18.json
index 824226f..79bfeea 100644
--- a/src/assets/data/missions/mission_story_18.json
+++ b/src/assets/data/missions/mission_story_18.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_VOID_SEEP",
- "hazards": [
- "HAZARD_SPORE_VENTS"
- ]
+ "hazards": ["HAZARD_SPORE_VENTS"]
},
"narrative": {
"intro_sequence": "NARRATIVE_18_INTRO",
@@ -30,12 +28,10 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "MASTERY_CUSTODIAN"
- ]
+ "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_19.json b/src/assets/data/missions/mission_story_19.json
index 5e7bbbe..563c5fb 100644
--- a/src/assets/data/missions/mission_story_19.json
+++ b/src/assets/data/missions/mission_story_19.json
@@ -13,9 +13,7 @@
"generator_config": {
"density": "WARZONE"
},
- "hazards": [
- "HAZARD_ARTILLERY_STRIKES"
- ]
+ "hazards": ["HAZARD_ARTILLERY_STRIKES"]
},
"narrative": {
"intro_sequence": "NARRATIVE_19_INTRO",
@@ -43,9 +41,8 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_PEACEKEEPER_BADGE"
- ]
+ "items": ["ITEM_PEACEKEEPER_BADGE"],
+ "unlocks": ["MISSION_STORY_20"]
}
}
-}
\ 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 edc45c7..6fbbb41 100644
--- a/src/assets/data/missions/mission_story_20.json
+++ b/src/assets/data/missions/mission_story_20.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_RUSTING_WASTES",
- "hazards": [
- "HAZARD_LIVE_WIRES"
- ]
+ "hazards": ["HAZARD_LIVE_WIRES"]
},
"narrative": {
"intro_sequence": "NARRATIVE_20_INTRO",
@@ -22,8 +20,9 @@
"primary": [
{
"id": "OBJ_CHOICE",
- "type": "CUSTOM_CHECK",
- "description": "Arm (Legion) OR Disarm (Concord) 3 Bomb Sites."
+ "type": "INTERACT",
+ "description": "Arm (Legion) OR Disarm (Concord) 3 Bomb Sites.",
+ "target_object_id": "OBJ_BOMB_SITE"
}
]
},
@@ -31,7 +30,14 @@
"guaranteed": {
"currency": {
"aether_shards": 800
- }
+ },
+ "unlocks": ["MISSION_STORY_21"]
}
- }
-}
\ No newline at end of file
+ },
+ "mission_objects": [
+ {
+ "object_id": "OBJ_BOMB_SITE",
+ "placement_strategy": "random_walkable"
+ }
+ ]
+}
diff --git a/src/assets/data/missions/mission_story_21.json b/src/assets/data/missions/mission_story_21.json
index 0826ea7..8937b2e 100644
--- a/src/assets/data/missions/mission_story_21.json
+++ b/src/assets/data/missions/mission_story_21.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_CRYSTAL_SPIRES",
- "hazards": [
- "HAZARD_GRAVITY_FLUX"
- ]
+ "hazards": ["HAZARD_GRAVITY_FLUX"]
},
"narrative": {
"intro_sequence": "NARRATIVE_21_INTRO",
@@ -22,17 +20,16 @@
"primary": [
{
"id": "OBJ_KOTH",
- "type": "KING_OF_THE_HILL",
- "target_score": 100,
- "description": "Control the Geode Platform."
+ "type": "REACH_ZONE",
+ "description": "Control the Geode Platform.",
+ "target_count": 1
}
]
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_AETHER_LENS"
- ]
+ "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 7f728a2..2c17f94 100644
--- a/src/assets/data/missions/mission_story_22.json
+++ b/src/assets/data/missions/mission_story_22.json
@@ -27,9 +27,8 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_CORRUPTED_IDOL"
- ]
+ "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 cc4f9c6..838ecc5 100644
--- a/src/assets/data/missions/mission_story_23.json
+++ b/src/assets/data/missions/mission_story_23.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_CONTESTED_FRONTIER",
- "hazards": [
- "HAZARD_FALLING_DEBRIS"
- ]
+ "hazards": ["HAZARD_FALLING_DEBRIS"]
},
"narrative": {
"intro_sequence": "NARRATIVE_23_INTRO",
@@ -41,7 +39,8 @@
"guaranteed": {
"currency": {
"aether_shards": 900
- }
+ },
+ "unlocks": ["MISSION_STORY_24"]
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_24.json b/src/assets/data/missions/mission_story_24.json
index 8024b6c..1dc2e0a 100644
--- a/src/assets/data/missions/mission_story_24.json
+++ b/src/assets/data/missions/mission_story_24.json
@@ -31,7 +31,8 @@
"guaranteed": {
"currency": {
"ancient_cores": 3
- }
+ },
+ "unlocks": ["MISSION_STORY_25"]
}
}
-}
\ 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 1ce4d0a..a3babda 100644
--- a/src/assets/data/missions/mission_story_25.json
+++ b/src/assets/data/missions/mission_story_25.json
@@ -31,9 +31,7 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "ACCESS_ACT_4"
- ]
+ "unlocks": ["ACCESS_ACT_4", "MISSION_STORY_26"]
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_26.json b/src/assets/data/missions/mission_story_26.json
index d731a75..e9e4f3d 100644
--- a/src/assets/data/missions/mission_story_26.json
+++ b/src/assets/data/missions/mission_story_26.json
@@ -22,10 +22,10 @@
"primary": [
{
"id": "OBJ_DESTROY_GENS",
- "type": "DESTROY_OBJECTS",
- "tag": "SHIELD_GENERATOR",
+ "type": "ELIMINATE_UNIT",
"target_count": 4,
- "description": "Destroy 4 Shield Generators."
+ "description": "Destroy 4 Shield Generators.",
+ "target_def_id": "ENEMY_SHIELD_GENERATOR"
}
]
},
@@ -33,7 +33,8 @@
"guaranteed": {
"currency": {
"ancient_cores": 2
- }
+ },
+ "unlocks": ["MISSION_STORY_27"]
}
}
-}
\ 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 4a5b73a..382affd 100644
--- a/src/assets/data/missions/mission_story_27.json
+++ b/src/assets/data/missions/mission_story_27.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_CRYSTAL_SPIRES",
- "hazards": [
- "HAZARD_GRAVITY_FLUX_HARD"
- ]
+ "hazards": ["HAZARD_GRAVITY_FLUX_HARD"]
},
"narrative": {
"intro_sequence": "NARRATIVE_27_INTRO",
@@ -36,7 +34,8 @@
"guaranteed": {
"currency": {
"ancient_cores": 2
- }
+ },
+ "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 d97aa8e..22a67f5 100644
--- a/src/assets/data/missions/mission_story_28.json
+++ b/src/assets/data/missions/mission_story_28.json
@@ -10,9 +10,7 @@
},
"biome": {
"type": "BIOME_FUNGAL_CAVES",
- "hazards": [
- "HAZARD_TOXIC_ATMOSPHERE"
- ]
+ "hazards": ["HAZARD_TOXIC_ATMOSPHERE"]
},
"narrative": {
"intro_sequence": "NARRATIVE_28_INTRO",
@@ -32,7 +30,8 @@
"guaranteed": {
"currency": {
"ancient_cores": 2
- }
+ },
+ "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 aa7d52d..e13ac72 100644
--- a/src/assets/data/missions/mission_story_29.json
+++ b/src/assets/data/missions/mission_story_29.json
@@ -26,9 +26,8 @@
},
"rewards": {
"guaranteed": {
- "items": [
- "ITEM_VOID_ESSENCE"
- ]
+ "items": ["ITEM_VOID_ESSENCE"],
+ "unlocks": ["MISSION_STORY_30"]
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_30.json b/src/assets/data/missions/mission_story_30.json
index 5b86d9c..f33dfab 100644
--- a/src/assets/data/missions/mission_story_30.json
+++ b/src/assets/data/missions/mission_story_30.json
@@ -32,7 +32,8 @@
"guaranteed": {
"currency": {
"ancient_cores": 5
- }
+ },
+ "unlocks": ["MISSION_STORY_31"]
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_story_31.json b/src/assets/data/missions/mission_story_31.json
index 5ed9fe5..eda60a0 100644
--- a/src/assets/data/missions/mission_story_31.json
+++ b/src/assets/data/missions/mission_story_31.json
@@ -34,9 +34,7 @@
},
"rewards": {
"guaranteed": {
- "unlocks": [
- "LIMIT_BREAK_MAX_LEVEL"
- ]
+ "unlocks": ["LIMIT_BREAK_MAX_LEVEL", "MISSION_STORY_32"]
}
}
-}
\ No newline at end of file
+}
diff --git a/src/assets/data/missions/mission_tutorial_01.description.md b/src/assets/data/missions/mission_tutorial_01.description.md
deleted file mode 100644
index c7ed03f..0000000
--- a/src/assets/data/missions/mission_tutorial_01.description.md
+++ /dev/null
@@ -1,75 +0,0 @@
-Here is the complete breakdown of Mission: Tutorial 01 ("Protocol: First Descent").
-
-This flow combines the Mission Config, the Narrative Script, and the Gameplay Objectives into one cohesive experience.
-
-1. Mission Overview
- Context: The player has just arrived in the Hub City (The neutral zone near the Spire).
-
-Patron: Director Vorn of the Cogwork Concord (The Technocracy). He is using this mission to test if your squad is competent enough to hire.
-
-Setting: The Rusting Wastes. A controlled, smaller map (Fixed Seed 12345) ensuring a fair first fight.
-
-Objective: Eliminate 2 Shardborn Sentinels.
-
-Rewards: Unlocks the Tinker Class (Vorn's signature class) and basic currency.
-
-2. The Playthrough Script
- Phase 1: The Hook (Cinematic)
- Trigger: Player clicks "New Descent" -> "Start Mission".
-
-Visuals: The screen dims. The Dialogue Overlay slides up.
-
-Dialogue (Director Vorn):
-
-Slide 1: "Explorer. You made it. Good. My sensors are bleeding red in Sector 4."
-
-Slide 2: "Standard Shardborn signature. Mindless, aggressive, and unfortunately, standing on top of my excavation site."
-
-Slide 3: "I need the perimeter cleared. Don't disappoint me."
-
-System Action: The Narrative Manager triggers START_DEPLOYMENT_PHASE. The HUD appears.
-
-Phase 2: Deployment (Tutorial)
-Visuals: The map loads. A bright Green Grid highlights the spawn zone.
-
-Tutorial Overlay: A pop-up points to the Team Bench.
-
-Text: "Drag units from the bench to the Green Zone."
-
-Action: Player places a Vanguard and an Aether Weaver.
-
-Action: Player clicks "INITIATE COMBAT".
-
-Phase 3: The Skirmish (Gameplay)
-Turn 1 (Player):
-
-The player moves the Vanguard forward.
-
-System Event: The game detects the player ended a turn exposed.
-
-Mid-Mission Trigger: Vorn interrupts briefly (Narrative Overlay).
-
-Vorn: "Careful! You're exposed. End your move behind High Walls (Full Cover) or Debris (Half Cover) to survive."
-
-Turn 1 (Enemy):
-
-The Corrupted Sentinel charges but hits the Vanguard's shield (reduced damage due to cover).
-
-Turn 2 (Player):
-
-The player uses the Aether Weaver to cast Fireball.
-
-The Sentinel dies. Objective Counter: 1/2.
-
-Phase 4: Victory (Resolution)
-Action: Player kills the second enemy.
-
-Visuals: "VICTORY" banner flashes.
-
-Outro Cinematic (Dialogue Overlay):
-
-Director Vorn: "Efficient. Brutal. I like it."
-
-Director Vorn: "Here's your payment. And take these schematics—you'll need an engineer if you want to survive the deeper levels."
-
-Rewards: The Tinker class card is added to the Roster.
diff --git a/src/assets/data/missions/mission_tutorial_01.json b/src/assets/data/missions/mission_tutorial_01.json
deleted file mode 100644
index 2495743..0000000
--- a/src/assets/data/missions/mission_tutorial_01.json
+++ /dev/null
@@ -1,65 +0,0 @@
-{
- "id": "MISSION_TUTORIAL_01",
- "type": "TUTORIAL",
- "config": {
- "title": "Protocol: First Descent",
- "description": "Establish a foothold in the Rusting Wastes and secure the perimeter.",
- "difficulty_tier": 1,
- "recommended_level": 1
- },
- "biome": {
- "type": "BIOME_RUSTING_WASTES",
- "generator_config": {
- "seed_type": "FIXED",
- "seed": 12345,
- "size": {
- "x": 20,
- "y": 5,
- "z": 10
- },
- "density": "LOW",
- "room_count": 4
- }
- },
- "deployment": {
- "suggested_units": ["CLASS_VANGUARD", "CLASS_AETHER_WEAVER"],
- "tutorial_hint": "Drag units from the bench to the Green Zone."
- },
- "narrative": {
- "intro_sequence": "NARRATIVE_TUTORIAL_INTRO",
- "outro_success": "NARRATIVE_TUTORIAL_SUCCESS",
- "scripted_events": [
- {
- "trigger": "ON_TURN_START",
- "turn_index": 2,
- "action": "PLAY_SEQUENCE",
- "sequence_id": "NARRATIVE_TUTORIAL_COVER_TIP"
- }
- ]
- },
- "enemy_spawns": [
- {
- "enemy_def_id": "ENEMY_SHARDBORN_SENTINEL",
- "count": 2
- }
- ],
- "objectives": {
- "primary": [
- {
- "id": "OBJ_ELIMINATE_ENEMIES",
- "type": "ELIMINATE_ALL",
- "description": "Eliminate 2 Shardborn Sentinels",
- "target_count": 2
- }
- ]
- },
- "rewards": {
- "guaranteed": {
- "xp": 100,
- "currency": {
- "aether_shards": 50
- },
- "unlocks": ["CLASS_TINKER"]
- }
- }
-}
diff --git a/src/assets/data/narrative/tutorial_cover_tip.json b/src/assets/data/narrative/tutorial_cover_tip.json
deleted file mode 100644
index 0cb99b0..0000000
--- a/src/assets/data/narrative/tutorial_cover_tip.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "id": "NARRATIVE_TUTORIAL_COVER_TIP",
- "nodes": [
- {
- "id": "1",
- "speaker": "Director Vorn",
- "portrait": "assets/images/portraits/tinker.png",
- "text": "Careful! You're exposed. End your move behind High Walls (Full Cover) or Debris (Half Cover) to survive.",
- "type": "DIALOGUE",
- "next": "END"
- }
- ]
-}
-
-
diff --git a/src/assets/data/narrative/tutorial_intro.json b/src/assets/data/narrative/tutorial_intro.json
deleted file mode 100644
index 270168e..0000000
--- a/src/assets/data/narrative/tutorial_intro.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
- "id": "NARRATIVE_TUTORIAL_INTRO",
- "nodes": [
- {
- "id": "1",
- "speaker": "Director Vorn",
- "portrait": "assets/images/portraits/tinker.png",
- "text": "Explorer. You made it. Good. My sensors are bleeding red in Sector 4.",
- "type": "DIALOGUE",
- "next": "2"
- },
- {
- "id": "2",
- "speaker": "Director Vorn",
- "portrait": "assets/images/portraits/tinker.png",
- "text": "Standard Shardborn signature. Mindless, aggressive, and unfortunately, standing on top of my excavation site.",
- "type": "DIALOGUE",
- "next": "3"
- },
- {
- "id": "3",
- "speaker": "Director Vorn",
- "portrait": "assets/images/portraits/tinker.png",
- "text": "I need the perimeter cleared. Don't disappoint me.",
- "type": "DIALOGUE",
- "next": "4"
- },
- {
- "id": "4",
- "speaker": "System",
- "portrait": null,
- "text": "Drag units from the bench to the Green Zone.",
- "type": "TUTORIAL",
- "highlightElement": "#canvas-container",
- "next": "END",
- "trigger": "START_DEPLOYMENT_PHASE"
- }
- ]
-}
diff --git a/src/assets/data/narrative/tutorial_success.json b/src/assets/data/narrative/tutorial_success.json
deleted file mode 100644
index 43a8dc9..0000000
--- a/src/assets/data/narrative/tutorial_success.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "id": "NARRATIVE_TUTORIAL_SUCCESS",
- "nodes": [
- {
- "id": "1",
- "speaker": "Director Vorn",
- "portrait": "assets/images/portraits/tinker.png",
- "text": "Efficient. Brutal. I like it.",
- "type": "DIALOGUE",
- "next": "2"
- },
- {
- "id": "2",
- "speaker": "Director Vorn",
- "portrait": "assets/images/portraits/tinker.png",
- "text": "Here's your payment. And take these schematics—you'll need an engineer if you want to survive the deeper levels.",
- "type": "DIALOGUE",
- "next": "END"
- }
- ]
-}
-
-
diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js
index e668411..587cccd 100644
--- a/src/core/GameLoop.js
+++ b/src/core/GameLoop.js
@@ -109,7 +109,7 @@ export class GameLoop {
/** @type {number} */
this.lastMoveTime = 0;
/** @type {number} */
-
+
// Camera Animation State
/** @type {boolean} */
this.isAnimatingCamera = false;
@@ -1622,7 +1622,7 @@ export class GameLoop {
if (walkableY !== null) {
objPos = { x: position.x, y: walkableY, z: position.z };
}
- }
+ }
// Otherwise, use placement strategy
else if (placement_strategy) {
objPos = this.findObjectPlacement(placement_strategy);
@@ -1630,7 +1630,9 @@ export class GameLoop {
if (!objPos) {
console.warn(
- `Could not find valid position for object ${object_id} using ${placement_strategy || "explicit position"}`
+ `Could not find valid position for object ${object_id} using ${
+ placement_strategy || "explicit position"
+ }`
);
continue;
}
@@ -1670,8 +1672,12 @@ export class GameLoop {
await this.missionManager.setupActiveMission();
// Populate zone coordinates for REACH_ZONE objectives
this.missionManager.populateZoneCoordinates();
+ // Resolve positions for mission objects
+ this.missionManager.resolveMissionObjectPositions();
// Create visual markers for zones
this.createZoneMarkers();
+ // Spawn mission objects
+ this.spawnMissionObjects();
}
// WIRING: Listen for mission events
@@ -1763,7 +1769,12 @@ export class GameLoop {
const reachZoneObjectives = [
...(this.missionManager.currentObjectives || []),
...(this.missionManager.secondaryObjectives || []),
- ].filter((obj) => obj.type === "REACH_ZONE" && obj.zone_coords && obj.zone_coords.length > 0);
+ ].filter(
+ (obj) =>
+ obj.type === "REACH_ZONE" &&
+ obj.zone_coords &&
+ obj.zone_coords.length > 0
+ );
for (const obj of reachZoneObjectives) {
for (const coord of obj.zone_coords) {
@@ -1772,6 +1783,66 @@ export class GameLoop {
}
}
+ /**
+ * Spawns visual objects for the current mission.
+ */
+ spawnMissionObjects() {
+ if (!this.missionManager || !this.missionManager.currentMissionDef) return;
+
+ const missionObjects =
+ this.missionManager.currentMissionDef.mission_objects || [];
+
+ for (const obj of missionObjects) {
+ if (obj.position) {
+ this.createMissionObject(obj);
+ }
+ }
+ }
+
+ /**
+ * Creates a visual mesh for a mission object.
+ * @param {import("./types.js").MissionObject} obj - Mission Object definition
+ */
+ createMissionObject(obj) {
+ // Check if checks if already spawned (avoid duplicates if called multiple times)
+ if (this.missionObjects.has(obj.object_id)) return;
+
+ const pos = obj.position;
+
+ // Golden Box for interaction objects
+ const geometry = new THREE.BoxGeometry(0.7, 0.7, 0.7);
+ const material = new THREE.MeshStandardMaterial({
+ color: 0xffd700, // Gold
+ metalness: 0.8,
+ roughness: 0.2,
+ emissive: 0x332200,
+ });
+
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.position.set(pos.x, pos.y + 0.35, pos.z); // Center on floor + half height
+
+ // Rotate 45 degrees for style
+ mesh.rotation.y = Math.PI / 4;
+ mesh.rotation.x = Math.PI / 4;
+
+ this.scene.add(mesh);
+ this.missionObjects.set(obj.object_id, pos);
+ this.missionObjectMeshes.set(obj.object_id, mesh);
+
+ // Add pulsing animation or similar to userData for animate loop
+ mesh.userData = {
+ type: "MISSION_OBJECT",
+ id: obj.object_id,
+ originalY: pos.y + 0.35,
+ floatSpeed: 0.002,
+ floatOffset: Math.random() * Math.PI * 2,
+ };
+
+ console.log(
+ `Spawned mission object ${obj.object_id} at ${pos.x},${pos.y},${pos.z}`
+ );
+ }
+
/**
* Creates a visual marker for a single zone coordinate.
* @param {Position} pos - Zone position
@@ -1780,7 +1851,7 @@ export class GameLoop {
// Create a glowing beacon/marker for the zone
// Use a cone or cylinder with pulsing glow effect
const geometry = new THREE.ConeGeometry(0.3, 1.2, 8);
-
+
// Cyan/blue color to indicate recon zones
const material = new THREE.MeshStandardMaterial({
color: 0x00ffff, // Cyan
@@ -1833,7 +1904,7 @@ export class GameLoop {
mesh.userData.time += mesh.userData.pulseSpeed;
const offset = Math.sin(mesh.userData.time) * mesh.userData.pulseAmount;
mesh.position.y = mesh.userData.originalY + offset;
-
+
// Pulse emissive intensity
if (mesh.material && mesh.material.emissive) {
const intensity = 0.004444 + Math.sin(mesh.userData.time) * 0.002;
@@ -2118,7 +2189,9 @@ export class GameLoop {
// Place in the center of the enemy spawn zone
if (this.enemySpawnZone.length > 0) {
// Find center of enemy spawn zone
- let sumX = 0, sumY = 0, sumZ = 0;
+ let sumX = 0,
+ sumY = 0,
+ sumZ = 0;
for (const spot of this.enemySpawnZone) {
sumX += spot.x;
sumY += spot.y;
@@ -2129,7 +2202,11 @@ export class GameLoop {
const avgY = Math.round(sumY / this.enemySpawnZone.length);
// Find walkable position near center
- const walkableY = this.movementSystem.findWalkableY(centerX, centerZ, avgY);
+ const walkableY = this.movementSystem.findWalkableY(
+ centerX,
+ centerZ,
+ avgY
+ );
if (walkableY !== null) {
return { x: centerX, y: walkableY, z: centerZ };
}
@@ -2139,7 +2216,9 @@ export class GameLoop {
case "center_of_player_room":
// Place in the center of the player spawn zone
if (this.playerSpawnZone.length > 0) {
- let sumX = 0, sumY = 0, sumZ = 0;
+ let sumX = 0,
+ sumY = 0,
+ sumZ = 0;
for (const spot of this.playerSpawnZone) {
sumX += spot.x;
sumY += spot.y;
@@ -2149,7 +2228,11 @@ export class GameLoop {
const centerZ = Math.round(sumZ / this.playerSpawnZone.length);
const avgY = Math.round(sumY / this.playerSpawnZone.length);
- const walkableY = this.movementSystem.findWalkableY(centerX, centerZ, avgY);
+ const walkableY = this.movementSystem.findWalkableY(
+ centerX,
+ centerZ,
+ avgY
+ );
if (walkableY !== null) {
return { x: centerX, y: walkableY, z: centerZ };
}
@@ -2165,7 +2248,10 @@ export class GameLoop {
const y = Math.floor(this.grid.size.y / 2); // Start from middle height
const walkableY = this.movementSystem.findWalkableY(x, z, y);
- if (walkableY !== null && !this.grid.isOccupied({ x, y: walkableY, z })) {
+ if (
+ walkableY !== null &&
+ !this.grid.isOccupied({ x, y: walkableY, z })
+ ) {
return { x, y: walkableY, z };
}
}
@@ -2175,14 +2261,32 @@ export class GameLoop {
// Try to place between player and enemy spawn zones
if (this.playerSpawnZone.length > 0 && this.enemySpawnZone.length > 0) {
const playerCenter = {
- x: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.x, 0) / this.playerSpawnZone.length),
- z: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.z, 0) / this.playerSpawnZone.length),
- y: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.y, 0) / this.playerSpawnZone.length)
+ x: Math.round(
+ this.playerSpawnZone.reduce((sum, s) => sum + s.x, 0) /
+ this.playerSpawnZone.length
+ ),
+ z: Math.round(
+ this.playerSpawnZone.reduce((sum, s) => sum + s.z, 0) /
+ this.playerSpawnZone.length
+ ),
+ y: Math.round(
+ this.playerSpawnZone.reduce((sum, s) => sum + s.y, 0) /
+ this.playerSpawnZone.length
+ ),
};
const enemyCenter = {
- x: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.x, 0) / this.enemySpawnZone.length),
- z: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.z, 0) / this.enemySpawnZone.length),
- y: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.y, 0) / this.enemySpawnZone.length)
+ x: Math.round(
+ this.enemySpawnZone.reduce((sum, s) => sum + s.x, 0) /
+ this.enemySpawnZone.length
+ ),
+ z: Math.round(
+ this.enemySpawnZone.reduce((sum, s) => sum + s.z, 0) /
+ this.enemySpawnZone.length
+ ),
+ y: Math.round(
+ this.enemySpawnZone.reduce((sum, s) => sum + s.y, 0) /
+ this.enemySpawnZone.length
+ ),
};
const midX = Math.round((playerCenter.x + enemyCenter.x) / 2);
@@ -2218,28 +2322,30 @@ export class GameLoop {
createMissionObjectMesh(objectId, pos) {
// Create a distinctive placeholder object (cylinder for objects vs boxes for units)
const geometry = new THREE.CylinderGeometry(0.4, 0.4, 0.8, 8);
-
+
// Use a bright color to make objects stand out (yellow/gold for interactable objects)
- const material = new THREE.MeshStandardMaterial({
+ const material = new THREE.MeshStandardMaterial({
color: 0xffaa00, // Orange/gold
emissive: 0x442200, // Slight glow
metalness: 0.3,
- roughness: 0.7
+ roughness: 0.7,
});
-
+
const mesh = new THREE.Mesh(geometry, material);
-
+
// Position the object on the floor (same as units: pos.y + 0.1)
mesh.position.set(pos.x, pos.y + 0.5, pos.z);
-
+
// Add metadata for interaction detection
mesh.userData = { objectId, originalY: pos.y + 0.5 };
-
+
// Add to scene
this.scene.add(mesh);
this.missionObjectMeshes.set(objectId, mesh);
-
- console.log(`Created mission object mesh for ${objectId} at ${pos.x},${pos.y},${pos.z}`);
+
+ console.log(
+ `Created mission object mesh for ${objectId} at ${pos.x},${pos.y},${pos.z}`
+ );
return mesh;
}
@@ -2251,7 +2357,7 @@ export class GameLoop {
if (!unit || !this.missionObjects) return;
const unitPos = unit.position;
-
+
// Check each mission object to see if unit is at its position
for (const [objectId, objPos] of this.missionObjects.entries()) {
// Check if unit is at the same x, z position (Y can vary slightly)
@@ -2260,22 +2366,22 @@ export class GameLoop {
Math.floor(unitPos.z) === Math.floor(objPos.z)
) {
console.log(`Unit ${unit.name} interacted with ${objectId}`);
-
+
// Dispatch INTERACT event for MissionManager to handle
if (this.missionManager) {
this.missionManager.onGameEvent("INTERACT", {
objectId: objectId,
unitId: unit.id,
- position: unitPos
+ position: unitPos,
});
}
-
+
// Visual feedback: make object glow or change color
const mesh = this.missionObjectMeshes.get(objectId);
if (mesh && mesh.material) {
mesh.material.emissive.setHex(0x884400); // Brighter glow on interaction
}
-
+
// Only interact with one object per move
break;
}
@@ -2468,42 +2574,46 @@ export class GameLoop {
requestAnimationFrame(this.animate);
if (this.inputManager) this.inputManager.update();
-
+
// Update zone marker animations
this.updateZoneMarkers();
-
+
// Handle camera animation if active
if (this.isAnimatingCamera && this.controls && this.camera) {
const now = Date.now();
const elapsed = now - this.cameraAnimationStartTime;
const progress = Math.min(elapsed / this.cameraAnimationDuration, 1.0);
-
+
// Ease-out cubic for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3);
-
+
// Interpolate between start and target
this.controls.target.lerpVectors(
this.cameraAnimationStart,
this.cameraAnimationTarget,
eased
);
-
+
// Maintain camera's relative offset to preserve rotation
if (this.cameraAnimationOffset) {
- this.camera.position.copy(this.controls.target).add(this.cameraAnimationOffset);
+ this.camera.position
+ .copy(this.controls.target)
+ .add(this.cameraAnimationOffset);
}
-
+
// If animation is complete, snap to final position and stop
if (progress >= 1.0) {
this.controls.target.copy(this.cameraAnimationTarget);
if (this.cameraAnimationOffset) {
- this.camera.position.copy(this.controls.target).add(this.cameraAnimationOffset);
+ this.camera.position
+ .copy(this.controls.target)
+ .add(this.cameraAnimationOffset);
}
this.isAnimatingCamera = false;
this.cameraAnimationOffset = null;
}
}
-
+
if (this.controls) this.controls.update();
const now = Date.now();
@@ -2949,9 +3059,9 @@ export class GameLoop {
secondary: this.missionManager.secondaryObjectives || [],
};
// Find turn limit from failure conditions
- const turnLimitCondition = (this.missionManager.failureConditions || []).find(
- (fc) => fc.type === "TURN_LIMIT_EXCEEDED" && fc.turn_limit
- );
+ const turnLimitCondition = (
+ this.missionManager.failureConditions || []
+ ).find((fc) => fc.type === "TURN_LIMIT_EXCEEDED" && fc.turn_limit);
if (turnLimitCondition) {
turnLimit = {
limit: turnLimitCondition.turn_limit,
@@ -3038,7 +3148,7 @@ export class GameLoop {
const { unit } = detail;
// Center camera on the active unit
this.centerCameraOnUnit(unit);
-
+
// Update movement highlights if it's a player's turn
if (unit.team === "PLAYER") {
this.updateMovementHighlights(unit);
@@ -3148,9 +3258,16 @@ export class GameLoop {
// Check for death if this was a damage effect
if (result.data.type === "DAMAGE") {
- if (result.data.currentHP <= 0 && target && typeof target === "object" && "currentHealth" in target) {
+ if (
+ result.data.currentHP <= 0 &&
+ target &&
+ typeof target === "object" &&
+ "currentHealth" in target
+ ) {
const killedUnit = /** @type {Unit} */ (target);
- console.log(`${killedUnit.name} has been defeated by passive effect!`);
+ console.log(
+ `${killedUnit.name} has been defeated by passive effect!`
+ );
// Process ON_KILL passive effects (on source)
this.processPassiveItemEffects(unit, "ON_KILL", {
target: killedUnit,
@@ -3168,7 +3285,9 @@ export class GameLoop {
damageResult.target
);
if (killedUnit) {
- console.log(`${killedUnit.name} has been defeated by passive chain damage!`);
+ console.log(
+ `${killedUnit.name} has been defeated by passive chain damage!`
+ );
// Process ON_KILL passive effects (on source)
this.processPassiveItemEffects(unit, "ON_KILL", {
target: killedUnit,
@@ -3345,22 +3464,32 @@ export class GameLoop {
*/
handleUnitDeath(unit) {
if (!unit) {
- console.warn("[GameLoop] handleUnitDeath called with null/undefined unit");
+ console.warn(
+ "[GameLoop] handleUnitDeath called with null/undefined unit"
+ );
return;
}
if (!this.grid || !this.unitManager) {
- console.warn("[GameLoop] handleUnitDeath called but grid or unitManager not available");
+ console.warn(
+ "[GameLoop] handleUnitDeath called but grid or unitManager not available"
+ );
return;
}
- console.log(`[GameLoop] handleUnitDeath called for ${unit.name} (${unit.id})`);
+ console.log(
+ `[GameLoop] handleUnitDeath called for ${unit.name} (${unit.id})`
+ );
// Remove unit from grid
if (unit.position) {
this.grid.removeUnit(unit.position);
- console.log(`[GameLoop] Removed ${unit.name} from grid at (${unit.position.x}, ${unit.position.y}, ${unit.position.z})`);
+ console.log(
+ `[GameLoop] Removed ${unit.name} from grid at (${unit.position.x}, ${unit.position.y}, ${unit.position.z})`
+ );
} else {
- console.warn(`[GameLoop] ${unit.name} has no position to remove from grid`);
+ console.warn(
+ `[GameLoop] ${unit.name} has no position to remove from grid`
+ );
}
// Dispatch death event to MissionManager BEFORE removing from UnitManager
@@ -3378,14 +3507,18 @@ export class GameLoop {
if (!unitDefId) {
unitDefId = unit.id; // Fallback to instance ID
}
- console.log(`[GameLoop] Dispatching ${eventType} event for ${unit.name} (defId: ${unitDefId}) BEFORE removing from UnitManager`);
+ console.log(
+ `[GameLoop] Dispatching ${eventType} event for ${unit.name} (defId: ${unitDefId}) BEFORE removing from UnitManager`
+ );
this.missionManager.onGameEvent(eventType, {
unitId: unit.id,
defId: unitDefId,
team: unit.team,
});
} else {
- console.warn(`[GameLoop] MissionManager not available, cannot dispatch death event`);
+ console.warn(
+ `[GameLoop] MissionManager not available, cannot dispatch death event`
+ );
}
// Remove unit mesh from scene FIRST (before removing from UnitManager)
@@ -3410,10 +3543,15 @@ export class GameLoop {
}
console.log(`[GameLoop] Mesh removed and disposed for ${unit.name}`);
} else {
- console.warn(`[GameLoop] No mesh found for ${unit.name} (${unit.id}) in unitMeshes map. Available meshes:`, Array.from(this.unitMeshes.keys()));
+ console.warn(
+ `[GameLoop] No mesh found for ${unit.name} (${unit.id}) in unitMeshes map. Available meshes:`,
+ Array.from(this.unitMeshes.keys())
+ );
}
- console.log(`[GameLoop] ${unit.name} (${unit.team}) has been removed from combat.`);
+ console.log(
+ `[GameLoop] ${unit.name} (${unit.team}) has been removed from combat.`
+ );
}
/**
@@ -3573,9 +3711,11 @@ export class GameLoop {
// Get reputation changes
const reputationChanges = [];
if (rewards.faction_reputation) {
- Object.entries(rewards.faction_reputation).forEach(([factionId, amount]) => {
- reputationChanges.push({ factionId, amount });
- });
+ Object.entries(rewards.faction_reputation).forEach(
+ ([factionId, amount]) => {
+ reputationChanges.push({ factionId, amount });
+ }
+ );
}
// Get squad status
diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js
index f1e8d2f..8c17cf9 100644
--- a/src/core/GameStateManager.js
+++ b/src/core/GameStateManager.js
@@ -190,6 +190,9 @@ class GameStateManagerClass {
// 7. Set up campaign data change listener
this._setupCampaignDataListener();
+ // 8. Set up mission sequence completion listener (transition to Hub)
+ this._setupMissionSequenceListener();
+
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
}
@@ -532,6 +535,29 @@ class GameStateManagerClass {
});
}
+ /**
+ * Sets up listener for mission sequence completion (outro finished).
+ * Transitions the game back to the Hub/Main Menu.
+ * @private
+ */
+ _setupMissionSequenceListener() {
+ window.addEventListener("mission-sequence-complete", async (event) => {
+ console.log(
+ "GameStateManager: Mission sequence complete. Clearing run and returning to Hub.",
+ event.detail
+ );
+
+ // Clear the active run (persistence and memory)
+ await this.clearActiveRun();
+
+ // Transition to Main Menu (will show Hub if unlocking conditions met)
+ await this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
+
+ // Force a refresh of the Hub screen if it was already open/cached?
+ // The state transition should handle visibility, but we check specific UI updates if needed.
+ });
+ }
+
/**
* Handles mission rewards by applying them to the hub stash.
* @param {Object} rewardData - Reward data from mission
diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js
index a57fc71..60a4da2 100644
--- a/src/managers/MissionManager.js
+++ b/src/managers/MissionManager.js
@@ -26,6 +26,9 @@ export class MissionManager {
this.activeMissionId = null;
/** @type {Set} */
this.completedMissions = new Set();
+ /** @type {Set} */
+ this.unlockedMissions = new Set(); // Track unlocked missions
+ this.unlockedMissions.add("MISSION_ACT1_01"); // Default unlock
/** @type {Map} */
this.missionRegistry = new Map();
/** @type {Map} */
@@ -82,24 +85,73 @@ export class MissionManager {
}
try {
- const [tutorialMission, story02Mission, story03Mission] =
- await Promise.all([
- import("../assets/data/missions/mission_tutorial_01.json", {
- with: { type: "json" },
- }).then((m) => m.default),
- import("../assets/data/missions/mission_story_02.json", {
- with: { type: "json" },
- }).then((m) => m.default),
- import("../assets/data/missions/mission_story_03.json", {
- with: { type: "json" },
- }).then((m) => m.default),
- ]);
+ // Step 1: Load Initial Mission (Act 1, Mission 1)
+ const mission01 = await import(
+ "../assets/data/missions/mission_act1_01.json",
+ {
+ with: { type: "json" },
+ }
+ ).then((m) => m.default);
- this.registerMission(tutorialMission);
- this.registerMission(story02Mission);
- this.registerMission(story03Mission);
- } catch (error) {
- console.error("Failed to load missions:", error);
+ this.registerMission(mission01);
+
+ // Step 2: Load unlock state
+ await this.loadUnlockState();
+
+ // Step 3: Load any other unlocked missions
+ if (this.unlockedMissions.size > 0) {
+ for (const missionId of this.unlockedMissions) {
+ if (
+ missionId !== "MISSION_ACT1_01" &&
+ !this.missionRegistry.has(missionId)
+ ) {
+ await this.loadMissionFile(missionId);
+ }
+ }
+ }
+
+ console.log("MissionManager: Missions loaded successfully.");
+ } catch (err) {
+ console.error("MissionManager: Failed to load missions:", err);
+ }
+ }
+
+ /**
+ * Dynamically loads a mission file by ID.
+ * Uses simple heuristic: MISSION_XYZ -> mission_xyz.json
+ * @param {string} missionId
+ */
+ async loadMissionFile(missionId) {
+ try {
+ const filename = missionId.toLowerCase() + ".json";
+ let path = `../assets/data/missions/${filename}`;
+
+ const mission = await import(/* @vite-ignore */ path, {
+ with: { type: "json" },
+ }).then((m) => m.default);
+
+ this.registerMission(mission);
+ console.log(`Dynamically loaded unlocked mission: ${missionId}`);
+ } catch (e) {
+ console.warn(
+ `Failed to dynamically load mission ${missionId} from ${filename}`,
+ e
+ );
+ // Fallback: Try with underscore
+ try {
+ const pathWithUnderscore = `../assets/data/missions/_${missionId.toLowerCase()}.json`;
+ const mission = await import(/* @vite-ignore */ pathWithUnderscore, {
+ with: { type: "json" },
+ }).then((m) => m.default);
+ this.registerMission(mission);
+ console.log(
+ `Dynamically loaded unlocked mission (with underscore): ${missionId}`
+ );
+ } catch (e2) {
+ console.error(
+ `Could not load mission ${missionId} with either naming convention.`
+ );
+ }
}
}
@@ -221,7 +273,7 @@ export class MissionManager {
// Unlock additional regions based on completed missions
// This is a simple implementation - can be enhanced with actual unlock logic
- if (this.completedMissions.has("MISSION_TUTORIAL_01")) {
+ if (this.completedMissions.has("MISSION_ACT1_01")) {
// After tutorial, unlock Crystal Spires
if (!defaultRegions.includes("BIOME_CRYSTAL_SPIRES")) {
defaultRegions.push("BIOME_CRYSTAL_SPIRES");
@@ -271,7 +323,7 @@ export class MissionManager {
}
// Default to Tutorial if history is empty
if (this.completedMissions.size === 0) {
- this.activeMissionId = "MISSION_TUTORIAL_01";
+ this.activeMissionId = "MISSION_ACT1_01";
}
}
@@ -305,7 +357,7 @@ export class MissionManager {
async getActiveMission() {
await this._ensureMissionsLoaded();
if (!this.activeMissionId)
- return this.missionRegistry.get("MISSION_TUTORIAL_01");
+ return this.missionRegistry.get("MISSION_ACT1_01");
return this.missionRegistry.get(this.activeMissionId);
}
@@ -397,6 +449,83 @@ export class MissionManager {
}
}
+ /**
+ * Resolves positions for mission objects based on placement strategies.
+ * Modifies the mission_objects array in-place with calculated positions.
+ */
+ resolveMissionObjectPositions() {
+ if (!this.grid || !this.movementSystem) {
+ console.warn(
+ "Cannot resolve mission object positions: grid or movementSystem not set"
+ );
+ return;
+ }
+
+ if (!this.currentMissionDef || !this.currentMissionDef.mission_objects) {
+ return;
+ }
+
+ for (const obj of this.currentMissionDef.mission_objects) {
+ if (obj.position) continue; // Already has position
+
+ let position = null;
+ const strategy = obj.placement_strategy || "random_walkable";
+
+ if (strategy === "random_walkable") {
+ const attempts = 50;
+ for (let i = 0; i < attempts; i++) {
+ const x = Math.floor(Math.random() * this.grid.size.x);
+ const z = Math.floor(Math.random() * this.grid.size.z);
+ const y = Math.floor(this.grid.size.y / 2); // Start middle
+
+ const walkableY = this.movementSystem.findWalkableY(x, z, y);
+ if (
+ walkableY !== null &&
+ !this.grid.isOccupied({ x, y: walkableY, z })
+ ) {
+ position = { x, y: walkableY, z };
+ break;
+ }
+ }
+ } else if (strategy === "center_of_enemy_room") {
+ // Placeholder: For now, treating as random if logic missing
+ // Ideally this would query a RoomManager or similar
+ // Falling back to random for now to ensure it spawns somewhere
+ console.warn(
+ "Placement strategy 'center_of_enemy_room' not fully implemented, falling back to random."
+ );
+ position = this._findRandomWalkablePosition();
+ } else if (strategy === "center_of_player_room") {
+ // Placeholder
+ position = this._findRandomWalkablePosition();
+ }
+
+ if (position) {
+ obj.position = position;
+ console.log(`Resolved position for object ${obj.object_id}:`, position);
+ } else {
+ console.warn(
+ `Failed to resolve position for object ${obj.object_id} with strategy ${strategy}`
+ );
+ }
+ }
+ }
+
+ _findRandomWalkablePosition() {
+ const attempts = 50;
+ for (let i = 0; i < attempts; i++) {
+ const x = Math.floor(Math.random() * this.grid.size.x);
+ const z = Math.floor(Math.random() * this.grid.size.z);
+ const y = Math.floor(this.grid.size.y / 2);
+
+ const walkableY = this.movementSystem.findWalkableY(x, z, y);
+ if (walkableY !== null && !this.grid.isOccupied({ x, y: walkableY, z })) {
+ return { x, y: walkableY, z };
+ }
+ }
+ return null;
+ }
+
/**
* Prepares the manager for a new run.
* Resets objectives and prepares narrative hooks.
@@ -455,22 +584,30 @@ export class MissionManager {
return new Promise(async (resolve) => {
const introId = this.currentMissionDef.narrative.intro_sequence;
- // Map narrative ID to filename
- // NARRATIVE_TUTORIAL_INTRO -> tutorial_intro.json
- const narrativeFileName = this._mapNarrativeIdToFileName(introId);
-
try {
- // Load the narrative JSON file
- const response = await fetch(
- `assets/data/narrative/${narrativeFileName}.json`
- );
- if (!response.ok) {
- console.error(`Failed to load narrative: ${narrativeFileName}`);
- resolve();
- return;
- }
+ let narrativeData;
- const narrativeData = await response.json();
+ // Check for dynamic data first
+ if (
+ this.currentMissionDef.narrative._dynamic_data &&
+ this.currentMissionDef.narrative._dynamic_data[introId]
+ ) {
+ console.log(`Using dynamic narrative data for ${introId}`);
+ narrativeData =
+ this.currentMissionDef.narrative._dynamic_data[introId];
+ } else {
+ // Fallback to loading from file
+ const narrativeFileName = this._mapNarrativeIdToFileName(introId);
+ const response = await fetch(
+ `assets/data/narrative/${narrativeFileName}.json`
+ );
+ if (!response.ok) {
+ console.error(`Failed to load narrative: ${narrativeFileName}`);
+ resolve();
+ return;
+ }
+ narrativeData = await response.json();
+ }
// Set up listener for narrative end
const onEnd = () => {
@@ -483,7 +620,7 @@ export class MissionManager {
console.log(`Playing Narrative Intro: ${introId}`);
narrativeManager.startSequence(narrativeData);
} catch (error) {
- console.error(`Error loading narrative ${narrativeFileName}:`, error);
+ console.error(`Error loading/playing narrative ${introId}:`, error);
resolve(); // Resolve anyway to not block game start
}
});
@@ -909,6 +1046,15 @@ export class MissionManager {
if (this.currentMissionDef.narrative?.outro_success) {
await this.playOutro(this.currentMissionDef.narrative.outro_success);
}
+
+ // Signal that the mission sequence (including outro) is complete
+ // This tells the GameStateManager to transition back to the Hub
+ console.log("MissionManager: Mission sequence complete, requesting exit.");
+ window.dispatchEvent(
+ new CustomEvent("mission-sequence-complete", {
+ detail: { missionId: this.activeMissionId },
+ })
+ );
}
/**
@@ -989,12 +1135,14 @@ export class MissionManager {
this.unlockClasses(classUnlocks);
}
- // Handle special unlocks
+ // Handle special unlocks & Mission unlocks
specialUnlocks.forEach((unlock) => {
if (unlock === "UNLOCK_PROCEDURAL_MISSIONS") {
// Procedural missions are now unlocked
// They will be generated when the mission board is accessed
console.log("Procedural missions unlocked!");
+ } else if (unlock.startsWith("MISSION_")) {
+ this.unlockMission(unlock);
}
});
}
@@ -1105,4 +1253,52 @@ export class MissionManager {
updateTurn(turn) {
this.currentTurn = turn;
}
+
+ /**
+ * Unlocks a specific mission ID and loads it.
+ * @param {string} missionId
+ */
+ async unlockMission(missionId) {
+ if (this.unlockedMissions.has(missionId)) return;
+
+ console.log(`Unlocking mission: ${missionId}`);
+ this.unlockedMissions.add(missionId);
+
+ // Load the file immediately so it's available
+ await this.loadMissionFile(missionId);
+
+ // Persist this unlock state
+ this.saveUnlockState();
+ }
+
+ /**
+ * Saves current unlock state (missions) to persistence.
+ */
+ async saveUnlockState() {
+ if (!this.persistence) return;
+ try {
+ const existing = await this.persistence.loadUnlocks();
+ const allUnlocks = new Set([...existing, ...this.unlockedMissions]);
+ await this.persistence.saveUnlocks(Array.from(allUnlocks));
+ } catch (e) {
+ console.error("Failed to save mission unlocks:", e);
+ }
+ }
+
+ /**
+ * Loads unlock state on startup.
+ */
+ async loadUnlockState() {
+ if (!this.persistence) return;
+ try {
+ const unlocks = await this.persistence.loadUnlocks();
+ for (const u of unlocks) {
+ if (u.startsWith("MISSION_")) {
+ this.unlockedMissions.add(u);
+ }
+ }
+ } catch (e) {
+ console.warn("Failed to load unlock state:", e);
+ }
+ }
}
diff --git a/src/systems/MissionGenerator.js b/src/systems/MissionGenerator.js
index c2ef0dc..2626f22 100644
--- a/src/systems/MissionGenerator.js
+++ b/src/systems/MissionGenerator.js
@@ -1,7 +1,7 @@
/**
* MissionGenerator.js
* Factory that produces temporary Mission objects for Side Ops (procedural missions).
- *
+ *
* @typedef {import("../managers/types.js").MissionDefinition} MissionDefinition
*/
@@ -10,616 +10,876 @@
* Generates procedural side missions based on campaign tier and unlocked regions.
*/
export class MissionGenerator {
- /**
- * Adjectives for mission naming (general flavor)
- */
- static ADJECTIVES = [
- "Silent", "Broken", "Red", "Crimson", "Shattered", "Frozen",
- "Burning", "Dark", "Blind", "Hidden", "Lost", "Ancient", "Hollow", "Swift"
- ];
+ /**
+ * Adjectives for mission naming (general flavor)
+ */
+ static ADJECTIVES = [
+ "Silent",
+ "Broken",
+ "Red",
+ "Crimson",
+ "Shattered",
+ "Frozen",
+ "Burning",
+ "Dark",
+ "Blind",
+ "Hidden",
+ "Lost",
+ "Ancient",
+ "Hollow",
+ "Swift",
+ ];
- /**
- * Nouns for Skirmish (Combat) missions
- */
- static NOUNS_SKIRMISH = [
- "Thunder", "Storm", "Iron", "Fury", "Shield", "Hammer",
- "Wrath", "Wall", "Strike", "Anvil"
- ];
+ /**
+ * Nouns for Skirmish (Combat) missions
+ */
+ static NOUNS_SKIRMISH = [
+ "Thunder",
+ "Storm",
+ "Iron",
+ "Fury",
+ "Shield",
+ "Hammer",
+ "Wrath",
+ "Wall",
+ "Strike",
+ "Anvil",
+ ];
- /**
- * Nouns for Salvage (Loot) missions
- */
- static NOUNS_SALVAGE = [
- "Cache", "Vault", "Echo", "Spark", "Core", "Grip",
- "Harvest", "Trove", "Fragment", "Salvage"
- ];
+ /**
+ * Nouns for Salvage (Loot) missions
+ */
+ static NOUNS_SALVAGE = [
+ "Cache",
+ "Vault",
+ "Echo",
+ "Spark",
+ "Core",
+ "Grip",
+ "Harvest",
+ "Trove",
+ "Fragment",
+ "Salvage",
+ ];
- /**
- * Nouns for Assassination (Kill) missions
- */
- static NOUNS_ASSASSINATION = [
- "Viper", "Dagger", "Fang", "Night", "Shadow", "End",
- "Hunt", "Razor", "Ghost", "Sting"
- ];
+ /**
+ * Nouns for Assassination (Kill) missions
+ */
+ static NOUNS_ASSASSINATION = [
+ "Viper",
+ "Dagger",
+ "Fang",
+ "Night",
+ "Shadow",
+ "End",
+ "Hunt",
+ "Razor",
+ "Ghost",
+ "Sting",
+ ];
- /**
- * Nouns for Recon (Explore) missions
- */
- static NOUNS_RECON = [
- "Eye", "Watch", "Path", "Horizon", "Whisper", "Dawn",
- "Light", "Step", "Vision", "Scope"
- ];
+ /**
+ * Nouns for Recon (Explore) missions
+ */
+ static NOUNS_RECON = [
+ "Eye",
+ "Watch",
+ "Path",
+ "Horizon",
+ "Whisper",
+ "Dawn",
+ "Light",
+ "Step",
+ "Vision",
+ "Scope",
+ ];
- /**
- * Tier configuration: [Name, Enemy Level Range, Reward Multiplier]
- */
- static TIER_CONFIG = {
- 1: { name: "Recon", enemyLevel: [1, 2], multiplier: 1.0 },
- 2: { name: "Patrol", enemyLevel: [3, 4], multiplier: 1.5 },
- 3: { name: "Conflict", enemyLevel: [5, 6], multiplier: 2.5 },
- 4: { name: "War", enemyLevel: [7, 8], multiplier: 4.0 },
- 5: { name: "Suicide", enemyLevel: [9, 10], multiplier: 6.0 }
+ /**
+ * Tier configuration: [Name, Enemy Level Range, Reward Multiplier]
+ */
+ static TIER_CONFIG = {
+ 1: { name: "Recon", enemyLevel: [1, 2], multiplier: 1.0 },
+ 2: { name: "Patrol", enemyLevel: [3, 4], multiplier: 1.5 },
+ 3: { name: "Conflict", enemyLevel: [5, 6], multiplier: 2.5 },
+ 4: { name: "War", enemyLevel: [7, 8], multiplier: 4.0 },
+ 5: { name: "Suicide", enemyLevel: [9, 10], multiplier: 6.0 },
+ };
+
+ /**
+ * Maps biome types to faction IDs for reputation rewards
+ */
+ static BIOME_TO_FACTION = {
+ BIOME_FUNGAL_CAVES: "ARCANE_DOMINION",
+ BIOME_RUSTING_WASTES: "COGWORK_CONCORD",
+ BIOME_CRYSTAL_SPIRES: "ARCANE_DOMINION",
+ BIOME_VOID_SEEP: "SHADOW_COVENANT",
+ BIOME_CONTESTED_FRONTIER: "IRON_LEGION",
+ };
+
+ /**
+ * Templates for dynamic narrative generation
+ */
+ static NARRATIVE_TEMPLATES = {
+ INTRO: {
+ IRON_LEGION: {
+ speaker: "General Kael",
+ portrait: "assets/images/portraits/vanguard.png",
+ text: "Commander, Iron Legion scouts report {enemy} activity in {biome}. We need you to {verb} them immediately.",
+ },
+ ARCANE_DOMINION: {
+ speaker: "Magister Varen",
+ portrait: "assets/images/portraits/arcanist.png",
+ text: "Readings indicate {enemy} disturbances in {biome}. Eliminate the threat before it destabilizes the region.",
+ },
+ COGWORK_CONCORD: {
+ speaker: "Chief Engineer Rix",
+ portrait: "assets/images/portraits/engineer.png",
+ text: "We've got a situation in {biome}. {enemy} units are interfering with our operations. Shut them down.",
+ },
+ SHADOW_COVENANT: {
+ speaker: "Whisper",
+ portrait: "assets/images/portraits/rogue.png",
+ text: "A target of interest has appeared in {biome}. {enemy} forces are guarding it. Remove them.",
+ },
+ },
+ OUTRO: {
+ IRON_LEGION: {
+ speaker: "General Kael",
+ portrait: "assets/images/portraits/vanguard.png",
+ text: "Target neutralized. Funds transferred. Good hunting, Explorer.",
+ },
+ ARCANE_DOMINION: {
+ speaker: "Magister Varen",
+ portrait: "assets/images/portraits/arcanist.png",
+ text: "The anomaly has been corrected. Payment has been sent.",
+ },
+ COGWORK_CONCORD: {
+ speaker: "Chief Engineer Rix",
+ portrait: "assets/images/portraits/engineer.png",
+ text: "Efficiency restored. Your compensation is in the account.",
+ },
+ SHADOW_COVENANT: {
+ speaker: "Whisper",
+ portrait: "assets/images/portraits/rogue.png",
+ text: "Done and dusted. The Covenant remembers your service.",
+ },
+ },
+ };
+
+ /**
+ * Map of biomes to potential hazards
+ */
+ static BIOME_HAZARDS = {
+ BIOME_FUNGAL_CAVES: ["HAZARD_POISON_SPORES", "HAZARD_ACID_POOLS"],
+ BIOME_RUSTING_WASTES: ["HAZARD_RADIATION_ZONES", "HAZARD_MAGNETIC_STORM"],
+ BIOME_CRYSTAL_SPIRES: ["HAZARD_MANA_STORM", "HAZARD_CRYSTAL_RESONANCE"],
+ BIOME_VOID_SEEP: ["HAZARD_VOID_POCKETS", "HAZARD_GRAVITY_FLUX"],
+ BIOME_CONTESTED_FRONTIER: ["HAZARD_ARTILLERY_STRIKE", "HAZARD_MINEFIELD"],
+ };
+
+ /**
+ * Converts a number to Roman numeral
+ * @param {number} num - Number to convert (2-4)
+ * @returns {string} Roman numeral
+ */
+ static toRomanNumeral(num) {
+ const roman = ["", "I", "II", "III", "IV", "V"];
+ return roman[num] || "";
+ }
+
+ /**
+ * Extracts the base name (adjective + noun) from a mission title
+ * @param {string} title - Mission title (e.g., "Operation: Silent Viper II")
+ * @returns {string} Base name (e.g., "Silent Viper")
+ */
+ static extractBaseName(title) {
+ // Remove "Operation: " prefix and any Roman numeral suffix
+ const match = title.match(/Operation:\s*(.+?)(?:\s+[IVX]+)?$/);
+ if (match) {
+ return match[1].trim();
+ }
+ return title
+ .replace(/Operation:\s*/, "")
+ .replace(/\s+[IVX]+$/, "")
+ .trim();
+ }
+
+ /**
+ * Finds the highest Roman numeral in history for a given base name
+ * @param {string} baseName - Base name (e.g., "Silent Viper")
+ * @param {Array} history - Array of completed mission titles or IDs
+ * @returns {number} Highest numeral found (0 if none)
+ */
+ static findHighestNumeral(baseName, history) {
+ let highest = 0;
+ const pattern = new RegExp(
+ `Operation:\\s*${baseName.replace(
+ /[.*+?^${}()|[\]\\]/g,
+ "\\$&"
+ )}\\s+([IVX]+)`,
+ "i"
+ );
+
+ for (const entry of history) {
+ const match = entry.match(pattern);
+ if (match) {
+ const roman = match[1];
+ const num = this.romanToNumber(roman);
+ if (num > highest) {
+ highest = num;
+ }
+ }
+ }
+
+ return highest;
+ }
+
+ /**
+ * Converts Roman numeral to number
+ * @param {string} roman - Roman numeral string
+ * @returns {number} Number value
+ */
+ static romanToNumber(roman) {
+ const map = { I: 1, II: 2, III: 3, IV: 4, V: 5 };
+ return map[roman] || 0;
+ }
+
+ /**
+ * Selects a random element from an array
+ * @param {Array} array - Array to select from
+ * @returns {T} Random element
+ * @template T
+ */
+ static randomChoice(array) {
+ return array[Math.floor(Math.random() * array.length)];
+ }
+
+ /**
+ * Generates a random number between min and max (inclusive)
+ * @param {number} min - Minimum value
+ * @param {number} max - Maximum value
+ * @returns {number} Random number
+ */
+ static randomRange(min, max) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+ }
+
+ /**
+ * Generates a random float between min and max
+ * @param {number} min - Minimum value
+ * @param {number} max - Maximum value
+ * @returns {number} Random float
+ */
+ static randomFloat(min, max) {
+ return Math.random() * (max - min) + min;
+ }
+
+ /**
+ * Selects a biome from unlocked regions with weighting
+ * @param {Array} unlockedRegions - Array of biome type IDs
+ * @returns {string} Selected biome type
+ */
+ static selectBiome(unlockedRegions) {
+ if (unlockedRegions.length === 0) {
+ // Default fallback
+ return "BIOME_RUSTING_WASTES";
+ }
+
+ // 40% chance for the most recently unlocked region (last in array)
+ if (Math.random() < 0.4 && unlockedRegions.length > 0) {
+ return unlockedRegions[unlockedRegions.length - 1];
+ }
+
+ // Otherwise random selection
+ return this.randomChoice(unlockedRegions);
+ }
+
+ /**
+ * Generates a Side Op mission
+ * @param {number} tier - Campaign tier (1-5)
+ * @param {Array} unlockedRegions - Array of biome type IDs
+ * @param {Array} history - Array of completed mission titles or IDs
+ * @returns {MissionDefinition} Generated mission object
+ */
+ static generateSideOp(tier, unlockedRegions, history = []) {
+ // Validate tier
+ const validTier = Math.max(1, Math.min(5, tier));
+ const tierConfig = this.TIER_CONFIG[validTier];
+
+ // Select archetype
+ const archetypes = ["SKIRMISH", "SALVAGE", "ASSASSINATION", "RECON"];
+ const archetype = this.randomChoice(archetypes);
+
+ // Select noun based on archetype
+ let noun;
+ switch (archetype) {
+ case "SKIRMISH":
+ noun = this.randomChoice(this.NOUNS_SKIRMISH);
+ break;
+ case "SALVAGE":
+ noun = this.randomChoice(this.NOUNS_SALVAGE);
+ break;
+ case "ASSASSINATION":
+ noun = this.randomChoice(this.NOUNS_ASSASSINATION);
+ break;
+ case "RECON":
+ noun = this.randomChoice(this.NOUNS_RECON);
+ break;
+ default:
+ noun = this.randomChoice(this.NOUNS_SKIRMISH);
+ }
+
+ // Select adjective
+ const adjective = this.randomChoice(this.ADJECTIVES);
+
+ // Check history for series
+ const baseName = `${adjective} ${noun}`;
+ const highestNumeral = this.findHighestNumeral(baseName, history);
+ const nextNumeral = highestNumeral + 1;
+ const romanSuffix =
+ nextNumeral > 1 ? ` ${this.toRomanNumeral(nextNumeral)}` : "";
+
+ // Build title
+ const title = `Operation: ${baseName}${romanSuffix}`;
+
+ // Select biome
+ const biomeType = this.selectBiome(unlockedRegions);
+
+ // Generate objectives based on archetype
+ const objectives = this.generateObjectives(archetype, validTier);
+
+ // Generate biome config based on archetype
+ const biomeConfig = this.generateBiomeConfig(archetype, biomeType);
+
+ // Generate enemy spawns based on archetype (especially for ASSASSINATION)
+ const enemySpawns = this.generateEnemySpawns(
+ archetype,
+ objectives,
+ validTier
+ );
+
+ // Calculate rewards
+ const rewards = this.calculateRewards(validTier, archetype, biomeType);
+
+ // Determine Faction ID for narrative
+ const factionId = this.BIOME_TO_FACTION[biomeType] || "IRON_LEGION";
+
+ // Generate unique ID (timestamp + random to ensure uniqueness)
+ const missionId = `SIDE_OP_${Date.now()}_${Math.random()
+ .toString(36)
+ .substring(2, 9)}`;
+
+ // Generate Narrative
+ const narrative = this.generateNarrative(
+ missionId,
+ archetype,
+ biomeType,
+ factionId
+ );
+
+ // Build mission object
+ const mission = {
+ id: missionId,
+ type: "SIDE_QUEST",
+ config: {
+ title: title,
+ description: this.generateDescription(archetype, biomeType),
+ difficulty_tier: validTier,
+ recommended_level: tierConfig.enemyLevel[1], // Use max enemy level as recommended
+ },
+ biome: biomeConfig,
+ deployment: {
+ squad_size_limit: 4,
+ },
+ narrative: narrative,
+ objectives: objectives,
+ enemy_spawns: enemySpawns,
+ rewards: rewards,
+ expiresIn: 3, // Expires in 3 campaign days
};
- /**
- * Maps biome types to faction IDs for reputation rewards
- */
- static BIOME_TO_FACTION = {
- "BIOME_FUNGAL_CAVES": "ARCANE_DOMINION",
- "BIOME_RUSTING_WASTES": "COGWORK_CONCORD",
- "BIOME_CRYSTAL_SPIRES": "ARCANE_DOMINION",
- "BIOME_VOID_SEEP": "SHADOW_COVENANT",
- "BIOME_CONTESTED_FRONTIER": "IRON_LEGION"
- };
-
- /**
- * Converts a number to Roman numeral
- * @param {number} num - Number to convert (2-4)
- * @returns {string} Roman numeral
- */
- static toRomanNumeral(num) {
- const roman = ["", "I", "II", "III", "IV", "V"];
- return roman[num] || "";
- }
-
- /**
- * Extracts the base name (adjective + noun) from a mission title
- * @param {string} title - Mission title (e.g., "Operation: Silent Viper II")
- * @returns {string} Base name (e.g., "Silent Viper")
- */
- static extractBaseName(title) {
- // Remove "Operation: " prefix and any Roman numeral suffix
- const match = title.match(/Operation:\s*(.+?)(?:\s+[IVX]+)?$/);
- if (match) {
- return match[1].trim();
- }
- return title.replace(/Operation:\s*/, "").replace(/\s+[IVX]+$/, "").trim();
- }
-
- /**
- * Finds the highest Roman numeral in history for a given base name
- * @param {string} baseName - Base name (e.g., "Silent Viper")
- * @param {Array} history - Array of completed mission titles or IDs
- * @returns {number} Highest numeral found (0 if none)
- */
- static findHighestNumeral(baseName, history) {
- let highest = 0;
- const pattern = new RegExp(`Operation:\\s*${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+([IVX]+)`, "i");
-
- for (const entry of history) {
- const match = entry.match(pattern);
- if (match) {
- const roman = match[1];
- const num = this.romanToNumber(roman);
- if (num > highest) {
- highest = num;
- }
- }
- }
-
- return highest;
- }
-
- /**
- * Converts Roman numeral to number
- * @param {string} roman - Roman numeral string
- * @returns {number} Number value
- */
- static romanToNumber(roman) {
- const map = { "I": 1, "II": 2, "III": 3, "IV": 4, "V": 5 };
- return map[roman] || 0;
- }
-
- /**
- * Selects a random element from an array
- * @param {Array} array - Array to select from
- * @returns {T} Random element
- * @template T
- */
- static randomChoice(array) {
- return array[Math.floor(Math.random() * array.length)];
- }
-
- /**
- * Generates a random number between min and max (inclusive)
- * @param {number} min - Minimum value
- * @param {number} max - Maximum value
- * @returns {number} Random number
- */
- static randomRange(min, max) {
- return Math.floor(Math.random() * (max - min + 1)) + min;
- }
-
- /**
- * Generates a random float between min and max
- * @param {number} min - Minimum value
- * @param {number} max - Maximum value
- * @returns {number} Random float
- */
- static randomFloat(min, max) {
- return Math.random() * (max - min) + min;
- }
-
- /**
- * Selects a biome from unlocked regions with weighting
- * @param {Array} unlockedRegions - Array of biome type IDs
- * @returns {string} Selected biome type
- */
- static selectBiome(unlockedRegions) {
- if (unlockedRegions.length === 0) {
- // Default fallback
- return "BIOME_RUSTING_WASTES";
- }
-
- // 40% chance for the most recently unlocked region (last in array)
- if (Math.random() < 0.4 && unlockedRegions.length > 0) {
- return unlockedRegions[unlockedRegions.length - 1];
- }
-
- // Otherwise random selection
- return this.randomChoice(unlockedRegions);
- }
-
- /**
- * Generates a Side Op mission
- * @param {number} tier - Campaign tier (1-5)
- * @param {Array} unlockedRegions - Array of biome type IDs
- * @param {Array} history - Array of completed mission titles or IDs
- * @returns {MissionDefinition} Generated mission object
- */
- static generateSideOp(tier, unlockedRegions, history = []) {
- // Validate tier
- const validTier = Math.max(1, Math.min(5, tier));
- const tierConfig = this.TIER_CONFIG[validTier];
-
- // Select archetype
- const archetypes = ["SKIRMISH", "SALVAGE", "ASSASSINATION", "RECON"];
- const archetype = this.randomChoice(archetypes);
-
- // Select noun based on archetype
- let noun;
- switch (archetype) {
- case "SKIRMISH":
- noun = this.randomChoice(this.NOUNS_SKIRMISH);
- break;
- case "SALVAGE":
- noun = this.randomChoice(this.NOUNS_SALVAGE);
- break;
- case "ASSASSINATION":
- noun = this.randomChoice(this.NOUNS_ASSASSINATION);
- break;
- case "RECON":
- noun = this.randomChoice(this.NOUNS_RECON);
- break;
- default:
- noun = this.randomChoice(this.NOUNS_SKIRMISH);
- }
-
- // Select adjective
- const adjective = this.randomChoice(this.ADJECTIVES);
-
- // Check history for series
- const baseName = `${adjective} ${noun}`;
- const highestNumeral = this.findHighestNumeral(baseName, history);
- const nextNumeral = highestNumeral + 1;
- const romanSuffix = nextNumeral > 1 ? ` ${this.toRomanNumeral(nextNumeral)}` : "";
-
- // Build title
- const title = `Operation: ${baseName}${romanSuffix}`;
-
- // Select biome
- const biomeType = this.selectBiome(unlockedRegions);
-
- // Generate objectives based on archetype
- const objectives = this.generateObjectives(archetype, validTier);
-
- // Generate biome config based on archetype
- const biomeConfig = this.generateBiomeConfig(archetype, biomeType);
-
- // Generate enemy spawns based on archetype (especially for ASSASSINATION)
- const enemySpawns = this.generateEnemySpawns(archetype, objectives, validTier);
-
- // Calculate rewards
- const rewards = this.calculateRewards(validTier, archetype, biomeType);
-
- // Generate unique ID (timestamp + random to ensure uniqueness)
- const missionId = `SIDE_OP_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
-
- // Build mission object
- const mission = {
- id: missionId,
- type: "SIDE_QUEST",
- config: {
- title: title,
- description: this.generateDescription(archetype, biomeType),
- difficulty_tier: validTier,
- recommended_level: tierConfig.enemyLevel[1] // Use max enemy level as recommended
- },
- biome: biomeConfig,
- deployment: {
- squad_size_limit: 4
- },
- objectives: objectives,
- enemy_spawns: enemySpawns,
- rewards: rewards,
- expiresIn: 3 // Expires in 3 campaign days
+ // Add boss config for Assassination missions
+ if (archetype === "ASSASSINATION") {
+ // Find target ID from objectives
+ const targetObj = objectives.primary.find(
+ (o) => o.type === "ELIMINATE_UNIT"
+ );
+ if (targetObj?.target_def_id) {
+ mission.config.boss_config = {
+ target_def_id: targetObj.target_def_id,
+ name: `${this.randomChoice([
+ "Krag",
+ "Vorak",
+ "Xol",
+ "Zea",
+ ])} the ${this.randomChoice(["Breaker", "Rot", "Vile", "Ender"])}`,
+ stats: {
+ hp_multiplier: 2.0,
+ attack_multiplier: 1.5,
+ },
};
-
- return mission;
+ }
}
- /**
- * Generates objectives based on archetype
- * @param {string} archetype - Mission archetype
- * @param {number} tier - Difficulty tier
- * @returns {Object} Objectives object
- */
- static generateObjectives(archetype, tier) {
- switch (archetype) {
- case "SKIRMISH":
- return {
- primary: [{
- id: "OBJ_ELIMINATE_ALL",
- type: "ELIMINATE_ALL",
- description: "Clear the sector of hostile forces."
- }],
- failure_conditions: [{ type: "SQUAD_WIPE" }]
- };
+ return mission;
+ }
- case "SALVAGE":
- const crateCount = this.randomRange(3, 5);
- return {
- primary: [{
- id: "OBJ_SALVAGE",
- type: "INTERACT",
- target_object_id: "OBJ_SUPPLY_CRATE",
- target_count: crateCount,
- description: `Recover ${crateCount} supply crates before the enemy secures them.`
- }],
- failure_conditions: [{ type: "SQUAD_WIPE" }]
- };
+ /**
+ * Generates dynamic narrative for the mission
+ * @param {string} missionId - The unique ID of the mission
+ * @param {string} archetype - Mission archetype
+ * @param {string} biomeType - Biome type
+ * @param {string} factionId - Faction ID requesting the mission
+ * @returns {Object} Narrative object with intro/outro and dynamic data
+ */
+ static generateNarrative(missionId, archetype, biomeType, factionId) {
+ const introId = `NARRATIVE_${missionId}_INTRO`;
+ const outroId = `NARRATIVE_${missionId}_OUTRO`;
- case "ASSASSINATION":
- // Generate a random elite enemy ID
- const eliteEnemies = [
- "ENEMY_ELITE_ECHO",
- "ENEMY_ELITE_BREAKER",
- "ENEMY_ELITE_STALKER",
- "ENEMY_ELITE_WARDEN"
- ];
- const targetId = this.randomChoice(eliteEnemies);
- return {
- primary: [{
- id: "OBJ_HUNT",
- type: "ELIMINATE_UNIT",
- target_def_id: targetId,
- target_count: 1, // Explicitly set to 1 for single target elimination
- description: "A High-Value Target has been spotted. Eliminate them."
- }],
- failure_conditions: [{ type: "SQUAD_WIPE" }]
- };
+ // Get templates (fallback to Iron Legion if missing)
+ const introTemplate =
+ this.NARRATIVE_TEMPLATES.INTRO[factionId] ||
+ this.NARRATIVE_TEMPLATES.INTRO["IRON_LEGION"];
+ const outroTemplate =
+ this.NARRATIVE_TEMPLATES.OUTRO[factionId] ||
+ this.NARRATIVE_TEMPLATES.OUTRO["IRON_LEGION"];
- case "RECON":
- // Generate 3 zone coordinates (simplified - actual zones would be set during mission generation)
- // Turn limit: 15 + (tier * 5) turns for RECON missions
- const turnLimit = 15 + (tier * 5);
- return {
- primary: [{
- id: "OBJ_RECON",
- type: "REACH_ZONE",
- target_count: 3,
- description: "Survey the designated coordinates."
- }],
- failure_conditions: [
- { type: "SQUAD_WIPE" },
- { type: "TURN_LIMIT_EXCEEDED", turn_limit: turnLimit }
- ]
- };
-
- default:
- return {
- primary: [{
- id: "OBJ_DEFAULT",
- type: "ELIMINATE_ALL",
- description: "Complete the mission objectives."
- }],
- failure_conditions: [{ type: "SQUAD_WIPE" }]
- };
- }
+ // Format Strings
+ const biomeName = biomeType.replace("BIOME_", "").replace("_", " ");
+ let verb = "eliminate";
+ switch (archetype) {
+ case "SALVAGE":
+ verb = "recover";
+ break;
+ case "RECON":
+ verb = "scout";
+ break;
+ case "ASSASSINATION":
+ verb = "assassinate";
+ break;
}
- /**
- * Generates enemy spawns based on archetype and objectives
- * @param {string} archetype - Mission archetype
- * @param {Object} objectives - Generated objectives
- * @param {number} tier - Difficulty tier
- * @returns {Array} Array of enemy spawn definitions
- */
- static generateEnemySpawns(archetype, objectives, tier) {
- const spawns = [];
+ const introText = introTemplate.text
+ .replace("{enemy}", "hostile")
+ .replace("{biome}", biomeName.toLowerCase())
+ .replace("{verb}", verb);
- switch (archetype) {
- case "ASSASSINATION":
- // For ASSASSINATION, spawn the target enemy from the ELIMINATE_UNIT objective
- const eliminateUnitObj = objectives.primary?.find(
- (obj) => obj.type === "ELIMINATE_UNIT"
- );
- if (eliminateUnitObj?.target_def_id) {
- spawns.push({
- enemy_def_id: eliminateUnitObj.target_def_id,
- count: 1
- });
- }
- // Also spawn some regular enemies for support
- const regularEnemies = [
- "ENEMY_SHARDBORN_SENTINEL",
- "ENEMY_GOBLIN_RAIDER",
- "ENEMY_CRYSTAL_SHARD"
- ];
- const supportEnemy = this.randomChoice(regularEnemies);
- spawns.push({
- enemy_def_id: supportEnemy,
- count: Math.max(1, Math.floor(tier / 2)) // 1-2 support enemies based on tier
- });
- break;
-
- case "SKIRMISH":
- // Skirmish missions spawn a mix of enemies
- const skirmishEnemies = [
- "ENEMY_SHARDBORN_SENTINEL",
- "ENEMY_GOBLIN_RAIDER",
- "ENEMY_CRYSTAL_SHARD"
- ];
- const totalSkirmish = 3 + tier; // 4-8 enemies based on tier
- for (let i = 0; i < totalSkirmish; i++) {
- const enemyType = this.randomChoice(skirmishEnemies);
- const existingSpawn = spawns.find((s) => s.enemy_def_id === enemyType);
- if (existingSpawn) {
- existingSpawn.count++;
- } else {
- spawns.push({ enemy_def_id: enemyType, count: 1 });
- }
- }
- break;
-
- case "SALVAGE":
- case "RECON":
- // These missions have fewer enemies (lower density)
- const lightEnemies = [
- "ENEMY_SHARDBORN_SENTINEL",
- "ENEMY_GOBLIN_RAIDER"
- ];
- const totalLight = Math.max(1, tier); // 1-5 enemies based on tier
- for (let i = 0; i < totalLight; i++) {
- const enemyType = this.randomChoice(lightEnemies);
- const existingSpawn = spawns.find((s) => s.enemy_def_id === enemyType);
- if (existingSpawn) {
- existingSpawn.count++;
- } else {
- spawns.push({ enemy_def_id: enemyType, count: 1 });
- }
- }
- break;
-
- default:
- // Default: spawn a few basic enemies
- spawns.push({
- enemy_def_id: "ENEMY_SHARDBORN_SENTINEL",
- count: Math.max(1, tier)
- });
- }
-
- return spawns;
- }
-
- /**
- * Generates biome configuration based on archetype
- * @param {string} archetype - Mission archetype
- * @param {string} biomeType - Biome type ID
- * @returns {Object} Biome configuration
- */
- static generateBiomeConfig(archetype, biomeType) {
- let size, roomCount, density;
-
- switch (archetype) {
- case "SKIRMISH":
- size = { x: 20, y: 12, z: 20 };
- roomCount = 6;
- density = "MEDIUM";
- break;
-
- case "SALVAGE":
- size = { x: 18, y: 12, z: 18 };
- roomCount = 5;
- density = "HIGH"; // High density for obstacles/cover
- break;
-
- case "ASSASSINATION":
- size = { x: 22, y: 12, z: 22 };
- roomCount = 7;
- density = "MEDIUM";
- break;
-
- case "RECON":
- size = { x: 24, y: 12, z: 24 }; // Large map
- roomCount = 8;
- density = "LOW"; // Low enemy density
- break;
-
- default:
- size = { x: 20, y: 12, z: 20 };
- roomCount = 6;
- density = "MEDIUM";
- }
+ return {
+ intro_sequence: introId,
+ outro_success: outroId,
+ _dynamic_data: {
+ [introId]: {
+ id: introId,
+ nodes: [
+ {
+ id: "1",
+ type: "DIALOGUE",
+ speaker: introTemplate.speaker,
+ portrait: introTemplate.portrait,
+ text: introText,
+ next: "END",
+ },
+ ],
+ },
+ [outroId]: {
+ id: outroId,
+ nodes: [
+ {
+ id: "1",
+ type: "DIALOGUE",
+ speaker: outroTemplate.speaker,
+ portrait: outroTemplate.portrait,
+ text: outroTemplate.text,
+ next: "END",
+ },
+ ],
+ },
+ },
+ };
+ }
+ /**
+ * Generates objectives based on archetype
+ * @param {string} archetype - Mission archetype
+ * @param {number} tier - Difficulty tier
+ * @returns {Object} Objectives object
+ */
+ static generateObjectives(archetype, tier) {
+ switch (archetype) {
+ case "SKIRMISH":
return {
- type: biomeType,
- generator_config: {
- seed_type: "RANDOM",
- size: size,
- room_count: roomCount,
- density: density
- }
- };
- }
-
- /**
- * Generates mission description based on archetype and biome
- * @param {string} archetype - Mission archetype
- * @param {string} biomeType - Biome type ID
- * @returns {string} Description text
- */
- static generateDescription(archetype, biomeType) {
- const biomeNames = {
- "BIOME_FUNGAL_CAVES": "Fungal Caves",
- "BIOME_RUSTING_WASTES": "Rusting Wastes",
- "BIOME_CRYSTAL_SPIRES": "Crystal Spires",
- "BIOME_VOID_SEEP": "Void Seep",
- "BIOME_CONTESTED_FRONTIER": "Contested Frontier"
- };
- const biomeName = biomeNames[biomeType] || "the region";
-
- switch (archetype) {
- case "SKIRMISH":
- return `Clear the sector of hostile forces in ${biomeName}.`;
- case "SALVAGE":
- return `Recover lost supplies before the enemy secures them in ${biomeName}.`;
- case "ASSASSINATION":
- return `A High-Value Target has been spotted in ${biomeName}. Eliminate them.`;
- case "RECON":
- return `Survey the designated coordinates in ${biomeName}.`;
- default:
- return `Complete the mission objectives in ${biomeName}.`;
- }
- }
-
- /**
- * Calculates rewards based on tier and archetype
- * @param {number} tier - Difficulty tier
- * @param {string} archetype - Mission archetype
- * @param {string} biomeType - Biome type ID
- * @returns {Object} Rewards object
- */
- static calculateRewards(tier, archetype, biomeType) {
- const tierConfig = this.TIER_CONFIG[tier];
- const multiplier = tierConfig.multiplier;
-
- // Base currency calculation: Base (50) * TierMultiplier * Random(0.8, 1.2)
- const baseCurrency = 50;
- const randomFactor = this.randomFloat(0.8, 1.2);
- const currencyAmount = Math.round(baseCurrency * multiplier * randomFactor);
-
- // XP calculation (base 100 * multiplier)
- const baseXP = 100;
- const xpAmount = Math.round(baseXP * multiplier * this.randomFloat(0.9, 1.1));
-
- // Assassination missions get bonus currency
- let finalCurrency = currencyAmount;
- if (archetype === "ASSASSINATION") {
- finalCurrency = Math.round(finalCurrency * 1.5);
- }
-
- // Items: 20% chance per Tier to drop a Chest Key or Item
- const items = [];
- const itemChance = tier * 0.2;
- if (Math.random() < itemChance) {
- // Randomly choose between chest key or item
- if (Math.random() < 0.5) {
- items.push("ITEM_CHEST_KEY");
- } else {
- // Generic item - would need item registry in real implementation
- items.push("ITEM_MATERIAL_SCRAP");
- }
- }
-
- // Reputation: +10 with the Region's owner
- const factionId = this.BIOME_TO_FACTION[biomeType] || "IRON_LEGION";
- const reputation = 10;
-
- const rewards = {
- guaranteed: {
- xp: xpAmount,
- currency: {
- aether_shards: finalCurrency
- }
+ primary: [
+ {
+ id: "OBJ_ELIMINATE_ALL",
+ type: "ELIMINATE_ALL",
+ description: "Clear the sector of hostile forces.",
},
- faction_reputation: {
- [factionId]: reputation
- }
+ ],
+ failure_conditions: [{ type: "SQUAD_WIPE" }],
};
- // Add items if any
- if (items.length > 0) {
- rewards.guaranteed.items = items;
- }
+ case "SALVAGE":
+ const crateCount = this.randomRange(3, 5);
+ return {
+ primary: [
+ {
+ id: "OBJ_SALVAGE",
+ type: "INTERACT",
+ target_object_id: "OBJ_SUPPLY_CRATE",
+ target_count: crateCount,
+ description: `Recover ${crateCount} supply crates before the enemy secures them.`,
+ },
+ ],
+ failure_conditions: [{ type: "SQUAD_WIPE" }],
+ };
- return rewards;
+ case "ASSASSINATION":
+ // Generate a random elite enemy ID
+ const eliteEnemies = [
+ "ENEMY_ELITE_ECHO",
+ "ENEMY_ELITE_BREAKER",
+ "ENEMY_ELITE_STALKER",
+ "ENEMY_ELITE_WARDEN",
+ ];
+ const targetId = this.randomChoice(eliteEnemies);
+ return {
+ primary: [
+ {
+ id: "OBJ_HUNT",
+ type: "ELIMINATE_UNIT",
+ target_def_id: targetId,
+ target_count: 1, // Explicitly set to 1 for single target elimination
+ description:
+ "A High-Value Target has been spotted. Eliminate them.",
+ },
+ ],
+ failure_conditions: [{ type: "SQUAD_WIPE" }],
+ };
+
+ case "RECON":
+ // Generate 3 zone coordinates (simplified - actual zones would be set during mission generation)
+ // Turn limit: 15 + (tier * 5) turns for RECON missions
+ const turnLimit = 15 + tier * 5;
+ return {
+ primary: [
+ {
+ id: "OBJ_RECON",
+ type: "REACH_ZONE",
+ target_count: 3,
+ description: "Survey the designated coordinates.",
+ },
+ ],
+ failure_conditions: [
+ { type: "SQUAD_WIPE" },
+ { type: "TURN_LIMIT_EXCEEDED", turn_limit: turnLimit },
+ ],
+ };
+
+ default:
+ return {
+ primary: [
+ {
+ id: "OBJ_DEFAULT",
+ type: "ELIMINATE_ALL",
+ description: "Complete the mission objectives.",
+ },
+ ],
+ failure_conditions: [{ type: "SQUAD_WIPE" }],
+ };
}
+ }
- /**
- * Refreshes the mission board, filling it up to 5 entries and removing expired missions
- * @param {Array} currentMissions - Current list of available missions
- * @param {number} tier - Current campaign tier
- * @param {Array} unlockedRegions - Array of unlocked biome type IDs
- * @param {Array} history - Array of completed mission titles or IDs
- * @param {boolean} isDailyReset - If true, decrements expiresIn for all missions
- * @returns {Array} Updated mission list
- */
- static refreshBoard(currentMissions = [], tier, unlockedRegions, history = [], isDailyReset = false) {
- // On daily reset, decrement expiresIn for all missions
- let validMissions = currentMissions;
- if (isDailyReset) {
- validMissions = currentMissions.map(mission => {
- if (mission.expiresIn !== undefined) {
- const updated = { ...mission };
- updated.expiresIn = (mission.expiresIn || 3) - 1;
- return updated;
- }
- return mission;
- });
+ /**
+ * Generates enemy spawns based on archetype and objectives
+ * @param {string} archetype - Mission archetype
+ * @param {Object} objectives - Generated objectives
+ * @param {number} tier - Difficulty tier
+ * @returns {Array} Array of enemy spawn definitions
+ */
+ static generateEnemySpawns(archetype, objectives, tier) {
+ const spawns = [];
+
+ switch (archetype) {
+ case "ASSASSINATION":
+ // For ASSASSINATION, spawn the target enemy from the ELIMINATE_UNIT objective
+ const eliminateUnitObj = objectives.primary?.find(
+ (obj) => obj.type === "ELIMINATE_UNIT"
+ );
+ if (eliminateUnitObj?.target_def_id) {
+ spawns.push({
+ enemy_def_id: eliminateUnitObj.target_def_id,
+ count: 1,
+ });
}
-
- // Remove missions that have expired (expiresIn <= 0)
- validMissions = validMissions.filter(mission => {
- if (mission.expiresIn !== undefined) {
- return mission.expiresIn > 0;
- }
- // Keep missions without expiration tracking
- return true;
+ // Also spawn some regular enemies for support
+ const regularEnemies = [
+ "ENEMY_SHARDBORN_SENTINEL",
+ "ENEMY_GOBLIN_RAIDER",
+ "ENEMY_CRYSTAL_SHARD",
+ ];
+ const supportEnemy = this.randomChoice(regularEnemies);
+ spawns.push({
+ enemy_def_id: supportEnemy,
+ count: Math.max(1, Math.floor(tier / 2)), // 1-2 support enemies based on tier
});
+ break;
- // Fill up to 5 missions
- const targetCount = 5;
- const needed = Math.max(0, targetCount - validMissions.length);
-
- const newMissions = [];
- for (let i = 0; i < needed; i++) {
- const mission = this.generateSideOp(tier, unlockedRegions, history);
- newMissions.push(mission);
+ case "SKIRMISH":
+ // Skirmish missions spawn a mix of enemies
+ const skirmishEnemies = [
+ "ENEMY_SHARDBORN_SENTINEL",
+ "ENEMY_GOBLIN_RAIDER",
+ "ENEMY_CRYSTAL_SHARD",
+ ];
+ const totalSkirmish = 3 + tier; // 4-8 enemies based on tier
+ for (let i = 0; i < totalSkirmish; i++) {
+ const enemyType = this.randomChoice(skirmishEnemies);
+ const existingSpawn = spawns.find(
+ (s) => s.enemy_def_id === enemyType
+ );
+ if (existingSpawn) {
+ existingSpawn.count++;
+ } else {
+ spawns.push({ enemy_def_id: enemyType, count: 1 });
+ }
}
+ break;
- // Combine and return
- return [...validMissions, ...newMissions];
+ case "SALVAGE":
+ case "RECON":
+ // These missions have fewer enemies (lower density)
+ const lightEnemies = [
+ "ENEMY_SHARDBORN_SENTINEL",
+ "ENEMY_GOBLIN_RAIDER",
+ ];
+ const totalLight = Math.max(1, tier); // 1-5 enemies based on tier
+ for (let i = 0; i < totalLight; i++) {
+ const enemyType = this.randomChoice(lightEnemies);
+ const existingSpawn = spawns.find(
+ (s) => s.enemy_def_id === enemyType
+ );
+ if (existingSpawn) {
+ existingSpawn.count++;
+ } else {
+ spawns.push({ enemy_def_id: enemyType, count: 1 });
+ }
+ }
+ break;
+
+ default:
+ // Default: spawn a few basic enemies
+ spawns.push({
+ enemy_def_id: "ENEMY_SHARDBORN_SENTINEL",
+ count: Math.max(1, tier),
+ });
}
-}
+ return spawns;
+ }
+
+ /**
+ * Generates biome configuration based on archetype
+ * @param {string} archetype - Mission archetype
+ * @param {string} biomeType - Biome type ID
+ * @returns {Object} Biome configuration
+ */
+ static generateBiomeConfig(archetype, biomeType) {
+ let size, roomCount, density;
+
+ switch (archetype) {
+ case "SKIRMISH":
+ size = { x: 20, y: 12, z: 20 };
+ roomCount = 6;
+ density = "MEDIUM";
+ break;
+
+ case "SALVAGE":
+ size = { x: 18, y: 12, z: 18 };
+ roomCount = 5;
+ density = "HIGH"; // High density for obstacles/cover
+ break;
+
+ case "ASSASSINATION":
+ size = { x: 22, y: 12, z: 22 };
+ roomCount = 7;
+ density = "MEDIUM";
+ break;
+
+ case "RECON":
+ size = { x: 24, y: 12, z: 24 }; // Large map
+ roomCount = 8;
+ density = "LOW"; // Low enemy density
+ break;
+
+ default:
+ size = { x: 20, y: 12, z: 20 };
+ roomCount = 6;
+ density = "MEDIUM";
+ }
+
+ // Roll for Hazards (30% chance)
+ const hazards = [];
+ const allowedHazards = this.BIOME_HAZARDS[biomeType];
+ if (allowedHazards && Math.random() < 0.3) {
+ hazards.push(this.randomChoice(allowedHazards));
+ }
+
+ return {
+ type: biomeType,
+ hazards: hazards,
+ generator_config: {
+ seed_type: "RANDOM",
+ size: size,
+ room_count: roomCount,
+ density: density,
+ },
+ };
+ }
+
+ /**
+ * Generates mission description based on archetype and biome
+ * @param {string} archetype - Mission archetype
+ * @param {string} biomeType - Biome type ID
+ * @returns {string} Description text
+ */
+ static generateDescription(archetype, biomeType) {
+ const biomeNames = {
+ BIOME_FUNGAL_CAVES: "Fungal Caves",
+ BIOME_RUSTING_WASTES: "Rusting Wastes",
+ BIOME_CRYSTAL_SPIRES: "Crystal Spires",
+ BIOME_VOID_SEEP: "Void Seep",
+ BIOME_CONTESTED_FRONTIER: "Contested Frontier",
+ };
+ const biomeName = biomeNames[biomeType] || "the region";
+
+ switch (archetype) {
+ case "SKIRMISH":
+ return `Clear the sector of hostile forces in ${biomeName}.`;
+ case "SALVAGE":
+ return `Recover lost supplies before the enemy secures them in ${biomeName}.`;
+ case "ASSASSINATION":
+ return `A High-Value Target has been spotted in ${biomeName}. Eliminate them.`;
+ case "RECON":
+ return `Survey the designated coordinates in ${biomeName}.`;
+ default:
+ return `Complete the mission objectives in ${biomeName}.`;
+ }
+ }
+
+ /**
+ * Calculates rewards based on tier and archetype
+ * @param {number} tier - Difficulty tier
+ * @param {string} archetype - Mission archetype
+ * @param {string} biomeType - Biome type ID
+ * @returns {Object} Rewards object
+ */
+ static calculateRewards(tier, archetype, biomeType) {
+ const tierConfig = this.TIER_CONFIG[tier];
+ const multiplier = tierConfig.multiplier;
+
+ // Base currency calculation: Base (50) * TierMultiplier * Random(0.8, 1.2)
+ const baseCurrency = 50;
+ const randomFactor = this.randomFloat(0.8, 1.2);
+ const currencyAmount = Math.round(baseCurrency * multiplier * randomFactor);
+
+ // XP calculation (base 100 * multiplier)
+ const baseXP = 100;
+ const xpAmount = Math.round(
+ baseXP * multiplier * this.randomFloat(0.9, 1.1)
+ );
+
+ // Assassination missions get bonus currency
+ let finalCurrency = currencyAmount;
+ if (archetype === "ASSASSINATION") {
+ finalCurrency = Math.round(finalCurrency * 1.5);
+ }
+
+ // Items: 20% chance per Tier to drop a Chest Key or Item
+ const items = [];
+ const itemChance = tier * 0.2;
+ if (Math.random() < itemChance) {
+ // Randomly choose between chest key or item
+ if (Math.random() < 0.5) {
+ items.push("ITEM_CHEST_KEY");
+ } else {
+ // Generic item - would need item registry in real implementation
+ items.push("ITEM_MATERIAL_SCRAP");
+ }
+ }
+
+ // Reputation: +10 with the Region's owner
+ const factionId = this.BIOME_TO_FACTION[biomeType] || "IRON_LEGION";
+ const reputation = 10;
+
+ const rewards = {
+ guaranteed: {
+ xp: xpAmount,
+ currency: {
+ aether_shards: finalCurrency,
+ },
+ },
+ faction_reputation: {
+ [factionId]: reputation,
+ },
+ };
+
+ // Add items if any
+ if (items.length > 0) {
+ rewards.guaranteed.items = items;
+ }
+
+ return rewards;
+ }
+
+ /**
+ * Refreshes the mission board, filling it up to 5 entries and removing expired missions
+ * @param {Array} currentMissions - Current list of available missions
+ * @param {number} tier - Current campaign tier
+ * @param {Array} unlockedRegions - Array of unlocked biome type IDs
+ * @param {Array} history - Array of completed mission titles or IDs
+ * @param {boolean} isDailyReset - If true, decrements expiresIn for all missions
+ * @returns {Array} Updated mission list
+ */
+ static refreshBoard(
+ currentMissions = [],
+ tier,
+ unlockedRegions,
+ history = [],
+ isDailyReset = false
+ ) {
+ // On daily reset, decrement expiresIn for all missions
+ let validMissions = currentMissions;
+ if (isDailyReset) {
+ validMissions = currentMissions.map((mission) => {
+ if (mission.expiresIn !== undefined) {
+ const updated = { ...mission };
+ updated.expiresIn = (mission.expiresIn || 3) - 1;
+ return updated;
+ }
+ return mission;
+ });
+ }
+
+ // Remove missions that have expired (expiresIn <= 0)
+ validMissions = validMissions.filter((mission) => {
+ if (mission.expiresIn !== undefined) {
+ return mission.expiresIn > 0;
+ }
+ // Keep missions without expiration tracking
+ return true;
+ });
+
+ // Fill up to 5 missions
+ const targetCount = 5;
+ const needed = Math.max(0, targetCount - validMissions.length);
+
+ const newMissions = [];
+ for (let i = 0; i < needed; i++) {
+ const mission = this.generateSideOp(tier, unlockedRegions, history);
+ newMissions.push(mission);
+ }
+
+ // Combine and return
+ return [...validMissions, ...newMissions];
+ }
+}
diff --git a/test/core/GameLoop/helpers.js b/test/core/GameLoop/helpers.js
index feda44b..4ac3058 100644
--- a/test/core/GameLoop/helpers.js
+++ b/test/core/GameLoop/helpers.js
@@ -79,6 +79,7 @@ export function createMockMissionManager(enemySpawns = []) {
getActiveMission: sinon.stub().returns(mockMissionDef),
setGridContext: sinon.stub(),
populateZoneCoordinates: sinon.stub(),
+ resolveMissionObjectPositions: sinon.stub().returns([]),
};
}
@@ -102,7 +103,10 @@ export function createRunData(overrides = {}) {
* @returns {{ playerUnit: Object; enemyUnit: Object }}
*/
export function setupCombatUnits(gameLoop) {
- const playerUnit = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
+ const playerUnit = gameLoop.unitManager.createUnit(
+ "CLASS_VANGUARD",
+ "PLAYER"
+ );
playerUnit.baseStats.movement = 4;
playerUnit.baseStats.speed = 10;
playerUnit.currentAP = 10;
@@ -136,7 +140,7 @@ export function cleanupTurnSystem(gameLoop) {
// End combat first to stop any loops
gameLoop.turnSystem.endCombat();
}
-
+
// Then reset the turn system
if (typeof gameLoop.turnSystem.reset === "function") {
gameLoop.turnSystem.reset();
@@ -154,4 +158,3 @@ export function cleanupTurnSystem(gameLoop) {
}
}
}
-
diff --git a/test/managers/MissionManager.test.js b/test/managers/MissionManager.test.js
index eac4ac5..be99491 100644
--- a/test/managers/MissionManager.test.js
+++ b/test/managers/MissionManager.test.js
@@ -37,7 +37,7 @@ describe("Manager: MissionManager", () => {
it("CoA 1: Should initialize with tutorial mission registered", async () => {
await manager._ensureMissionsLoaded();
- expect(manager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true;
+ expect(manager.missionRegistry.has("MISSION_ACT1_01")).to.be.true;
expect(manager.activeMissionId).to.be.null;
expect(manager.completedMissions).to.be.instanceof(Set);
});
@@ -60,7 +60,7 @@ describe("Manager: MissionManager", () => {
const mission = await manager.getActiveMission();
expect(mission).to.exist;
- expect(mission.id).to.equal("MISSION_TUTORIAL_01");
+ expect(mission.id).to.equal("MISSION_ACT1_01");
});
it("CoA 4: getActiveMission should return active mission if set", async () => {
@@ -83,7 +83,11 @@ describe("Manager: MissionManager", () => {
mission.objectives = {
primary: [
{ type: "ELIMINATE_ALL", target_count: 5 },
- { type: "ELIMINATE_UNIT", target_def_id: "ENEMY_GOBLIN", target_count: 3 },
+ {
+ type: "ELIMINATE_UNIT",
+ target_def_id: "ENEMY_GOBLIN",
+ target_count: 3,
+ },
],
};
@@ -106,9 +110,7 @@ describe("Manager: MissionManager", () => {
manager.setUnitManager(mockUnitManager);
await manager.setupActiveMission();
- manager.currentObjectives = [
- { type: "ELIMINATE_ALL", complete: false },
- ];
+ manager.currentObjectives = [{ type: "ELIMINATE_ALL", complete: false }];
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" });
@@ -128,9 +130,18 @@ describe("Manager: MissionManager", () => {
},
];
- manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN", defId: "ENEMY_GOBLIN" });
- manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_OTHER", defId: "ENEMY_OTHER" }); // Should not count
- manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN", defId: "ENEMY_GOBLIN" });
+ manager.onGameEvent("ENEMY_DEATH", {
+ unitId: "ENEMY_GOBLIN",
+ defId: "ENEMY_GOBLIN",
+ });
+ manager.onGameEvent("ENEMY_DEATH", {
+ unitId: "ENEMY_OTHER",
+ defId: "ENEMY_OTHER",
+ }); // Should not count
+ manager.onGameEvent("ENEMY_DEATH", {
+ unitId: "ENEMY_GOBLIN",
+ defId: "ENEMY_GOBLIN",
+ });
expect(manager.currentObjectives[0].current).to.equal(2);
expect(manager.currentObjectives[0].complete).to.be.true;
@@ -148,9 +159,9 @@ describe("Manager: MissionManager", () => {
manager.currentObjectives = [
{ type: "ELIMINATE_ALL", target_count: 2, current: 2, complete: true },
];
- manager.activeMissionId = "MISSION_TUTORIAL_01";
+ manager.activeMissionId = "MISSION_ACT1_01";
manager.currentMissionDef = {
- id: "MISSION_TUTORIAL_01",
+ id: "MISSION_ACT1_01",
rewards: { guaranteed: {} },
};
@@ -163,9 +174,9 @@ describe("Manager: MissionManager", () => {
});
it("CoA 9: completeActiveMission should add mission to completed set", async () => {
- manager.activeMissionId = "MISSION_TUTORIAL_01";
+ manager.activeMissionId = "MISSION_ACT1_01";
manager.currentMissionDef = {
- id: "MISSION_TUTORIAL_01",
+ id: "MISSION_ACT1_01",
rewards: { guaranteed: {} },
};
@@ -175,44 +186,48 @@ describe("Manager: MissionManager", () => {
await manager.completeActiveMission();
- expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true;
+ expect(manager.completedMissions.has("MISSION_ACT1_01")).to.be.true;
expect(eventSpy.called).to.be.true;
- expect(eventSpy.firstCall.args[0].detail.missionCompleted).to.equal("MISSION_TUTORIAL_01");
+ expect(eventSpy.firstCall.args[0].detail.missionCompleted).to.equal(
+ "MISSION_ACT1_01"
+ );
window.removeEventListener("campaign-data-changed", eventSpy);
});
it("CoA 10: load should restore completed missions", () => {
const saveData = {
- completedMissions: ["MISSION_TUTORIAL_01", "MISSION_TEST_01"],
+ completedMissions: ["MISSION_ACT1_01", "MISSION_TEST_01"],
};
manager.load(saveData);
- expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true;
+ expect(manager.completedMissions.has("MISSION_ACT1_01")).to.be.true;
expect(manager.completedMissions.has("MISSION_TEST_01")).to.be.true;
});
it("CoA 11: save should serialize completed missions", () => {
- manager.completedMissions.add("MISSION_TUTORIAL_01");
+ manager.completedMissions.add("MISSION_ACT1_01");
manager.completedMissions.add("MISSION_TEST_01");
const saved = manager.save();
expect(saved.completedMissions).to.be.an("array");
- expect(saved.completedMissions).to.include("MISSION_TUTORIAL_01");
+ expect(saved.completedMissions).to.include("MISSION_ACT1_01");
expect(saved.completedMissions).to.include("MISSION_TEST_01");
});
it("CoA 12: _mapNarrativeIdToFileName should convert narrative IDs to filenames", () => {
- expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_INTRO")).to.equal(
- "tutorial_intro"
- );
- expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_SUCCESS")).to.equal(
- "tutorial_success"
- );
+ expect(
+ manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_INTRO")
+ ).to.equal("tutorial_intro");
+ expect(
+ manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_SUCCESS")
+ ).to.equal("tutorial_success");
// The implementation converts NARRATIVE_UNKNOWN to narrative_unknown (lowercase with NARRATIVE_ prefix removed)
- expect(manager._mapNarrativeIdToFileName("NARRATIVE_UNKNOWN")).to.equal("narrative_unknown");
+ expect(manager._mapNarrativeIdToFileName("NARRATIVE_UNKNOWN")).to.equal(
+ "narrative_unknown"
+ );
});
it("CoA 13: getActiveMission should expose enemy_spawns from mission definition", async () => {
@@ -220,9 +235,7 @@ describe("Manager: MissionManager", () => {
const missionWithEnemies = {
id: "MISSION_TEST",
config: { title: "Test Mission" },
- enemy_spawns: [
- { enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", count: 2 },
- ],
+ enemy_spawns: [{ enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", count: 2 }],
objectives: { primary: [] },
};
@@ -233,7 +246,9 @@ describe("Manager: MissionManager", () => {
expect(mission.enemy_spawns).to.exist;
expect(mission.enemy_spawns).to.have.length(1);
- expect(mission.enemy_spawns[0].enemy_def_id).to.equal("ENEMY_SHARDBORN_SENTINEL");
+ expect(mission.enemy_spawns[0].enemy_def_id).to.equal(
+ "ENEMY_SHARDBORN_SENTINEL"
+ );
expect(mission.enemy_spawns[0].count).to.equal(2);
});
@@ -263,9 +278,15 @@ describe("Manager: MissionManager", () => {
expect(mission.mission_objects).to.exist;
expect(mission.mission_objects).to.have.length(2);
expect(mission.mission_objects[0].object_id).to.equal("OBJ_SIGNAL_RELAY");
- expect(mission.mission_objects[0].placement_strategy).to.equal("center_of_enemy_room");
+ expect(mission.mission_objects[0].placement_strategy).to.equal(
+ "center_of_enemy_room"
+ );
expect(mission.mission_objects[1].object_id).to.equal("OBJ_DATA_TERMINAL");
- expect(mission.mission_objects[1].position).to.deep.equal({ x: 10, y: 1, z: 10 });
+ expect(mission.mission_objects[1].position).to.deep.equal({
+ x: 10,
+ y: 1,
+ z: 10,
+ });
});
it("CoA 15: getActiveMission should expose deployment constraints with tutorial hints", async () => {
@@ -350,7 +371,9 @@ describe("Manager: MissionManager", () => {
window.addEventListener("mission-failure", failureSpy);
manager.currentTurn = 11;
- manager.failureConditions = [{ type: "TURN_LIMIT_EXCEEDED", turn_limit: 10 }];
+ manager.failureConditions = [
+ { type: "TURN_LIMIT_EXCEEDED", turn_limit: 10 },
+ ];
manager.checkFailureConditions("TURN_END", {});
@@ -564,9 +587,7 @@ describe("Manager: MissionManager", () => {
config: { title: "Test" },
objectives: {
primary: [{ type: "ELIMINATE_ALL", id: "PRIMARY_1" }],
- secondary: [
- { type: "SURVIVE", turn_count: 10, id: "SECONDARY_1" },
- ],
+ secondary: [{ type: "SURVIVE", turn_count: 10, id: "SECONDARY_1" }],
},
};
@@ -842,7 +863,7 @@ describe("Manager: MissionManager", () => {
it("CoA 31: Should lazy-load missions on first access", async () => {
// Create a fresh manager to test lazy loading
const freshManager = new MissionManager(mockPersistence);
-
+
// Initially, registry should be empty (missions not loaded)
expect(freshManager.missionRegistry.size).to.equal(0);
@@ -851,7 +872,7 @@ describe("Manager: MissionManager", () => {
// Now missions should be loaded
expect(freshManager.missionRegistry.size).to.be.greaterThan(0);
- expect(freshManager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true;
+ expect(freshManager.missionRegistry.has("MISSION_ACT1_01")).to.be.true;
});
it("CoA 32: Should not reload missions if already loaded", async () => {
@@ -869,7 +890,7 @@ describe("Manager: MissionManager", () => {
it("CoA 33: Should handle lazy loading errors gracefully", async () => {
// Create a manager with a failing persistence (if needed)
const freshManager = new MissionManager(mockPersistence);
-
+
// Should not throw even if missions fail to load
try {
await freshManager._ensureMissionsLoaded();
@@ -881,5 +902,70 @@ describe("Manager: MissionManager", () => {
}
});
});
-});
+ describe("Dynamic Narrative Loading", () => {
+ it("CoA 35: playIntro should use dynamic narrative data if present", async () => {
+ const dynamicData = {
+ NARRATIVE_DYNAMIC_INTRO: {
+ id: "NARRATIVE_DYNAMIC_INTRO",
+ nodes: [{ id: "1", text: "Dynamic Text" }],
+ },
+ };
+
+ manager.activeMissionId = "MISSION_DYNAMIC_TEST";
+ manager.currentMissionDef = {
+ id: "MISSION_DYNAMIC_TEST",
+ narrative: {
+ intro_sequence: "NARRATIVE_DYNAMIC_INTRO",
+ _dynamic_data: dynamicData,
+ },
+ };
+
+ // Mock fetch to fail if called (should not be called)
+ const fetchStub = sinon
+ .stub(window, "fetch")
+ .rejects(new Error("Should not fetch"));
+
+ await manager.playIntro();
+
+ expect(mockNarrativeManager.startSequence.calledOnce).to.be.true;
+ expect(
+ mockNarrativeManager.startSequence.firstCall.args[0]
+ ).to.deep.equal(dynamicData["NARRATIVE_DYNAMIC_INTRO"]);
+ expect(fetchStub.called).to.be.false;
+
+ fetchStub.restore();
+ });
+
+ it("CoA 36: playIntro should fallback to fetch if dynamic data matches but ID not found", async () => {
+ // This case checks if _dynamic_data exists but doesn't have the specific ID
+ manager.activeMissionId = "MISSION_FALLBACK_TEST";
+ manager.currentMissionDef = {
+ id: "MISSION_FALLBACK_TEST",
+ narrative: {
+ intro_sequence: "NARRATIVE_FILE_INTRO",
+ _dynamic_data: { OTHER_ID: {} },
+ },
+ };
+
+ // Mock fetch to succeed
+ const mockResponse = new Response(
+ JSON.stringify({ id: "NARRATIVE_FILE_INTRO" }),
+ { status: 200 }
+ );
+ const fetchStub = sinon.stub(window, "fetch").resolves(mockResponse);
+
+ // Stub mapNarrativeIdToFileName to return simple name
+ manager._mapNarrativeIdToFileName = sinon
+ .stub()
+ .returns("narrative_file_intro");
+
+ await manager.playIntro();
+
+ expect(fetchStub.calledOnce).to.be.true;
+ expect(mockNarrativeManager.startSequence.calledOnce).to.be.true;
+
+ fetchStub.restore();
+ });
+ });
+});
diff --git a/test/systems/MissionGenerator.test.js b/test/systems/MissionGenerator.test.js
index 82327d8..ca33228 100644
--- a/test/systems/MissionGenerator.test.js
+++ b/test/systems/MissionGenerator.test.js
@@ -36,9 +36,15 @@ describe("Systems: MissionGenerator", function () {
describe("extractBaseName", () => {
it("should extract base name from mission title", () => {
- expect(MissionGenerator.extractBaseName("Operation: Silent Viper")).to.equal("Silent Viper");
- expect(MissionGenerator.extractBaseName("Operation: Silent Viper II")).to.equal("Silent Viper");
- expect(MissionGenerator.extractBaseName("Operation: Crimson Cache III")).to.equal("Crimson Cache");
+ expect(
+ MissionGenerator.extractBaseName("Operation: Silent Viper")
+ ).to.equal("Silent Viper");
+ expect(
+ MissionGenerator.extractBaseName("Operation: Silent Viper II")
+ ).to.equal("Silent Viper");
+ expect(
+ MissionGenerator.extractBaseName("Operation: Crimson Cache III")
+ ).to.equal("Crimson Cache");
});
});
@@ -47,14 +53,18 @@ describe("Systems: MissionGenerator", function () {
const history = [
"Operation: Silent Viper",
"Operation: Silent Viper II",
- "Operation: Silent Viper III"
+ "Operation: Silent Viper III",
];
- expect(MissionGenerator.findHighestNumeral("Silent Viper", history)).to.equal(3);
+ expect(
+ MissionGenerator.findHighestNumeral("Silent Viper", history)
+ ).to.equal(3);
});
it("should return 0 if no matches found", () => {
const history = ["Operation: Other Mission"];
- expect(MissionGenerator.findHighestNumeral("Silent Viper", history)).to.equal(0);
+ expect(
+ MissionGenerator.findHighestNumeral("Silent Viper", history)
+ ).to.equal(0);
});
});
@@ -77,7 +87,11 @@ describe("Systems: MissionGenerator", function () {
const emptyHistory = [];
it("CoA 1: Should generate a mission with required structure", () => {
- const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
expect(mission).to.have.property("id");
expect(mission).to.have.property("type", "SIDE_QUEST");
@@ -89,8 +103,16 @@ describe("Systems: MissionGenerator", function () {
});
it("CoA 2: Should generate unique mission IDs", () => {
- const mission1 = MissionGenerator.generateSideOp(1, unlockedRegions, emptyHistory);
- const mission2 = MissionGenerator.generateSideOp(1, unlockedRegions, emptyHistory);
+ const mission1 = MissionGenerator.generateSideOp(
+ 1,
+ unlockedRegions,
+ emptyHistory
+ );
+ const mission2 = MissionGenerator.generateSideOp(
+ 1,
+ unlockedRegions,
+ emptyHistory
+ );
expect(mission1.id).to.not.equal(mission2.id);
expect(mission1.id).to.match(/^SIDE_OP_\d+_[a-z0-9]+$/);
@@ -98,7 +120,11 @@ describe("Systems: MissionGenerator", function () {
});
it("CoA 3: Should generate title in 'Operation: [Adj] [Noun]' format", () => {
- const mission = MissionGenerator.generateSideOp(1, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 1,
+ unlockedRegions,
+ emptyHistory
+ );
expect(mission.config.title).to.match(/^Operation: .+$/);
const parts = mission.config.title.replace("Operation: ", "").split(" ");
@@ -111,7 +137,11 @@ describe("Systems: MissionGenerator", function () {
const objectiveTypes = new Set();
for (let i = 0; i < 20; i++) {
- const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
const primaryObj = mission.objectives.primary[0];
objectiveTypes.add(primaryObj.type);
@@ -133,7 +163,11 @@ describe("Systems: MissionGenerator", function () {
it("CoA 5: Should generate series missions with Roman numerals", () => {
const history = ["Operation: Silent Viper"];
- const mission = MissionGenerator.generateSideOp(1, unlockedRegions, history);
+ const mission = MissionGenerator.generateSideOp(
+ 1,
+ unlockedRegions,
+ history
+ );
// If it matches the base name, should have "II"
if (mission.config.title.includes("Silent Viper")) {
@@ -143,48 +177,82 @@ describe("Systems: MissionGenerator", function () {
it("CoA 6: Should scale difficulty tier correctly", () => {
for (let tier = 1; tier <= 5; tier++) {
- const mission = MissionGenerator.generateSideOp(tier, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ tier,
+ unlockedRegions,
+ emptyHistory
+ );
expect(mission.config.difficulty_tier).to.equal(tier);
expect(mission.config.recommended_level).to.be.a("number");
}
});
it("CoA 7: Should generate biome configuration", () => {
- const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
expect(mission.biome).to.have.property("type");
expect(mission.biome).to.have.property("generator_config");
- expect(mission.biome.generator_config).to.have.property("seed_type", "RANDOM");
+ expect(mission.biome.generator_config).to.have.property(
+ "seed_type",
+ "RANDOM"
+ );
expect(mission.biome.generator_config).to.have.property("size");
expect(mission.biome.generator_config).to.have.property("room_count");
expect(mission.biome.generator_config).to.have.property("density");
});
it("CoA 8: Should generate rewards with tier-based scaling", () => {
- const mission = MissionGenerator.generateSideOp(3, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 3,
+ unlockedRegions,
+ emptyHistory
+ );
expect(mission.rewards).to.have.property("guaranteed");
expect(mission.rewards.guaranteed).to.have.property("xp");
expect(mission.rewards.guaranteed).to.have.property("currency");
- expect(mission.rewards.guaranteed.currency).to.have.property("aether_shards");
+ expect(mission.rewards.guaranteed.currency).to.have.property(
+ "aether_shards"
+ );
expect(mission.rewards).to.have.property("faction_reputation");
// Higher tier should have higher rewards
- const lowTierMission = MissionGenerator.generateSideOp(1, unlockedRegions, emptyHistory);
- const highTierMission = MissionGenerator.generateSideOp(5, unlockedRegions, emptyHistory);
+ const lowTierMission = MissionGenerator.generateSideOp(
+ 1,
+ unlockedRegions,
+ emptyHistory
+ );
+ const highTierMission = MissionGenerator.generateSideOp(
+ 5,
+ unlockedRegions,
+ emptyHistory
+ );
- expect(highTierMission.rewards.guaranteed.currency.aether_shards)
- .to.be.greaterThan(lowTierMission.rewards.guaranteed.currency.aether_shards);
+ expect(
+ highTierMission.rewards.guaranteed.currency.aether_shards
+ ).to.be.greaterThan(
+ lowTierMission.rewards.guaranteed.currency.aether_shards
+ );
});
it("CoA 9: Should generate archetype-specific objectives", () => {
// Test Skirmish (ELIMINATE_ALL)
let foundSkirmish = false;
for (let i = 0; i < 30 && !foundSkirmish; i++) {
- const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
if (mission.objectives.primary[0].type === "ELIMINATE_ALL") {
foundSkirmish = true;
- expect(mission.objectives.primary[0].description).to.include("Clear the sector");
+ expect(mission.objectives.primary[0].description).to.include(
+ "Clear the sector"
+ );
}
}
expect(foundSkirmish).to.be.true;
@@ -192,10 +260,16 @@ describe("Systems: MissionGenerator", function () {
// Test Salvage (INTERACT)
let foundSalvage = false;
for (let i = 0; i < 30 && !foundSalvage; i++) {
- const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
if (mission.objectives.primary[0].type === "INTERACT") {
foundSalvage = true;
- expect(mission.objectives.primary[0].target_object_id).to.equal("OBJ_SUPPLY_CRATE");
+ expect(mission.objectives.primary[0].target_object_id).to.equal(
+ "OBJ_SUPPLY_CRATE"
+ );
expect(mission.objectives.primary[0].target_count).to.be.at.least(3);
expect(mission.objectives.primary[0].target_count).to.be.at.most(5);
}
@@ -205,11 +279,19 @@ describe("Systems: MissionGenerator", function () {
// Test Assassination (ELIMINATE_UNIT)
let foundAssassination = false;
for (let i = 0; i < 30 && !foundAssassination; i++) {
- const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") {
foundAssassination = true;
- expect(mission.objectives.primary[0]).to.have.property("target_def_id");
- expect(mission.objectives.primary[0].description).to.include("High-Value Target");
+ expect(mission.objectives.primary[0]).to.have.property(
+ "target_def_id"
+ );
+ expect(mission.objectives.primary[0].description).to.include(
+ "High-Value Target"
+ );
}
}
expect(foundAssassination).to.be.true;
@@ -217,7 +299,11 @@ describe("Systems: MissionGenerator", function () {
// Test Recon (REACH_ZONE)
let foundRecon = false;
for (let i = 0; i < 30 && !foundRecon; i++) {
- const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
if (mission.objectives.primary[0].type === "REACH_ZONE") {
foundRecon = true;
expect(mission.objectives.primary[0].target_count).to.equal(3);
@@ -235,7 +321,11 @@ describe("Systems: MissionGenerator", function () {
const configs = new Map();
for (let i = 0; i < 50; i++) {
- const mission = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
const objType = mission.objectives.primary[0].type;
if (!configs.has(objType)) {
configs.set(objType, mission.biome.generator_config);
@@ -256,15 +346,29 @@ describe("Systems: MissionGenerator", function () {
});
it("CoA 11: Should map biome to faction reputation", () => {
- const mission = MissionGenerator.generateSideOp(2, ["BIOME_RUSTING_WASTES"], emptyHistory);
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ ["BIOME_RUSTING_WASTES"],
+ emptyHistory
+ );
- expect(mission.rewards.faction_reputation).to.have.property("COGWORK_CONCORD");
+ expect(mission.rewards.faction_reputation).to.have.property(
+ "COGWORK_CONCORD"
+ );
expect(mission.rewards.faction_reputation.COGWORK_CONCORD).to.equal(10);
});
it("CoA 12: Should clamp tier to valid range (1-5)", () => {
- const lowMission = MissionGenerator.generateSideOp(0, unlockedRegions, emptyHistory);
- const highMission = MissionGenerator.generateSideOp(10, unlockedRegions, emptyHistory);
+ const lowMission = MissionGenerator.generateSideOp(
+ 0,
+ unlockedRegions,
+ emptyHistory
+ );
+ const highMission = MissionGenerator.generateSideOp(
+ 10,
+ unlockedRegions,
+ emptyHistory
+ );
expect(lowMission.config.difficulty_tier).to.equal(1);
expect(highMission.config.difficulty_tier).to.equal(5);
@@ -277,7 +381,12 @@ describe("Systems: MissionGenerator", function () {
it("CoA 13: Should fill board up to 5 missions", () => {
const emptyBoard = [];
- const refreshed = MissionGenerator.refreshBoard(emptyBoard, 2, unlockedRegions, emptyHistory);
+ const refreshed = MissionGenerator.refreshBoard(
+ emptyBoard,
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
expect(refreshed.length).to.equal(5);
});
@@ -286,45 +395,78 @@ describe("Systems: MissionGenerator", function () {
const existingMissions = [
MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory),
MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory),
- MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory)
+ MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory),
];
- const refreshed = MissionGenerator.refreshBoard(existingMissions, 2, unlockedRegions, emptyHistory);
+ const refreshed = MissionGenerator.refreshBoard(
+ existingMissions,
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
expect(refreshed.length).to.equal(5);
});
it("CoA 15: Should remove expired missions on daily reset", () => {
- const mission1 = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission1 = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
mission1.expiresIn = 1; // About to expire
- const mission2 = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission2 = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
mission2.expiresIn = 3; // Still valid
const board = [mission1, mission2];
- const refreshed = MissionGenerator.refreshBoard(board, 2, unlockedRegions, emptyHistory, true);
+ const refreshed = MissionGenerator.refreshBoard(
+ board,
+ 2,
+ unlockedRegions,
+ emptyHistory,
+ true
+ );
// Mission1 should be removed (expiresIn becomes 0), mission2 kept, then filled to 5
expect(refreshed.length).to.equal(5);
// Mission1 should not be in the list
- expect(refreshed.find(m => m.id === mission1.id)).to.be.undefined;
+ expect(refreshed.find((m) => m.id === mission1.id)).to.be.undefined;
// Mission2 should be present with decremented expiresIn
- const foundMission2 = refreshed.find(m => m.id === mission2.id);
+ const foundMission2 = refreshed.find((m) => m.id === mission2.id);
expect(foundMission2).to.exist;
expect(foundMission2.expiresIn).to.equal(2);
});
it("CoA 16: Should not remove missions when not daily reset", () => {
- const mission1 = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission1 = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
mission1.expiresIn = 1;
- const mission2 = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission2 = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
mission2.expiresIn = 3;
const board = [mission1, mission2];
- const refreshed = MissionGenerator.refreshBoard(board, 2, unlockedRegions, emptyHistory, false);
+ const refreshed = MissionGenerator.refreshBoard(
+ board,
+ 2,
+ unlockedRegions,
+ emptyHistory,
+ false
+ );
// Both should remain (not expired yet), expiresIn unchanged, then filled to 5
expect(refreshed.length).to.equal(5);
- const foundMission1 = refreshed.find(m => m.id === mission1.id);
- const foundMission2 = refreshed.find(m => m.id === mission2.id);
+ const foundMission1 = refreshed.find((m) => m.id === mission1.id);
+ const foundMission2 = refreshed.find((m) => m.id === mission2.id);
expect(foundMission1).to.exist;
expect(foundMission2).to.exist;
expect(foundMission1.expiresIn).to.equal(1);
@@ -332,25 +474,44 @@ describe("Systems: MissionGenerator", function () {
});
it("CoA 17: Should preserve valid missions and add new ones", () => {
- const mission1 = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission1 = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
mission1.expiresIn = 3;
const board = [mission1];
- const refreshed = MissionGenerator.refreshBoard(board, 2, unlockedRegions, emptyHistory);
+ const refreshed = MissionGenerator.refreshBoard(
+ board,
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
expect(refreshed.length).to.equal(5);
- expect(refreshed.find(m => m.id === mission1.id)).to.exist;
+ expect(refreshed.find((m) => m.id === mission1.id)).to.exist;
});
it("CoA 18: Should handle missions without expiresIn", () => {
- const mission1 = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
+ const mission1 = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
delete mission1.expiresIn;
const board = [mission1];
- const refreshed = MissionGenerator.refreshBoard(board, 2, unlockedRegions, emptyHistory, true);
+ const refreshed = MissionGenerator.refreshBoard(
+ board,
+ 2,
+ unlockedRegions,
+ emptyHistory,
+ true
+ );
// Mission without expiresIn should be preserved
- expect(refreshed.find(m => m.id === mission1.id)).to.exist;
+ expect(refreshed.find((m) => m.id === mission1.id)).to.exist;
});
});
@@ -365,8 +526,9 @@ describe("Systems: MissionGenerator", function () {
for (let i = 0; i < 20 && !foundNonAssassination; i++) {
const mission = MissionGenerator.generateSideOp(3, unlockedRegions, []);
const currency = mission.rewards.guaranteed.currency.aether_shards;
- const isAssassination = mission.objectives.primary[0].type === "ELIMINATE_UNIT";
-
+ const isAssassination =
+ mission.objectives.primary[0].type === "ELIMINATE_UNIT";
+
if (!isAssassination) {
foundNonAssassination = true;
expect(currency).to.be.at.least(100); // 50 * 2.5 * 0.8 = 100
@@ -389,7 +551,8 @@ describe("Systems: MissionGenerator", function () {
const mission = MissionGenerator.generateSideOp(2, unlockedRegions, []);
if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") {
foundAssassination = true;
- assassinationCurrency = mission.rewards.guaranteed.currency.aether_shards;
+ assassinationCurrency =
+ mission.rewards.guaranteed.currency.aether_shards;
} else {
otherCurrency = mission.rewards.guaranteed.currency.aether_shards;
}
@@ -406,15 +569,111 @@ describe("Systems: MissionGenerator", function () {
let foundItem = false;
for (let i = 0; i < 50; i++) {
const mission = MissionGenerator.generateSideOp(5, unlockedRegions, []);
- if (mission.rewards.guaranteed.items && mission.rewards.guaranteed.items.length > 0) {
+ if (
+ mission.rewards.guaranteed.items &&
+ mission.rewards.guaranteed.items.length > 0
+ ) {
foundItem = true;
expect(mission.rewards.guaranteed.items[0]).to.match(/^ITEM_/);
break;
}
}
// Tier 5 has 100% chance (5 * 0.2), so should always find one
- // But randomness, so we'll just check structure if found
+ if (!foundItem) {
+ // Fallback check if random failed (unlikely with 50 tries at 100% but safe)
+ }
+ });
+ });
+
+ describe("New Features: Narrative, Hazards, Bosses", () => {
+ const unlockedRegions = ["BIOME_RUSTING_WASTES"];
+ const emptyHistory = [];
+
+ it("CoA 22: Should generate dynamic narrative with correct structure", () => {
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
+
+ expect(mission).to.have.property("narrative");
+ expect(mission.narrative).to.have.property("intro_sequence");
+ expect(mission.narrative).to.have.property("outro_success");
+ expect(mission.narrative).to.have.property("_dynamic_data");
+
+ const introId = mission.narrative.intro_sequence;
+ const dynamicData = mission.narrative._dynamic_data;
+
+ expect(dynamicData).to.have.property(introId);
+ expect(dynamicData[introId].nodes).to.be.an("array");
+ expect(dynamicData[introId].nodes[0].text).to.be.a("string");
+ expect(dynamicData[introId].nodes[0].text.length).to.be.greaterThan(10);
+ });
+
+ it("CoA 23: Should generate hazards for biomes (probabilistic)", () => {
+ // Check that we eventually get a hazard
+ let foundHazard = false;
+ let attempts = 0;
+ while (!foundHazard && attempts < 50) {
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
+ if (mission.biome.hazards && mission.biome.hazards.length > 0) {
+ foundHazard = true;
+ expect(
+ MissionGenerator.BIOME_HAZARDS["BIOME_RUSTING_WASTES"]
+ ).to.include(mission.biome.hazards[0]);
+ }
+ attempts++;
+ }
+ expect(foundHazard, "Should have generated a hazard within 50 attempts")
+ .to.be.true;
+ });
+
+ it("CoA 24: Should generate boss config for Assassination missions", () => {
+ let foundAssassination = false;
+ let attempts = 0;
+
+ while (!foundAssassination && attempts < 50) {
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ unlockedRegions,
+ emptyHistory
+ );
+ if (mission.config.boss_config) {
+ foundAssassination = true;
+ const config = mission.config.boss_config;
+
+ expect(config).to.have.property("target_def_id");
+ expect(config).to.have.property("name");
+ expect(config).to.have.property("stats");
+ expect(config.stats.hp_multiplier).to.equal(2.0);
+ expect(config.stats.attack_multiplier).to.equal(1.5);
+ }
+ attempts++;
+ }
+ expect(
+ foundAssassination,
+ "Should have generated an Assassination mission with boss config"
+ ).to.be.true;
+ });
+
+ it("CoA 25: Narrative text should contain replaced variables", () => {
+ const mission = MissionGenerator.generateSideOp(
+ 2,
+ ["BIOME_RUSTING_WASTES"],
+ emptyHistory
+ );
+ const dynamicData = mission.narrative._dynamic_data;
+ const introId = mission.narrative.intro_sequence;
+ const text = dynamicData[introId].nodes[0].text;
+
+ // Check for replacements (lowercase biome name)
+ expect(text).to.not.include("{biome}");
+ expect(text).to.not.include("{enemy}");
+ expect(text).to.include("rusting wastes");
});
});
});
-