feat: Implement core game loop, state management, and mission system with initial mission data.
This commit is contained in:
parent
aeace34d05
commit
964a12fa47
50 changed files with 4126 additions and 1356 deletions
71
specs/Mission_flow.md
Normal file
71
specs/Mission_flow.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
```mermaid
|
||||
graph TD
|
||||
%% ACT I: Awakening
|
||||
Start((Start Game)) --> M01[M01: First Descent<br><i>Unlock: Tinker, Barracks</i>]
|
||||
M01 --> M02[M02: The Signal<br><i>Unlock: Marketplace, Scavenger</i>]
|
||||
M02 --> M03[M03: The Foundation<br><i>Unlock: Research, Vanguard</i>]
|
||||
|
||||
%% ACT II: Faction Agendas (Parallel)
|
||||
M03 --> Hub{The Hub<br><i>Unlock: Side Ops</i>}
|
||||
|
||||
Hub --> ChainA[Arcane Dominion Arc<br><i>Crystal Spires</i>]
|
||||
Hub --> ChainB[Cogwork Concord Arc<br><i>Rusting Wastes</i>]
|
||||
Hub --> ChainC[Iron Legion Arc<br><i>Frontier/Void</i>]
|
||||
Hub --> ChainD[Golden Exchange Arc<br><i>Mixed</i>]
|
||||
Hub --> ChainE[Silent Sanctuary Arc<br><i>Fungal Caves</i>]
|
||||
|
||||
%% Chain A
|
||||
ChainA --> M04[M04: Unstable Aether]
|
||||
M04 --> M05[M05: Rogue Mage]
|
||||
M05 --> M06[M06: Reality Tear<br><i>Unlock: Battle Mage</i>]
|
||||
|
||||
%% Chain B
|
||||
ChainB --> M07[M07: Factory Reset]
|
||||
M07 --> M08[M08: The Construct<br><i>Boss Fight</i>]
|
||||
M08 --> M09[M09: Data Recovery<br><i>Unlock: Field Engineer</i>]
|
||||
|
||||
%% Chain C
|
||||
ChainC --> M10[M10: Hold the Line]
|
||||
M10 --> M11[M11: Breach & Clear]
|
||||
M11 --> M12[M12: The Warlord<br><i>Unlock: Warlord</i>]
|
||||
|
||||
%% Chain D
|
||||
ChainD --> M13[M13: Supply Run]
|
||||
M13 --> M14[M14: Hostile Takeover]
|
||||
M14 --> M15[M15: The Auction<br><i>Unlock: Platinum Pass</i>]
|
||||
|
||||
%% Chain E
|
||||
ChainE --> M16[M16: Cleansing]
|
||||
M16 --> M17[M17: Lost Spirits]
|
||||
M17 --> M18[M18: Source of Rot<br><i>Unlock: Custodian Mastery</i>]
|
||||
|
||||
%% ACT III: The Fracture
|
||||
M06 & M09 & M12 & M15 & M18 --> Fracture{Civil War Begins}
|
||||
|
||||
Fracture --> M19[M19: Diplomatic Immunity]
|
||||
M19 --> M20[M20: Sabotage<br><i>Choice: Legion vs Concord</i>]
|
||||
M20 --> M21[M21: Resource War]
|
||||
M21 --> M22[M22: False Prophet]
|
||||
M22 --> M23[M23: Spirequake]
|
||||
M23 --> M24[M24: The Ultimatum<br><i>Invasion Event</i>]
|
||||
M24 --> M25[M25: Into the Dark<br><i>Unlock: Act IV</i>]
|
||||
|
||||
%% 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<br><i>Boss: Void Brute Omega</i>]
|
||||
M30 --> M31[M31: Ascension]
|
||||
M31 --> M32[M32: The Origin<br><i>Final Boss</i>]
|
||||
M32 --> End((Game Complete<br><i>New Game+</i>))
|
||||
|
||||
%% 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;
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
2010
specs/initial-mission-registry.json
Normal file
2010
specs/initial-mission-registry.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
|
|||
16
src/assets/data/missions/mission.d.ts
vendored
16
src/assets/data/missions/mission.d.ts
vendored
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
export interface ScriptedEvent {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"primary": [
|
||||
{
|
||||
"id": "OBJ_KILL_2",
|
||||
"type": "ELIMINATE_ENEMIES",
|
||||
"type": "ELIMINATE_ALL",
|
||||
"target_count": 2,
|
||||
"description": "Eliminate 2 Shardborn Sentinels."
|
||||
}
|
||||
|
|
@ -36,9 +36,7 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"unlocks": [
|
||||
"CLASS_TINKER"
|
||||
],
|
||||
"unlocks": ["CLASS_TINKER", "MISSION_ACT1_02"],
|
||||
"currency": {
|
||||
"aether_shards": 100
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
"mission_objects": [
|
||||
{
|
||||
"object_id": "OBJ_SIGNAL_RELAY",
|
||||
"placement_strategy": "random_walkable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,9 +20,7 @@
|
|||
"room_count": 1,
|
||||
"density": "ARENA"
|
||||
},
|
||||
"hazards": [
|
||||
"HAZARD_VOID_RIFTS"
|
||||
]
|
||||
"hazards": ["HAZARD_VOID_RIFTS"]
|
||||
},
|
||||
"narrative": {
|
||||
"intro_sequence": "NARRATIVE_04_INTRO",
|
||||
|
|
@ -50,9 +48,7 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"unlocks": [
|
||||
"CLASS_BATTLE_MAGE"
|
||||
],
|
||||
"unlocks": ["CLASS_BATTLE_MAGE", "MISSION_STORY_05"],
|
||||
"currency": {
|
||||
"ancient_cores": 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@
|
|||
"guaranteed": {
|
||||
"currency": {
|
||||
"aether_shards": 500
|
||||
}
|
||||
},
|
||||
"unlocks": ["MISSION_STORY_06"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -51,5 +51,11 @@
|
|||
"faction_reputation": {
|
||||
"ARCANE_DOMINION": 50
|
||||
}
|
||||
},
|
||||
"mission_objects": [
|
||||
{
|
||||
"object_id": "OBJ_VOLATILE_CRYSTAL",
|
||||
"placement_strategy": "random_walkable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
"mission_objects": [
|
||||
{
|
||||
"object_id": "OBJ_GENERATOR_CONSOLE",
|
||||
"placement_strategy": "random_walkable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -13,9 +13,7 @@
|
|||
"generator_config": {
|
||||
"density": "ARENA"
|
||||
},
|
||||
"hazards": [
|
||||
"HAZARD_OIL_SLICKS"
|
||||
]
|
||||
"hazards": ["HAZARD_OIL_SLICKS"]
|
||||
},
|
||||
"narrative": {
|
||||
"intro_sequence": "NARRATIVE_08_INTRO",
|
||||
|
|
@ -33,12 +31,11 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"items": [
|
||||
"ITEM_TITAN_PLATING"
|
||||
],
|
||||
"items": ["ITEM_TITAN_PLATING"],
|
||||
"currency": {
|
||||
"ancient_cores": 3
|
||||
}
|
||||
},
|
||||
"unlocks": ["MISSION_STORY_09"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"COGWORK_CONCORD": 75
|
||||
|
|
|
|||
|
|
@ -35,9 +35,8 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"items": [
|
||||
"ITEM_DATA_DRIVE_RELIC"
|
||||
]
|
||||
"items": ["ITEM_DATA_DRIVE_RELIC"],
|
||||
"unlocks": ["CLASS_FIELD_ENGINEER", "MISSION_STORY_19"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,9 +30,8 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"items": [
|
||||
"ITEM_IRON_TOWER_SHIELD"
|
||||
]
|
||||
"items": ["ITEM_IRON_TOWER_SHIELD"],
|
||||
"unlocks": ["MISSION_STORY_11"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"IRON_LEGION": 40
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@
|
|||
},
|
||||
"biome": {
|
||||
"type": "BIOME_RUSTING_WASTES",
|
||||
"hazards": [
|
||||
"HAZARD_NEST_SPORES"
|
||||
]
|
||||
"hazards": ["HAZARD_NEST_SPORES"]
|
||||
},
|
||||
"narrative": {
|
||||
"intro_sequence": "NARRATIVE_11_INTRO",
|
||||
|
|
@ -27,17 +25,15 @@
|
|||
},
|
||||
{
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"unlocks": [
|
||||
"MASTERY_VANGUARD"
|
||||
]
|
||||
"unlocks": ["CLASS_WARLORD", "MISSION_STORY_19"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"IRON_LEGION": 75
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@
|
|||
"generator_config": {
|
||||
"density": "LINEAR_PATH"
|
||||
},
|
||||
"hazards": [
|
||||
"HAZARD_UNSTABLE_PLATFORMS"
|
||||
]
|
||||
"hazards": ["HAZARD_UNSTABLE_PLATFORMS"]
|
||||
},
|
||||
"narrative": {
|
||||
"intro_sequence": "NARRATIVE_13_INTRO",
|
||||
|
|
@ -43,9 +41,8 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"items": [
|
||||
"ITEM_MERCENARY_CONTRACT"
|
||||
]
|
||||
"items": ["ITEM_MERCENARY_CONTRACT"],
|
||||
"unlocks": ["MISSION_STORY_14"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"GOLDEN_EXCHANGE": 40
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@
|
|||
"guaranteed": {
|
||||
"currency": {
|
||||
"aether_shards": 700
|
||||
}
|
||||
},
|
||||
"unlocks": ["MISSION_STORY_15"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"GOLDEN_EXCHANGE": 45
|
||||
|
|
|
|||
|
|
@ -30,9 +30,8 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"items": [
|
||||
"ITEM_MARKET_PASS_PLATINUM"
|
||||
]
|
||||
"items": ["ITEM_MARKET_PASS_PLATINUM"],
|
||||
"unlocks": ["MISSION_STORY_19"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"GOLDEN_EXCHANGE": 75
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
"mission_objects": [
|
||||
{
|
||||
"object_id": "OBJ_CORRUPTED_ROOT",
|
||||
"placement_strategy": "random_walkable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
"mission_objects": [
|
||||
{
|
||||
"object_id": "OBJ_LOST_PILGRIM",
|
||||
"placement_strategy": "random_walkable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -10,9 +10,7 @@
|
|||
},
|
||||
"biome": {
|
||||
"type": "BIOME_VOID_SEEP",
|
||||
"hazards": [
|
||||
"HAZARD_SPORE_VENTS"
|
||||
]
|
||||
"hazards": ["HAZARD_SPORE_VENTS"]
|
||||
},
|
||||
"narrative": {
|
||||
"intro_sequence": "NARRATIVE_18_INTRO",
|
||||
|
|
@ -30,9 +28,7 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"unlocks": [
|
||||
"MASTERY_CUSTODIAN"
|
||||
]
|
||||
"unlocks": ["CLASS_CUSTODIAN_MASTERY", "MISSION_STORY_19"]
|
||||
},
|
||||
"faction_reputation": {
|
||||
"SILENT_SANCTUARY": 75
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
},
|
||||
"mission_objects": [
|
||||
{
|
||||
"object_id": "OBJ_BOMB_SITE",
|
||||
"placement_strategy": "random_walkable"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,9 +27,8 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"items": [
|
||||
"ITEM_CORRUPTED_IDOL"
|
||||
]
|
||||
"items": ["ITEM_CORRUPTED_IDOL"],
|
||||
"unlocks": ["MISSION_STORY_23"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,8 @@
|
|||
"guaranteed": {
|
||||
"currency": {
|
||||
"ancient_cores": 3
|
||||
}
|
||||
},
|
||||
"unlocks": ["MISSION_STORY_25"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,9 +31,7 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"unlocks": [
|
||||
"ACCESS_ACT_4"
|
||||
]
|
||||
"unlocks": ["ACCESS_ACT_4", "MISSION_STORY_26"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,9 +26,8 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"items": [
|
||||
"ITEM_VOID_ESSENCE"
|
||||
]
|
||||
"items": ["ITEM_VOID_ESSENCE"],
|
||||
"unlocks": ["MISSION_STORY_30"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,8 @@
|
|||
"guaranteed": {
|
||||
"currency": {
|
||||
"ancient_cores": 5
|
||||
}
|
||||
},
|
||||
"unlocks": ["MISSION_STORY_31"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,9 +34,7 @@
|
|||
},
|
||||
"rewards": {
|
||||
"guaranteed": {
|
||||
"unlocks": [
|
||||
"LIMIT_BREAK_MAX_LEVEL"
|
||||
]
|
||||
"unlocks": ["LIMIT_BREAK_MAX_LEVEL", "MISSION_STORY_32"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
@ -2224,7 +2328,7 @@ export class GameLoop {
|
|||
color: 0xffaa00, // Orange/gold
|
||||
emissive: 0x442200, // Slight glow
|
||||
metalness: 0.3,
|
||||
roughness: 0.7
|
||||
roughness: 0.7,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
|
|
@ -2239,7 +2343,9 @@ export class GameLoop {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -2266,7 +2372,7 @@ export class GameLoop {
|
|||
this.missionManager.onGameEvent("INTERACT", {
|
||||
objectId: objectId,
|
||||
unitId: unit.id,
|
||||
position: unitPos
|
||||
position: unitPos,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2490,14 +2596,18 @@ export class GameLoop {
|
|||
|
||||
// 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;
|
||||
|
|
@ -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,
|
||||
|
|
@ -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]) => {
|
||||
Object.entries(rewards.faction_reputation).forEach(
|
||||
([factionId, amount]) => {
|
||||
reputationChanges.push({ factionId, amount });
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get squad status
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export class MissionManager {
|
|||
this.activeMissionId = null;
|
||||
/** @type {Set<string>} */
|
||||
this.completedMissions = new Set();
|
||||
/** @type {Set<string>} */
|
||||
this.unlockedMissions = new Set(); // Track unlocked missions
|
||||
this.unlockedMissions.add("MISSION_ACT1_01"); // Default unlock
|
||||
/** @type {Map<string, MissionDefinition>} */
|
||||
this.missionRegistry = new Map();
|
||||
/** @type {Map<string, MissionDefinition>} */
|
||||
|
|
@ -82,24 +85,73 @@ export class MissionManager {
|
|||
}
|
||||
|
||||
try {
|
||||
const [tutorialMission, story02Mission, story03Mission] =
|
||||
await Promise.all([
|
||||
import("../assets/data/missions/mission_tutorial_01.json", {
|
||||
// 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),
|
||||
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),
|
||||
]);
|
||||
}
|
||||
).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,12 +584,20 @@ 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
|
||||
let narrativeData;
|
||||
|
||||
// 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`
|
||||
);
|
||||
|
|
@ -469,8 +606,8 @@ export class MissionManager {
|
|||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const narrativeData = await response.json();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,40 +14,84 @@ 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"
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
"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"
|
||||
"Eye",
|
||||
"Watch",
|
||||
"Path",
|
||||
"Horizon",
|
||||
"Whisper",
|
||||
"Dawn",
|
||||
"Light",
|
||||
"Step",
|
||||
"Vision",
|
||||
"Scope",
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -58,18 +102,79 @@ export class MissionGenerator {
|
|||
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 }
|
||||
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"
|
||||
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"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -93,7 +198,10 @@ export class MissionGenerator {
|
|||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
return title.replace(/Operation:\s*/, "").replace(/\s+[IVX]+$/, "").trim();
|
||||
return title
|
||||
.replace(/Operation:\s*/, "")
|
||||
.replace(/\s+[IVX]+$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -104,7 +212,13 @@ export class MissionGenerator {
|
|||
*/
|
||||
static findHighestNumeral(baseName, history) {
|
||||
let highest = 0;
|
||||
const pattern = new RegExp(`Operation:\\s*${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+([IVX]+)`, "i");
|
||||
const pattern = new RegExp(
|
||||
`Operation:\\s*${baseName.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
"\\$&"
|
||||
)}\\s+([IVX]+)`,
|
||||
"i"
|
||||
);
|
||||
|
||||
for (const entry of history) {
|
||||
const match = entry.match(pattern);
|
||||
|
|
@ -126,7 +240,7 @@ export class MissionGenerator {
|
|||
* @returns {number} Number value
|
||||
*/
|
||||
static romanToNumber(roman) {
|
||||
const map = { "I": 1, "II": 2, "III": 3, "IV": 4, "V": 5 };
|
||||
const map = { I: 1, II: 2, III: 3, IV: 4, V: 5 };
|
||||
return map[roman] || 0;
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +336,8 @@ export class MissionGenerator {
|
|||
const baseName = `${adjective} ${noun}`;
|
||||
const highestNumeral = this.findHighestNumeral(baseName, history);
|
||||
const nextNumeral = highestNumeral + 1;
|
||||
const romanSuffix = nextNumeral > 1 ? ` ${this.toRomanNumeral(nextNumeral)}` : "";
|
||||
const romanSuffix =
|
||||
nextNumeral > 1 ? ` ${this.toRomanNumeral(nextNumeral)}` : "";
|
||||
|
||||
// Build title
|
||||
const title = `Operation: ${baseName}${romanSuffix}`;
|
||||
|
|
@ -237,13 +352,30 @@ export class MissionGenerator {
|
|||
const biomeConfig = this.generateBiomeConfig(archetype, biomeType);
|
||||
|
||||
// Generate enemy spawns based on archetype (especially for ASSASSINATION)
|
||||
const enemySpawns = this.generateEnemySpawns(archetype, objectives, validTier);
|
||||
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)}`;
|
||||
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 = {
|
||||
|
|
@ -253,21 +385,119 @@ export class MissionGenerator {
|
|||
title: title,
|
||||
description: this.generateDescription(archetype, biomeType),
|
||||
difficulty_tier: validTier,
|
||||
recommended_level: tierConfig.enemyLevel[1] // Use max enemy level as recommended
|
||||
recommended_level: tierConfig.enemyLevel[1], // Use max enemy level as recommended
|
||||
},
|
||||
biome: biomeConfig,
|
||||
deployment: {
|
||||
squad_size_limit: 4
|
||||
squad_size_limit: 4,
|
||||
},
|
||||
narrative: narrative,
|
||||
objectives: objectives,
|
||||
enemy_spawns: enemySpawns,
|
||||
rewards: rewards,
|
||||
expiresIn: 3 // Expires in 3 campaign days
|
||||
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 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`;
|
||||
|
||||
// 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"];
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const introText = introTemplate.text
|
||||
.replace("{enemy}", "hostile")
|
||||
.replace("{biome}", biomeName.toLowerCase())
|
||||
.replace("{verb}", verb);
|
||||
|
||||
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
|
||||
|
|
@ -278,25 +508,29 @@ export class MissionGenerator {
|
|||
switch (archetype) {
|
||||
case "SKIRMISH":
|
||||
return {
|
||||
primary: [{
|
||||
primary: [
|
||||
{
|
||||
id: "OBJ_ELIMINATE_ALL",
|
||||
type: "ELIMINATE_ALL",
|
||||
description: "Clear the sector of hostile forces."
|
||||
}],
|
||||
failure_conditions: [{ type: "SQUAD_WIPE" }]
|
||||
description: "Clear the sector of hostile forces.",
|
||||
},
|
||||
],
|
||||
failure_conditions: [{ type: "SQUAD_WIPE" }],
|
||||
};
|
||||
|
||||
case "SALVAGE":
|
||||
const crateCount = this.randomRange(3, 5);
|
||||
return {
|
||||
primary: [{
|
||||
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" }]
|
||||
description: `Recover ${crateCount} supply crates before the enemy secures them.`,
|
||||
},
|
||||
],
|
||||
failure_conditions: [{ type: "SQUAD_WIPE" }],
|
||||
};
|
||||
|
||||
case "ASSASSINATION":
|
||||
|
|
@ -305,45 +539,52 @@ export class MissionGenerator {
|
|||
"ENEMY_ELITE_ECHO",
|
||||
"ENEMY_ELITE_BREAKER",
|
||||
"ENEMY_ELITE_STALKER",
|
||||
"ENEMY_ELITE_WARDEN"
|
||||
"ENEMY_ELITE_WARDEN",
|
||||
];
|
||||
const targetId = this.randomChoice(eliteEnemies);
|
||||
return {
|
||||
primary: [{
|
||||
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" }]
|
||||
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);
|
||||
const turnLimit = 15 + tier * 5;
|
||||
return {
|
||||
primary: [{
|
||||
primary: [
|
||||
{
|
||||
id: "OBJ_RECON",
|
||||
type: "REACH_ZONE",
|
||||
target_count: 3,
|
||||
description: "Survey the designated coordinates."
|
||||
}],
|
||||
description: "Survey the designated coordinates.",
|
||||
},
|
||||
],
|
||||
failure_conditions: [
|
||||
{ type: "SQUAD_WIPE" },
|
||||
{ type: "TURN_LIMIT_EXCEEDED", turn_limit: turnLimit }
|
||||
]
|
||||
{ type: "TURN_LIMIT_EXCEEDED", turn_limit: turnLimit },
|
||||
],
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
primary: [{
|
||||
primary: [
|
||||
{
|
||||
id: "OBJ_DEFAULT",
|
||||
type: "ELIMINATE_ALL",
|
||||
description: "Complete the mission objectives."
|
||||
}],
|
||||
failure_conditions: [{ type: "SQUAD_WIPE" }]
|
||||
description: "Complete the mission objectives.",
|
||||
},
|
||||
],
|
||||
failure_conditions: [{ type: "SQUAD_WIPE" }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -367,19 +608,19 @@ export class MissionGenerator {
|
|||
if (eliminateUnitObj?.target_def_id) {
|
||||
spawns.push({
|
||||
enemy_def_id: eliminateUnitObj.target_def_id,
|
||||
count: 1
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
// Also spawn some regular enemies for support
|
||||
const regularEnemies = [
|
||||
"ENEMY_SHARDBORN_SENTINEL",
|
||||
"ENEMY_GOBLIN_RAIDER",
|
||||
"ENEMY_CRYSTAL_SHARD"
|
||||
"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
|
||||
count: Math.max(1, Math.floor(tier / 2)), // 1-2 support enemies based on tier
|
||||
});
|
||||
break;
|
||||
|
||||
|
|
@ -388,12 +629,14 @@ export class MissionGenerator {
|
|||
const skirmishEnemies = [
|
||||
"ENEMY_SHARDBORN_SENTINEL",
|
||||
"ENEMY_GOBLIN_RAIDER",
|
||||
"ENEMY_CRYSTAL_SHARD"
|
||||
"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);
|
||||
const existingSpawn = spawns.find(
|
||||
(s) => s.enemy_def_id === enemyType
|
||||
);
|
||||
if (existingSpawn) {
|
||||
existingSpawn.count++;
|
||||
} else {
|
||||
|
|
@ -407,12 +650,14 @@ export class MissionGenerator {
|
|||
// These missions have fewer enemies (lower density)
|
||||
const lightEnemies = [
|
||||
"ENEMY_SHARDBORN_SENTINEL",
|
||||
"ENEMY_GOBLIN_RAIDER"
|
||||
"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);
|
||||
const existingSpawn = spawns.find(
|
||||
(s) => s.enemy_def_id === enemyType
|
||||
);
|
||||
if (existingSpawn) {
|
||||
existingSpawn.count++;
|
||||
} else {
|
||||
|
|
@ -425,7 +670,7 @@ export class MissionGenerator {
|
|||
// Default: spawn a few basic enemies
|
||||
spawns.push({
|
||||
enemy_def_id: "ENEMY_SHARDBORN_SENTINEL",
|
||||
count: Math.max(1, tier)
|
||||
count: Math.max(1, tier),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -472,14 +717,22 @@ export class MissionGenerator {
|
|||
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
|
||||
}
|
||||
density: density,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -491,11 +744,11 @@ export class MissionGenerator {
|
|||
*/
|
||||
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"
|
||||
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";
|
||||
|
||||
|
|
@ -531,7 +784,9 @@ export class MissionGenerator {
|
|||
|
||||
// XP calculation (base 100 * multiplier)
|
||||
const baseXP = 100;
|
||||
const xpAmount = Math.round(baseXP * multiplier * this.randomFloat(0.9, 1.1));
|
||||
const xpAmount = Math.round(
|
||||
baseXP * multiplier * this.randomFloat(0.9, 1.1)
|
||||
);
|
||||
|
||||
// Assassination missions get bonus currency
|
||||
let finalCurrency = currencyAmount;
|
||||
|
|
@ -560,12 +815,12 @@ export class MissionGenerator {
|
|||
guaranteed: {
|
||||
xp: xpAmount,
|
||||
currency: {
|
||||
aether_shards: finalCurrency
|
||||
}
|
||||
aether_shards: finalCurrency,
|
||||
},
|
||||
},
|
||||
faction_reputation: {
|
||||
[factionId]: reputation
|
||||
}
|
||||
[factionId]: reputation,
|
||||
},
|
||||
};
|
||||
|
||||
// Add items if any
|
||||
|
|
@ -585,11 +840,17 @@ export class MissionGenerator {
|
|||
* @param {boolean} isDailyReset - If true, decrements expiresIn for all missions
|
||||
* @returns {Array<MissionDefinition>} Updated mission list
|
||||
*/
|
||||
static refreshBoard(currentMissions = [], tier, unlockedRegions, history = [], isDailyReset = false) {
|
||||
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 => {
|
||||
validMissions = currentMissions.map((mission) => {
|
||||
if (mission.expiresIn !== undefined) {
|
||||
const updated = { ...mission };
|
||||
updated.expiresIn = (mission.expiresIn || 3) - 1;
|
||||
|
|
@ -600,7 +861,7 @@ export class MissionGenerator {
|
|||
}
|
||||
|
||||
// Remove missions that have expired (expiresIn <= 0)
|
||||
validMissions = validMissions.filter(mission => {
|
||||
validMissions = validMissions.filter((mission) => {
|
||||
if (mission.expiresIn !== undefined) {
|
||||
return mission.expiresIn > 0;
|
||||
}
|
||||
|
|
@ -622,4 +883,3 @@ export class MissionGenerator {
|
|||
return [...validMissions, ...newMissions];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -154,4 +158,3 @@ export function cleanupTurnSystem(gameLoop) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,7 +526,8 @@ 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;
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue