From 964a12fa4746c3d0ff6802ea906731a2d145c39e Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Fri, 2 Jan 2026 15:25:26 -0800 Subject: [PATCH] feat: Implement core game loop, state management, and mission system with initial mission data. --- specs/Mission_flow.md | 71 + specs/Procedural_Missions.spec.md | 181 +- specs/initial-mission-registry.json | 2010 +++++++++++++++++ src/assets/data/missions/mission-schema.md | 21 +- src/assets/data/missions/mission.d.ts | 16 + src/assets/data/missions/mission_act1_01.json | 8 +- src/assets/data/missions/mission_act1_02.json | 18 +- src/assets/data/missions/mission_act1_03.json | 10 +- .../data/missions/mission_story_02.json | 61 - .../data/missions/mission_story_03.json | 59 - .../data/missions/mission_story_04.json | 10 +- .../data/missions/mission_story_05.json | 5 +- .../data/missions/mission_story_06.json | 8 +- .../data/missions/mission_story_07.json | 14 +- .../data/missions/mission_story_08.json | 13 +- .../data/missions/mission_story_09.json | 7 +- .../data/missions/mission_story_10.json | 7 +- .../data/missions/mission_story_11.json | 16 +- .../data/missions/mission_story_12.json | 6 +- .../data/missions/mission_story_13.json | 11 +- .../data/missions/mission_story_14.json | 5 +- .../data/missions/mission_story_15.json | 7 +- .../data/missions/mission_story_16.json | 18 +- .../data/missions/mission_story_17.json | 19 +- .../data/missions/mission_story_18.json | 10 +- .../data/missions/mission_story_19.json | 11 +- .../data/missions/mission_story_20.json | 22 +- .../data/missions/mission_story_21.json | 17 +- .../data/missions/mission_story_22.json | 7 +- .../data/missions/mission_story_23.json | 9 +- .../data/missions/mission_story_24.json | 5 +- .../data/missions/mission_story_25.json | 6 +- .../data/missions/mission_story_26.json | 11 +- .../data/missions/mission_story_27.json | 9 +- .../data/missions/mission_story_28.json | 9 +- .../data/missions/mission_story_29.json | 7 +- .../data/missions/mission_story_30.json | 5 +- .../data/missions/mission_story_31.json | 6 +- .../mission_tutorial_01.description.md | 75 - .../data/missions/mission_tutorial_01.json | 65 - .../data/narrative/tutorial_cover_tip.json | 15 - src/assets/data/narrative/tutorial_intro.json | 39 - .../data/narrative/tutorial_success.json | 23 - src/core/GameLoop.js | 258 ++- src/core/GameStateManager.js | 26 + src/managers/MissionManager.js | 268 ++- src/systems/MissionGenerator.js | 1426 +++++++----- test/core/GameLoop/helpers.js | 9 +- test/managers/MissionManager.test.js | 166 +- test/systems/MissionGenerator.test.js | 377 +++- 50 files changed, 4126 insertions(+), 1356 deletions(-) create mode 100644 specs/Mission_flow.md create mode 100644 specs/initial-mission-registry.json delete mode 100644 src/assets/data/missions/mission_story_02.json delete mode 100644 src/assets/data/missions/mission_story_03.json delete mode 100644 src/assets/data/missions/mission_tutorial_01.description.md delete mode 100644 src/assets/data/missions/mission_tutorial_01.json delete mode 100644 src/assets/data/narrative/tutorial_cover_tip.json delete mode 100644 src/assets/data/narrative/tutorial_intro.json delete mode 100644 src/assets/data/narrative/tutorial_success.json 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"); }); }); }); -