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**
|
# **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**
|
## **1. System Architecture**
|
||||||
|
|
||||||
Class: MissionGenerator
|
Class: MissionGenerator
|
||||||
Responsibility: Factory that produces temporary Mission objects adhering to the Mission.ts interface.
|
Responsibility: Factory that produces temporary Mission objects.
|
||||||
Triggers:
|
Triggers: Daily Reset, Mission Complete.
|
||||||
|
|
||||||
- **Daily Reset:** When the campaign day advances.
|
|
||||||
- **Mission Complete:** Replenish the board after a run.
|
|
||||||
|
|
||||||
## **2. Generation Logic**
|
## **2. Generation Logic**
|
||||||
|
|
||||||
To generate a Side Op, the system inputs the **Campaign Tier** (1-5) and **Unlocked Regions**.
|
### **A. Naming & Flavor (The Hook)**
|
||||||
|
|
||||||
### **A. Naming Convention**
|
|
||||||
|
|
||||||
Missions use a context-aware "Operation: [Adjective] [Noun] [Numeral]" format.
|
Missions use a context-aware "Operation: [Adjective] [Noun] [Numeral]" format.
|
||||||
|
|
||||||
Noun Selection (Context-Aware):
|
- **Skirmish Nouns:** _Thunder, Storm, Iron, Fury, Shield, Hammer._
|
||||||
The noun is selected based on the Mission Archetype to imply the goal.
|
- **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._
|
### **B. Narrative Synthesis (Dynamic Context)**
|
||||||
- **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._
|
|
||||||
|
|
||||||
**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 creates these JSON blobs and registers them with the NarrativeManager using a generated ID (e.g., NARRATIVE_SIDE_123_INTRO)._
|
||||||
The generator checks the player's MissionHistory.
|
|
||||||
|
|
||||||
- If "Operation: Silent Viper" was completed previously, the new mission is named "Operation: Silent Viper **II**".
|
### **C. Biome & Hazards**
|
||||||
- This creates the illusion of persistent, ongoing military campaigns.
|
|
||||||
|
|
||||||
### **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**.
|
### **D. Mission Archetypes (Objectives)**
|
||||||
- _Weighting:_ 40% chance for the most recently unlocked region (to keep content relevant).
|
|
||||||
|
|
||||||
### **C. Mission Archetypes (Objectives)**
|
#### **1. Skirmish (Combat Focus)**
|
||||||
|
|
||||||
The generator picks one of four templates and hydrates it with specific data.
|
|
||||||
|
|
||||||
#### **1. Skirmish (Standard Combat)**
|
|
||||||
|
|
||||||
- **Objective:** ELIMINATE_ALL.
|
- **Objective:** ELIMINATE_ALL.
|
||||||
- **Description:** "Clear the sector of hostile forces."
|
- **Icon:** assets/icons/mission_sword.png.
|
||||||
- **Config:** Standard room count, Medium density.
|
- **Config:** Medium Density, Standard Rewards.
|
||||||
- **Turn Limit:** None.
|
|
||||||
|
|
||||||
#### **2. Salvage (Loot Run)**
|
#### **2. Salvage (Resource Focus)**
|
||||||
|
|
||||||
- **Objective:** INTERACT with 3-5 "Supply Crates".
|
- **Objective:** INTERACT with 3-5 "Supply Crates".
|
||||||
- **Description:** "Recover lost supplies before the enemy secures them."
|
- **Icon:** assets/icons/mission_coin.png.
|
||||||
- **Config:** High density of obstacles/cover.
|
- **Config:** High Cover Density.
|
||||||
- **Reward Bonus:** Higher chance for ITEMS or MATERIALS.
|
- **Reward Bonus:** Items/Materials.
|
||||||
|
|
||||||
#### **3. Assassination (Elite Hunt)**
|
#### **3. Assassination (Boss Focus)**
|
||||||
|
|
||||||
- **Objective:** ELIMINATE_UNIT (Specific Target ID).
|
- **Objective:** ELIMINATE_UNIT (Named Elite).
|
||||||
- **Description:** "A High-Value Target has been spotted. Eliminate them."
|
- **Icon:** assets/icons/mission_skull.png.
|
||||||
- **Config:** Spawns a named Elite Unit (e.g., "Krag the Breaker") with +50% Stats.
|
- **Config:** Spawns a Unit with stats: { hp: 200%, attack: 150% } and a unique name (e.g., "Gorgon the Rot").
|
||||||
- **Reward Bonus:** High CURRENCY payout.
|
- **Reward Bonus:** High Currency.
|
||||||
|
|
||||||
#### **4. Recon (Scouting)**
|
#### **4. Recon (Speed Focus)**
|
||||||
|
|
||||||
- **Objective:** REACH_ZONE (3 separate zones on the map).
|
- **Objective:** REACH_ZONE (3 Zones).
|
||||||
- **Description:** "Survey the designated coordinates."
|
- **Icon:** assets/icons/mission_search.png.
|
||||||
- **Config:** Large map size, Low enemy density (Mobility focus).
|
- **Config:** Large Map, Low Density.
|
||||||
- **Turn Limit:** Tight (Speed is key).
|
- **Turn Limit:** 8 Turns.
|
||||||
|
|
||||||
## **3. Scaling & Rewards**
|
## **3. Rewards Scaling**
|
||||||
|
|
||||||
### **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:
|
|
||||||
|
|
||||||
- **Currency:** Base (50) _ TierMultiplier _ Random(0.8, 1.2).
|
- **Currency:** Base (50) _ TierMultiplier _ Random(0.8, 1.2).
|
||||||
- **Items:** 20% chance per Tier to drop a Chest Key or Item.
|
- **Reputation:** +10 to Patron Faction.
|
||||||
- **Reputation:** +10 Reputation with the Region's owner (e.g., Missions in Rusting Wastes give +Cogwork Rep).
|
- **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
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -104,35 +94,74 @@ Rewards are calculated dynamically:
|
||||||
"type": "SIDE_QUEST",
|
"type": "SIDE_QUEST",
|
||||||
"config": {
|
"config": {
|
||||||
"title": "Operation: Crimson Viper II",
|
"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,
|
"difficulty_tier": 2,
|
||||||
"recommended_level": 3
|
"recommended_level": 3,
|
||||||
|
"icon": "assets/icons/mission_skull.png"
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_CRYSTAL_SPIRES",
|
"type": "BIOME_CRYSTAL_SPIRES",
|
||||||
"generator_config": {
|
"generator_config": {
|
||||||
"seed_type": "RANDOM",
|
"seed_type": "RANDOM",
|
||||||
"size": { "x": 20, "y": 12, "z": 20 },
|
"seed": 982374,
|
||||||
"room_count": 6
|
"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": {
|
"objectives": {
|
||||||
"primary": [
|
"primary": [
|
||||||
{
|
{
|
||||||
"id": "OBJ_HUNT",
|
"id": "OBJ_HUNT",
|
||||||
"type": "ELIMINATE_UNIT",
|
"type": "ELIMINATE_UNIT",
|
||||||
"target_def_id": "ENEMY_ELITE_ECHO",
|
"target_def_id": "ENEMY_ELITE_ECHO_KRAG",
|
||||||
"description": "Eliminate the Aether Echo Prime."
|
"description": "Eliminate Krag the Breaker."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"xp": 300,
|
"xp": 350,
|
||||||
"currency": { "aether_shards": 120 }
|
"currency": { "aether_shards": 150 }
|
||||||
},
|
},
|
||||||
"faction_reputation": { "ARCANE_DOMINION": 15 }
|
"faction_reputation": { "IRON_LEGION": 15 }
|
||||||
},
|
},
|
||||||
"expiresIn": 3
|
"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",
|
"title": "Operation: Broken Sky",
|
||||||
"description": "The Iron Legion demands we silence the Shardborn Artillery. Expect heavy resistance.",
|
"description": "The Iron Legion demands we silence the Shardborn Artillery. Expect heavy resistance.",
|
||||||
"difficulty_tier": 3,
|
"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": {
|
"biome": {
|
||||||
"type": "BIOME_RUSTING_WASTES",
|
"type": "BIOME_RUSTING_WASTES",
|
||||||
|
|
@ -62,7 +67,10 @@ This example utilizes every capability of the system.
|
||||||
"action": "PLAY_SEQUENCE",
|
"action": "PLAY_SEQUENCE",
|
||||||
"sequence_id": "NARRATIVE_BOSS_PHASE_2"
|
"sequence_id": "NARRATIVE_BOSS_PHASE_2"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"_dynamic_data": {
|
||||||
|
"NARRATIVE_ACT1_FINAL_INTRO": { "id": "...", "nodes": [] }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"enemy_spawns": [
|
"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.
|
- **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.").
|
- **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**
|
||||||
|
|
||||||
- **enemy_spawns**: Array of enemy spawn definitions. Each entry specifies an enemy definition ID and count.
|
- **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)
|
* - "locked": Mission shows as locked with requirements visible (default for SIDE_QUEST, PROCEDURAL)
|
||||||
*/
|
*/
|
||||||
visibility_when_locked?: "hidden" | "locked";
|
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 ---
|
// --- BIOME / WORLD GEN ---
|
||||||
|
|
@ -133,6 +147,8 @@ export interface MissionNarrative {
|
||||||
outro_failure?: string;
|
outro_failure?: string;
|
||||||
/** Triggers that fire during gameplay */
|
/** Triggers that fire during gameplay */
|
||||||
scripted_events?: ScriptedEvent[];
|
scripted_events?: ScriptedEvent[];
|
||||||
|
/** Dynamic narrative data generated procedurally */
|
||||||
|
_dynamic_data?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptedEvent {
|
export interface ScriptedEvent {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"primary": [
|
"primary": [
|
||||||
{
|
{
|
||||||
"id": "OBJ_KILL_2",
|
"id": "OBJ_KILL_2",
|
||||||
"type": "ELIMINATE_ENEMIES",
|
"type": "ELIMINATE_ALL",
|
||||||
"target_count": 2,
|
"target_count": 2,
|
||||||
"description": "Eliminate 2 Shardborn Sentinels."
|
"description": "Eliminate 2 Shardborn Sentinels."
|
||||||
}
|
}
|
||||||
|
|
@ -36,12 +36,10 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["CLASS_TINKER", "MISSION_ACT1_02"],
|
||||||
"CLASS_TINKER"
|
|
||||||
],
|
|
||||||
"currency": {
|
"currency": {
|
||||||
"aether_shards": 100
|
"aether_shards": 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,7 @@
|
||||||
"room_count": 5,
|
"room_count": 5,
|
||||||
"density": "MEDIUM"
|
"density": "MEDIUM"
|
||||||
},
|
},
|
||||||
"hazards": [
|
"hazards": ["HAZARD_POISON_SPORES"]
|
||||||
"HAZARD_POISON_SPORES"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_02_INTRO",
|
"intro_sequence": "NARRATIVE_02_INTRO",
|
||||||
|
|
@ -35,9 +33,7 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["CLASS_SCAVENGER", "MISSION_ACT1_03"],
|
||||||
"CLASS_SCAVENGER"
|
|
||||||
],
|
|
||||||
"currency": {
|
"currency": {
|
||||||
"aether_shards": 150
|
"aether_shards": 150
|
||||||
}
|
}
|
||||||
|
|
@ -45,5 +41,11 @@
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"GOLDEN_EXCHANGE": 25
|
"GOLDEN_EXCHANGE": 25
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
"mission_objects": [
|
||||||
|
{
|
||||||
|
"object_id": "OBJ_SIGNAL_RELAY",
|
||||||
|
"placement_strategy": "random_walkable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,13 @@
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": [
|
||||||
"CLASS_VANGUARD"
|
"CLASS_VANGUARD",
|
||||||
|
"UNLOCK_PROCEDURAL_MISSIONS",
|
||||||
|
"MISSION_STORY_04",
|
||||||
|
"MISSION_STORY_07",
|
||||||
|
"MISSION_STORY_10",
|
||||||
|
"MISSION_STORY_13",
|
||||||
|
"MISSION_STORY_16"
|
||||||
],
|
],
|
||||||
"currency": {
|
"currency": {
|
||||||
"aether_shards": 200
|
"aether_shards": 200
|
||||||
|
|
@ -42,4 +48,4 @@
|
||||||
"IRON_LEGION": 25
|
"IRON_LEGION": 25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
"room_count": 1,
|
||||||
"density": "ARENA"
|
"density": "ARENA"
|
||||||
},
|
},
|
||||||
"hazards": [
|
"hazards": ["HAZARD_VOID_RIFTS"]
|
||||||
"HAZARD_VOID_RIFTS"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_04_INTRO",
|
"intro_sequence": "NARRATIVE_04_INTRO",
|
||||||
|
|
@ -50,12 +48,10 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["CLASS_BATTLE_MAGE", "MISSION_STORY_05"],
|
||||||
"CLASS_BATTLE_MAGE"
|
|
||||||
],
|
|
||||||
"currency": {
|
"currency": {
|
||||||
"ancient_cores": 1
|
"ancient_cores": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"aether_shards": 500
|
"aether_shards": 500
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_06"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,5 +51,11 @@
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"ARCANE_DOMINION": 50
|
"ARCANE_DOMINION": 50
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"mission_objects": [
|
||||||
|
{
|
||||||
|
"object_id": "OBJ_VOLATILE_CRYSTAL",
|
||||||
|
"placement_strategy": "random_walkable"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -33,9 +33,7 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["MASTERY_TINKER", "MISSION_STORY_08"],
|
||||||
"MASTERY_TINKER"
|
|
||||||
],
|
|
||||||
"currency": {
|
"currency": {
|
||||||
"aether_shards": 500,
|
"aether_shards": 500,
|
||||||
"ancient_cores": 2
|
"ancient_cores": 2
|
||||||
|
|
@ -44,5 +42,11 @@
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"COGWORK_CONCORD": 50
|
"COGWORK_CONCORD": 50
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
"mission_objects": [
|
||||||
|
{
|
||||||
|
"object_id": "OBJ_GENERATOR_CONSOLE",
|
||||||
|
"placement_strategy": "random_walkable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@
|
||||||
"generator_config": {
|
"generator_config": {
|
||||||
"density": "ARENA"
|
"density": "ARENA"
|
||||||
},
|
},
|
||||||
"hazards": [
|
"hazards": ["HAZARD_OIL_SLICKS"]
|
||||||
"HAZARD_OIL_SLICKS"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_08_INTRO",
|
"intro_sequence": "NARRATIVE_08_INTRO",
|
||||||
|
|
@ -33,15 +31,14 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_TITAN_PLATING"],
|
||||||
"ITEM_TITAN_PLATING"
|
|
||||||
],
|
|
||||||
"currency": {
|
"currency": {
|
||||||
"ancient_cores": 3
|
"ancient_cores": 3
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_09"]
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"COGWORK_CONCORD": 75
|
"COGWORK_CONCORD": 75
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,8 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_DATA_DRIVE_RELIC"],
|
||||||
"ITEM_DATA_DRIVE_RELIC"
|
"unlocks": ["CLASS_FIELD_ENGINEER", "MISSION_STORY_19"]
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,11 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_IRON_TOWER_SHIELD"],
|
||||||
"ITEM_IRON_TOWER_SHIELD"
|
"unlocks": ["MISSION_STORY_11"]
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"IRON_LEGION": 40
|
"IRON_LEGION": 40
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_RUSTING_WASTES",
|
"type": "BIOME_RUSTING_WASTES",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_NEST_SPORES"]
|
||||||
"HAZARD_NEST_SPORES"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_11_INTRO",
|
"intro_sequence": "NARRATIVE_11_INTRO",
|
||||||
|
|
@ -27,20 +25,18 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "OBJ_SPAWNERS",
|
"id": "OBJ_SPAWNERS",
|
||||||
"type": "DESTROY_OBJECTS",
|
"type": "ELIMINATE_UNIT",
|
||||||
"tag": "NEST_SPAWNER",
|
"description": "Destroy 3 Nest Spawners.",
|
||||||
"description": "Destroy 3 Nest Spawners."
|
"target_def_id": "ENEMY_NEST_SPAWNER"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["BLUEPRINT_HEAVY_PLATE_MK2", "MISSION_STORY_12"]
|
||||||
"BLUEPRINT_HEAVY_PLATE_MK2"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"IRON_LEGION": 50
|
"IRON_LEGION": 50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,10 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["CLASS_WARLORD", "MISSION_STORY_19"]
|
||||||
"MASTERY_VANGUARD"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"IRON_LEGION": 75
|
"IRON_LEGION": 75
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@
|
||||||
"generator_config": {
|
"generator_config": {
|
||||||
"density": "LINEAR_PATH"
|
"density": "LINEAR_PATH"
|
||||||
},
|
},
|
||||||
"hazards": [
|
"hazards": ["HAZARD_UNSTABLE_PLATFORMS"]
|
||||||
"HAZARD_UNSTABLE_PLATFORMS"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_13_INTRO",
|
"intro_sequence": "NARRATIVE_13_INTRO",
|
||||||
|
|
@ -43,12 +41,11 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_MERCENARY_CONTRACT"],
|
||||||
"ITEM_MERCENARY_CONTRACT"
|
"unlocks": ["MISSION_STORY_14"]
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"GOLDEN_EXCHANGE": 40
|
"GOLDEN_EXCHANGE": 40
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,11 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"aether_shards": 700
|
"aether_shards": 700
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_15"]
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"GOLDEN_EXCHANGE": 45
|
"GOLDEN_EXCHANGE": 45
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,11 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_MARKET_PASS_PLATINUM"],
|
||||||
"ITEM_MARKET_PASS_PLATINUM"
|
"unlocks": ["MISSION_STORY_19"]
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"GOLDEN_EXCHANGE": 75
|
"GOLDEN_EXCHANGE": 75
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_FUNGAL_CAVES",
|
"type": "BIOME_FUNGAL_CAVES",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_REGROWING_VINES"]
|
||||||
"HAZARD_REGROWING_VINES"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_16_INTRO",
|
"intro_sequence": "NARRATIVE_16_INTRO",
|
||||||
|
|
@ -31,12 +29,16 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["BLUEPRINT_REGEN_RING", "MISSION_STORY_17"]
|
||||||
"BLUEPRINT_REGEN_RING"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"SILENT_SANCTUARY": 40
|
"SILENT_SANCTUARY": 40
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
"mission_objects": [
|
||||||
|
{
|
||||||
|
"object_id": "OBJ_CORRUPTED_ROOT",
|
||||||
|
"placement_strategy": "random_walkable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_CRYSTAL_SPIRES",
|
"type": "BIOME_CRYSTAL_SPIRES",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_FOG"]
|
||||||
"HAZARD_FOG"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_17_INTRO",
|
"intro_sequence": "NARRATIVE_17_INTRO",
|
||||||
|
|
@ -31,12 +29,17 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_SPIRIT_LANTERN"],
|
||||||
"ITEM_SPIRIT_LANTERN"
|
"unlocks": ["MISSION_STORY_18"]
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"SILENT_SANCTUARY": 50
|
"SILENT_SANCTUARY": 50
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
"mission_objects": [
|
||||||
|
{
|
||||||
|
"object_id": "OBJ_LOST_PILGRIM",
|
||||||
|
"placement_strategy": "random_walkable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_VOID_SEEP",
|
"type": "BIOME_VOID_SEEP",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_SPORE_VENTS"]
|
||||||
"HAZARD_SPORE_VENTS"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_18_INTRO",
|
"intro_sequence": "NARRATIVE_18_INTRO",
|
||||||
|
|
@ -30,12 +28,10 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["CLASS_CUSTODIAN_MASTERY", "MISSION_STORY_19"]
|
||||||
"MASTERY_CUSTODIAN"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"faction_reputation": {
|
"faction_reputation": {
|
||||||
"SILENT_SANCTUARY": 75
|
"SILENT_SANCTUARY": 75
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@
|
||||||
"generator_config": {
|
"generator_config": {
|
||||||
"density": "WARZONE"
|
"density": "WARZONE"
|
||||||
},
|
},
|
||||||
"hazards": [
|
"hazards": ["HAZARD_ARTILLERY_STRIKES"]
|
||||||
"HAZARD_ARTILLERY_STRIKES"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_19_INTRO",
|
"intro_sequence": "NARRATIVE_19_INTRO",
|
||||||
|
|
@ -43,9 +41,8 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_PEACEKEEPER_BADGE"],
|
||||||
"ITEM_PEACEKEEPER_BADGE"
|
"unlocks": ["MISSION_STORY_20"]
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_RUSTING_WASTES",
|
"type": "BIOME_RUSTING_WASTES",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_LIVE_WIRES"]
|
||||||
"HAZARD_LIVE_WIRES"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_20_INTRO",
|
"intro_sequence": "NARRATIVE_20_INTRO",
|
||||||
|
|
@ -22,8 +20,9 @@
|
||||||
"primary": [
|
"primary": [
|
||||||
{
|
{
|
||||||
"id": "OBJ_CHOICE",
|
"id": "OBJ_CHOICE",
|
||||||
"type": "CUSTOM_CHECK",
|
"type": "INTERACT",
|
||||||
"description": "Arm (Legion) OR Disarm (Concord) 3 Bomb Sites."
|
"description": "Arm (Legion) OR Disarm (Concord) 3 Bomb Sites.",
|
||||||
|
"target_object_id": "OBJ_BOMB_SITE"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -31,7 +30,14 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"aether_shards": 800
|
"aether_shards": 800
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_21"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
"mission_objects": [
|
||||||
|
{
|
||||||
|
"object_id": "OBJ_BOMB_SITE",
|
||||||
|
"placement_strategy": "random_walkable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_CRYSTAL_SPIRES",
|
"type": "BIOME_CRYSTAL_SPIRES",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_GRAVITY_FLUX"]
|
||||||
"HAZARD_GRAVITY_FLUX"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_21_INTRO",
|
"intro_sequence": "NARRATIVE_21_INTRO",
|
||||||
|
|
@ -22,17 +20,16 @@
|
||||||
"primary": [
|
"primary": [
|
||||||
{
|
{
|
||||||
"id": "OBJ_KOTH",
|
"id": "OBJ_KOTH",
|
||||||
"type": "KING_OF_THE_HILL",
|
"type": "REACH_ZONE",
|
||||||
"target_score": 100,
|
"description": "Control the Geode Platform.",
|
||||||
"description": "Control the Geode Platform."
|
"target_count": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_AETHER_LENS"],
|
||||||
"ITEM_AETHER_LENS"
|
"unlocks": ["MISSION_STORY_22"]
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,8 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_CORRUPTED_IDOL"],
|
||||||
"ITEM_CORRUPTED_IDOL"
|
"unlocks": ["MISSION_STORY_23"]
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_CONTESTED_FRONTIER",
|
"type": "BIOME_CONTESTED_FRONTIER",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_FALLING_DEBRIS"]
|
||||||
"HAZARD_FALLING_DEBRIS"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_23_INTRO",
|
"intro_sequence": "NARRATIVE_23_INTRO",
|
||||||
|
|
@ -41,7 +39,8 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"aether_shards": 900
|
"aether_shards": 900
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_24"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"ancient_cores": 3
|
"ancient_cores": 3
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_25"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,7 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["ACCESS_ACT_4", "MISSION_STORY_26"]
|
||||||
"ACCESS_ACT_4"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,10 @@
|
||||||
"primary": [
|
"primary": [
|
||||||
{
|
{
|
||||||
"id": "OBJ_DESTROY_GENS",
|
"id": "OBJ_DESTROY_GENS",
|
||||||
"type": "DESTROY_OBJECTS",
|
"type": "ELIMINATE_UNIT",
|
||||||
"tag": "SHIELD_GENERATOR",
|
|
||||||
"target_count": 4,
|
"target_count": 4,
|
||||||
"description": "Destroy 4 Shield Generators."
|
"description": "Destroy 4 Shield Generators.",
|
||||||
|
"target_def_id": "ENEMY_SHIELD_GENERATOR"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -33,7 +33,8 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"ancient_cores": 2
|
"ancient_cores": 2
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_27"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_CRYSTAL_SPIRES",
|
"type": "BIOME_CRYSTAL_SPIRES",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_GRAVITY_FLUX_HARD"]
|
||||||
"HAZARD_GRAVITY_FLUX_HARD"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_27_INTRO",
|
"intro_sequence": "NARRATIVE_27_INTRO",
|
||||||
|
|
@ -36,7 +34,8 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"ancient_cores": 2
|
"ancient_cores": 2
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_28"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
},
|
},
|
||||||
"biome": {
|
"biome": {
|
||||||
"type": "BIOME_FUNGAL_CAVES",
|
"type": "BIOME_FUNGAL_CAVES",
|
||||||
"hazards": [
|
"hazards": ["HAZARD_TOXIC_ATMOSPHERE"]
|
||||||
"HAZARD_TOXIC_ATMOSPHERE"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"narrative": {
|
"narrative": {
|
||||||
"intro_sequence": "NARRATIVE_28_INTRO",
|
"intro_sequence": "NARRATIVE_28_INTRO",
|
||||||
|
|
@ -32,7 +30,8 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"ancient_cores": 2
|
"ancient_cores": 2
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_29"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,8 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"items": [
|
"items": ["ITEM_VOID_ESSENCE"],
|
||||||
"ITEM_VOID_ESSENCE"
|
"unlocks": ["MISSION_STORY_30"]
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"currency": {
|
"currency": {
|
||||||
"ancient_cores": 5
|
"ancient_cores": 5
|
||||||
}
|
},
|
||||||
|
"unlocks": ["MISSION_STORY_31"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,7 @@
|
||||||
},
|
},
|
||||||
"rewards": {
|
"rewards": {
|
||||||
"guaranteed": {
|
"guaranteed": {
|
||||||
"unlocks": [
|
"unlocks": ["LIMIT_BREAK_MAX_LEVEL", "MISSION_STORY_32"]
|
||||||
"LIMIT_BREAK_MAX_LEVEL"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ export class GameLoop {
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
this.lastMoveTime = 0;
|
this.lastMoveTime = 0;
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
|
|
||||||
// Camera Animation State
|
// Camera Animation State
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
this.isAnimatingCamera = false;
|
this.isAnimatingCamera = false;
|
||||||
|
|
@ -1622,7 +1622,7 @@ export class GameLoop {
|
||||||
if (walkableY !== null) {
|
if (walkableY !== null) {
|
||||||
objPos = { x: position.x, y: walkableY, z: position.z };
|
objPos = { x: position.x, y: walkableY, z: position.z };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise, use placement strategy
|
// Otherwise, use placement strategy
|
||||||
else if (placement_strategy) {
|
else if (placement_strategy) {
|
||||||
objPos = this.findObjectPlacement(placement_strategy);
|
objPos = this.findObjectPlacement(placement_strategy);
|
||||||
|
|
@ -1630,7 +1630,9 @@ export class GameLoop {
|
||||||
|
|
||||||
if (!objPos) {
|
if (!objPos) {
|
||||||
console.warn(
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -1670,8 +1672,12 @@ export class GameLoop {
|
||||||
await this.missionManager.setupActiveMission();
|
await this.missionManager.setupActiveMission();
|
||||||
// Populate zone coordinates for REACH_ZONE objectives
|
// Populate zone coordinates for REACH_ZONE objectives
|
||||||
this.missionManager.populateZoneCoordinates();
|
this.missionManager.populateZoneCoordinates();
|
||||||
|
// Resolve positions for mission objects
|
||||||
|
this.missionManager.resolveMissionObjectPositions();
|
||||||
// Create visual markers for zones
|
// Create visual markers for zones
|
||||||
this.createZoneMarkers();
|
this.createZoneMarkers();
|
||||||
|
// Spawn mission objects
|
||||||
|
this.spawnMissionObjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
// WIRING: Listen for mission events
|
// WIRING: Listen for mission events
|
||||||
|
|
@ -1763,7 +1769,12 @@ export class GameLoop {
|
||||||
const reachZoneObjectives = [
|
const reachZoneObjectives = [
|
||||||
...(this.missionManager.currentObjectives || []),
|
...(this.missionManager.currentObjectives || []),
|
||||||
...(this.missionManager.secondaryObjectives || []),
|
...(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 obj of reachZoneObjectives) {
|
||||||
for (const coord of obj.zone_coords) {
|
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.
|
* Creates a visual marker for a single zone coordinate.
|
||||||
* @param {Position} pos - Zone position
|
* @param {Position} pos - Zone position
|
||||||
|
|
@ -1780,7 +1851,7 @@ export class GameLoop {
|
||||||
// Create a glowing beacon/marker for the zone
|
// Create a glowing beacon/marker for the zone
|
||||||
// Use a cone or cylinder with pulsing glow effect
|
// Use a cone or cylinder with pulsing glow effect
|
||||||
const geometry = new THREE.ConeGeometry(0.3, 1.2, 8);
|
const geometry = new THREE.ConeGeometry(0.3, 1.2, 8);
|
||||||
|
|
||||||
// Cyan/blue color to indicate recon zones
|
// Cyan/blue color to indicate recon zones
|
||||||
const material = new THREE.MeshStandardMaterial({
|
const material = new THREE.MeshStandardMaterial({
|
||||||
color: 0x00ffff, // Cyan
|
color: 0x00ffff, // Cyan
|
||||||
|
|
@ -1833,7 +1904,7 @@ export class GameLoop {
|
||||||
mesh.userData.time += mesh.userData.pulseSpeed;
|
mesh.userData.time += mesh.userData.pulseSpeed;
|
||||||
const offset = Math.sin(mesh.userData.time) * mesh.userData.pulseAmount;
|
const offset = Math.sin(mesh.userData.time) * mesh.userData.pulseAmount;
|
||||||
mesh.position.y = mesh.userData.originalY + offset;
|
mesh.position.y = mesh.userData.originalY + offset;
|
||||||
|
|
||||||
// Pulse emissive intensity
|
// Pulse emissive intensity
|
||||||
if (mesh.material && mesh.material.emissive) {
|
if (mesh.material && mesh.material.emissive) {
|
||||||
const intensity = 0.004444 + Math.sin(mesh.userData.time) * 0.002;
|
const intensity = 0.004444 + Math.sin(mesh.userData.time) * 0.002;
|
||||||
|
|
@ -2118,7 +2189,9 @@ export class GameLoop {
|
||||||
// Place in the center of the enemy spawn zone
|
// Place in the center of the enemy spawn zone
|
||||||
if (this.enemySpawnZone.length > 0) {
|
if (this.enemySpawnZone.length > 0) {
|
||||||
// Find center of enemy spawn zone
|
// 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) {
|
for (const spot of this.enemySpawnZone) {
|
||||||
sumX += spot.x;
|
sumX += spot.x;
|
||||||
sumY += spot.y;
|
sumY += spot.y;
|
||||||
|
|
@ -2129,7 +2202,11 @@ export class GameLoop {
|
||||||
const avgY = Math.round(sumY / this.enemySpawnZone.length);
|
const avgY = Math.round(sumY / this.enemySpawnZone.length);
|
||||||
|
|
||||||
// Find walkable position near center
|
// Find walkable position near center
|
||||||
const walkableY = this.movementSystem.findWalkableY(centerX, centerZ, avgY);
|
const walkableY = this.movementSystem.findWalkableY(
|
||||||
|
centerX,
|
||||||
|
centerZ,
|
||||||
|
avgY
|
||||||
|
);
|
||||||
if (walkableY !== null) {
|
if (walkableY !== null) {
|
||||||
return { x: centerX, y: walkableY, z: centerZ };
|
return { x: centerX, y: walkableY, z: centerZ };
|
||||||
}
|
}
|
||||||
|
|
@ -2139,7 +2216,9 @@ export class GameLoop {
|
||||||
case "center_of_player_room":
|
case "center_of_player_room":
|
||||||
// Place in the center of the player spawn zone
|
// Place in the center of the player spawn zone
|
||||||
if (this.playerSpawnZone.length > 0) {
|
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) {
|
for (const spot of this.playerSpawnZone) {
|
||||||
sumX += spot.x;
|
sumX += spot.x;
|
||||||
sumY += spot.y;
|
sumY += spot.y;
|
||||||
|
|
@ -2149,7 +2228,11 @@ export class GameLoop {
|
||||||
const centerZ = Math.round(sumZ / this.playerSpawnZone.length);
|
const centerZ = Math.round(sumZ / this.playerSpawnZone.length);
|
||||||
const avgY = Math.round(sumY / 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) {
|
if (walkableY !== null) {
|
||||||
return { x: centerX, y: walkableY, z: centerZ };
|
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 y = Math.floor(this.grid.size.y / 2); // Start from middle height
|
||||||
|
|
||||||
const walkableY = this.movementSystem.findWalkableY(x, z, y);
|
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 };
|
return { x, y: walkableY, z };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2175,14 +2261,32 @@ export class GameLoop {
|
||||||
// Try to place between player and enemy spawn zones
|
// Try to place between player and enemy spawn zones
|
||||||
if (this.playerSpawnZone.length > 0 && this.enemySpawnZone.length > 0) {
|
if (this.playerSpawnZone.length > 0 && this.enemySpawnZone.length > 0) {
|
||||||
const playerCenter = {
|
const playerCenter = {
|
||||||
x: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.x, 0) / this.playerSpawnZone.length),
|
x: Math.round(
|
||||||
z: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.z, 0) / this.playerSpawnZone.length),
|
this.playerSpawnZone.reduce((sum, s) => sum + s.x, 0) /
|
||||||
y: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.y, 0) / this.playerSpawnZone.length)
|
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 = {
|
const enemyCenter = {
|
||||||
x: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.x, 0) / this.enemySpawnZone.length),
|
x: Math.round(
|
||||||
z: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.z, 0) / this.enemySpawnZone.length),
|
this.enemySpawnZone.reduce((sum, s) => sum + s.x, 0) /
|
||||||
y: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.y, 0) / this.enemySpawnZone.length)
|
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);
|
const midX = Math.round((playerCenter.x + enemyCenter.x) / 2);
|
||||||
|
|
@ -2218,28 +2322,30 @@ export class GameLoop {
|
||||||
createMissionObjectMesh(objectId, pos) {
|
createMissionObjectMesh(objectId, pos) {
|
||||||
// Create a distinctive placeholder object (cylinder for objects vs boxes for units)
|
// Create a distinctive placeholder object (cylinder for objects vs boxes for units)
|
||||||
const geometry = new THREE.CylinderGeometry(0.4, 0.4, 0.8, 8);
|
const geometry = new THREE.CylinderGeometry(0.4, 0.4, 0.8, 8);
|
||||||
|
|
||||||
// Use a bright color to make objects stand out (yellow/gold for interactable objects)
|
// Use a bright color to make objects stand out (yellow/gold for interactable objects)
|
||||||
const material = new THREE.MeshStandardMaterial({
|
const material = new THREE.MeshStandardMaterial({
|
||||||
color: 0xffaa00, // Orange/gold
|
color: 0xffaa00, // Orange/gold
|
||||||
emissive: 0x442200, // Slight glow
|
emissive: 0x442200, // Slight glow
|
||||||
metalness: 0.3,
|
metalness: 0.3,
|
||||||
roughness: 0.7
|
roughness: 0.7,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
|
||||||
// Position the object on the floor (same as units: pos.y + 0.1)
|
// Position the object on the floor (same as units: pos.y + 0.1)
|
||||||
mesh.position.set(pos.x, pos.y + 0.5, pos.z);
|
mesh.position.set(pos.x, pos.y + 0.5, pos.z);
|
||||||
|
|
||||||
// Add metadata for interaction detection
|
// Add metadata for interaction detection
|
||||||
mesh.userData = { objectId, originalY: pos.y + 0.5 };
|
mesh.userData = { objectId, originalY: pos.y + 0.5 };
|
||||||
|
|
||||||
// Add to scene
|
// Add to scene
|
||||||
this.scene.add(mesh);
|
this.scene.add(mesh);
|
||||||
this.missionObjectMeshes.set(objectId, 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;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2251,7 +2357,7 @@ export class GameLoop {
|
||||||
if (!unit || !this.missionObjects) return;
|
if (!unit || !this.missionObjects) return;
|
||||||
|
|
||||||
const unitPos = unit.position;
|
const unitPos = unit.position;
|
||||||
|
|
||||||
// Check each mission object to see if unit is at its position
|
// Check each mission object to see if unit is at its position
|
||||||
for (const [objectId, objPos] of this.missionObjects.entries()) {
|
for (const [objectId, objPos] of this.missionObjects.entries()) {
|
||||||
// Check if unit is at the same x, z position (Y can vary slightly)
|
// Check if unit is at the same x, z position (Y can vary slightly)
|
||||||
|
|
@ -2260,22 +2366,22 @@ export class GameLoop {
|
||||||
Math.floor(unitPos.z) === Math.floor(objPos.z)
|
Math.floor(unitPos.z) === Math.floor(objPos.z)
|
||||||
) {
|
) {
|
||||||
console.log(`Unit ${unit.name} interacted with ${objectId}`);
|
console.log(`Unit ${unit.name} interacted with ${objectId}`);
|
||||||
|
|
||||||
// Dispatch INTERACT event for MissionManager to handle
|
// Dispatch INTERACT event for MissionManager to handle
|
||||||
if (this.missionManager) {
|
if (this.missionManager) {
|
||||||
this.missionManager.onGameEvent("INTERACT", {
|
this.missionManager.onGameEvent("INTERACT", {
|
||||||
objectId: objectId,
|
objectId: objectId,
|
||||||
unitId: unit.id,
|
unitId: unit.id,
|
||||||
position: unitPos
|
position: unitPos,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visual feedback: make object glow or change color
|
// Visual feedback: make object glow or change color
|
||||||
const mesh = this.missionObjectMeshes.get(objectId);
|
const mesh = this.missionObjectMeshes.get(objectId);
|
||||||
if (mesh && mesh.material) {
|
if (mesh && mesh.material) {
|
||||||
mesh.material.emissive.setHex(0x884400); // Brighter glow on interaction
|
mesh.material.emissive.setHex(0x884400); // Brighter glow on interaction
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only interact with one object per move
|
// Only interact with one object per move
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -2468,42 +2574,46 @@ export class GameLoop {
|
||||||
requestAnimationFrame(this.animate);
|
requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
if (this.inputManager) this.inputManager.update();
|
if (this.inputManager) this.inputManager.update();
|
||||||
|
|
||||||
// Update zone marker animations
|
// Update zone marker animations
|
||||||
this.updateZoneMarkers();
|
this.updateZoneMarkers();
|
||||||
|
|
||||||
// Handle camera animation if active
|
// Handle camera animation if active
|
||||||
if (this.isAnimatingCamera && this.controls && this.camera) {
|
if (this.isAnimatingCamera && this.controls && this.camera) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const elapsed = now - this.cameraAnimationStartTime;
|
const elapsed = now - this.cameraAnimationStartTime;
|
||||||
const progress = Math.min(elapsed / this.cameraAnimationDuration, 1.0);
|
const progress = Math.min(elapsed / this.cameraAnimationDuration, 1.0);
|
||||||
|
|
||||||
// Ease-out cubic for smooth deceleration
|
// Ease-out cubic for smooth deceleration
|
||||||
const eased = 1 - Math.pow(1 - progress, 3);
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
|
||||||
// Interpolate between start and target
|
// Interpolate between start and target
|
||||||
this.controls.target.lerpVectors(
|
this.controls.target.lerpVectors(
|
||||||
this.cameraAnimationStart,
|
this.cameraAnimationStart,
|
||||||
this.cameraAnimationTarget,
|
this.cameraAnimationTarget,
|
||||||
eased
|
eased
|
||||||
);
|
);
|
||||||
|
|
||||||
// Maintain camera's relative offset to preserve rotation
|
// Maintain camera's relative offset to preserve rotation
|
||||||
if (this.cameraAnimationOffset) {
|
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 animation is complete, snap to final position and stop
|
||||||
if (progress >= 1.0) {
|
if (progress >= 1.0) {
|
||||||
this.controls.target.copy(this.cameraAnimationTarget);
|
this.controls.target.copy(this.cameraAnimationTarget);
|
||||||
if (this.cameraAnimationOffset) {
|
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.isAnimatingCamera = false;
|
||||||
this.cameraAnimationOffset = null;
|
this.cameraAnimationOffset = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.controls) this.controls.update();
|
if (this.controls) this.controls.update();
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -2949,9 +3059,9 @@ export class GameLoop {
|
||||||
secondary: this.missionManager.secondaryObjectives || [],
|
secondary: this.missionManager.secondaryObjectives || [],
|
||||||
};
|
};
|
||||||
// Find turn limit from failure conditions
|
// Find turn limit from failure conditions
|
||||||
const turnLimitCondition = (this.missionManager.failureConditions || []).find(
|
const turnLimitCondition = (
|
||||||
(fc) => fc.type === "TURN_LIMIT_EXCEEDED" && fc.turn_limit
|
this.missionManager.failureConditions || []
|
||||||
);
|
).find((fc) => fc.type === "TURN_LIMIT_EXCEEDED" && fc.turn_limit);
|
||||||
if (turnLimitCondition) {
|
if (turnLimitCondition) {
|
||||||
turnLimit = {
|
turnLimit = {
|
||||||
limit: turnLimitCondition.turn_limit,
|
limit: turnLimitCondition.turn_limit,
|
||||||
|
|
@ -3038,7 +3148,7 @@ export class GameLoop {
|
||||||
const { unit } = detail;
|
const { unit } = detail;
|
||||||
// Center camera on the active unit
|
// Center camera on the active unit
|
||||||
this.centerCameraOnUnit(unit);
|
this.centerCameraOnUnit(unit);
|
||||||
|
|
||||||
// Update movement highlights if it's a player's turn
|
// Update movement highlights if it's a player's turn
|
||||||
if (unit.team === "PLAYER") {
|
if (unit.team === "PLAYER") {
|
||||||
this.updateMovementHighlights(unit);
|
this.updateMovementHighlights(unit);
|
||||||
|
|
@ -3148,9 +3258,16 @@ export class GameLoop {
|
||||||
|
|
||||||
// Check for death if this was a damage effect
|
// Check for death if this was a damage effect
|
||||||
if (result.data.type === "DAMAGE") {
|
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);
|
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)
|
// Process ON_KILL passive effects (on source)
|
||||||
this.processPassiveItemEffects(unit, "ON_KILL", {
|
this.processPassiveItemEffects(unit, "ON_KILL", {
|
||||||
target: killedUnit,
|
target: killedUnit,
|
||||||
|
|
@ -3168,7 +3285,9 @@ export class GameLoop {
|
||||||
damageResult.target
|
damageResult.target
|
||||||
);
|
);
|
||||||
if (killedUnit) {
|
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)
|
// Process ON_KILL passive effects (on source)
|
||||||
this.processPassiveItemEffects(unit, "ON_KILL", {
|
this.processPassiveItemEffects(unit, "ON_KILL", {
|
||||||
target: killedUnit,
|
target: killedUnit,
|
||||||
|
|
@ -3345,22 +3464,32 @@ export class GameLoop {
|
||||||
*/
|
*/
|
||||||
handleUnitDeath(unit) {
|
handleUnitDeath(unit) {
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
console.warn("[GameLoop] handleUnitDeath called with null/undefined unit");
|
console.warn(
|
||||||
|
"[GameLoop] handleUnitDeath called with null/undefined unit"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.grid || !this.unitManager) {
|
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;
|
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
|
// Remove unit from grid
|
||||||
if (unit.position) {
|
if (unit.position) {
|
||||||
this.grid.removeUnit(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 {
|
} 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
|
// Dispatch death event to MissionManager BEFORE removing from UnitManager
|
||||||
|
|
@ -3378,14 +3507,18 @@ export class GameLoop {
|
||||||
if (!unitDefId) {
|
if (!unitDefId) {
|
||||||
unitDefId = unit.id; // Fallback to instance ID
|
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, {
|
this.missionManager.onGameEvent(eventType, {
|
||||||
unitId: unit.id,
|
unitId: unit.id,
|
||||||
defId: unitDefId,
|
defId: unitDefId,
|
||||||
team: unit.team,
|
team: unit.team,
|
||||||
});
|
});
|
||||||
} else {
|
} 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)
|
// 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}`);
|
console.log(`[GameLoop] Mesh removed and disposed for ${unit.name}`);
|
||||||
} else {
|
} 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
|
// Get reputation changes
|
||||||
const reputationChanges = [];
|
const reputationChanges = [];
|
||||||
if (rewards.faction_reputation) {
|
if (rewards.faction_reputation) {
|
||||||
Object.entries(rewards.faction_reputation).forEach(([factionId, amount]) => {
|
Object.entries(rewards.faction_reputation).forEach(
|
||||||
reputationChanges.push({ factionId, amount });
|
([factionId, amount]) => {
|
||||||
});
|
reputationChanges.push({ factionId, amount });
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get squad status
|
// Get squad status
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,9 @@ class GameStateManagerClass {
|
||||||
// 7. Set up campaign data change listener
|
// 7. Set up campaign data change listener
|
||||||
this._setupCampaignDataListener();
|
this._setupCampaignDataListener();
|
||||||
|
|
||||||
|
// 8. Set up mission sequence completion listener (transition to Hub)
|
||||||
|
this._setupMissionSequenceListener();
|
||||||
|
|
||||||
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
|
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.
|
* Handles mission rewards by applying them to the hub stash.
|
||||||
* @param {Object} rewardData - Reward data from mission
|
* @param {Object} rewardData - Reward data from mission
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ export class MissionManager {
|
||||||
this.activeMissionId = null;
|
this.activeMissionId = null;
|
||||||
/** @type {Set<string>} */
|
/** @type {Set<string>} */
|
||||||
this.completedMissions = new Set();
|
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>} */
|
/** @type {Map<string, MissionDefinition>} */
|
||||||
this.missionRegistry = new Map();
|
this.missionRegistry = new Map();
|
||||||
/** @type {Map<string, MissionDefinition>} */
|
/** @type {Map<string, MissionDefinition>} */
|
||||||
|
|
@ -82,24 +85,73 @@ export class MissionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [tutorialMission, story02Mission, story03Mission] =
|
// Step 1: Load Initial Mission (Act 1, Mission 1)
|
||||||
await Promise.all([
|
const mission01 = await import(
|
||||||
import("../assets/data/missions/mission_tutorial_01.json", {
|
"../assets/data/missions/mission_act1_01.json",
|
||||||
with: { type: "json" },
|
{
|
||||||
}).then((m) => m.default),
|
with: { type: "json" },
|
||||||
import("../assets/data/missions/mission_story_02.json", {
|
}
|
||||||
with: { type: "json" },
|
).then((m) => m.default);
|
||||||
}).then((m) => m.default),
|
|
||||||
import("../assets/data/missions/mission_story_03.json", {
|
|
||||||
with: { type: "json" },
|
|
||||||
}).then((m) => m.default),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.registerMission(tutorialMission);
|
this.registerMission(mission01);
|
||||||
this.registerMission(story02Mission);
|
|
||||||
this.registerMission(story03Mission);
|
// Step 2: Load unlock state
|
||||||
} catch (error) {
|
await this.loadUnlockState();
|
||||||
console.error("Failed to load missions:", error);
|
|
||||||
|
// 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
|
// Unlock additional regions based on completed missions
|
||||||
// This is a simple implementation - can be enhanced with actual unlock logic
|
// 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
|
// After tutorial, unlock Crystal Spires
|
||||||
if (!defaultRegions.includes("BIOME_CRYSTAL_SPIRES")) {
|
if (!defaultRegions.includes("BIOME_CRYSTAL_SPIRES")) {
|
||||||
defaultRegions.push("BIOME_CRYSTAL_SPIRES");
|
defaultRegions.push("BIOME_CRYSTAL_SPIRES");
|
||||||
|
|
@ -271,7 +323,7 @@ export class MissionManager {
|
||||||
}
|
}
|
||||||
// Default to Tutorial if history is empty
|
// Default to Tutorial if history is empty
|
||||||
if (this.completedMissions.size === 0) {
|
if (this.completedMissions.size === 0) {
|
||||||
this.activeMissionId = "MISSION_TUTORIAL_01";
|
this.activeMissionId = "MISSION_ACT1_01";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,7 +357,7 @@ export class MissionManager {
|
||||||
async getActiveMission() {
|
async getActiveMission() {
|
||||||
await this._ensureMissionsLoaded();
|
await this._ensureMissionsLoaded();
|
||||||
if (!this.activeMissionId)
|
if (!this.activeMissionId)
|
||||||
return this.missionRegistry.get("MISSION_TUTORIAL_01");
|
return this.missionRegistry.get("MISSION_ACT1_01");
|
||||||
return this.missionRegistry.get(this.activeMissionId);
|
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.
|
* Prepares the manager for a new run.
|
||||||
* Resets objectives and prepares narrative hooks.
|
* Resets objectives and prepares narrative hooks.
|
||||||
|
|
@ -455,22 +584,30 @@ export class MissionManager {
|
||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
const introId = this.currentMissionDef.narrative.intro_sequence;
|
const introId = this.currentMissionDef.narrative.intro_sequence;
|
||||||
|
|
||||||
// Map narrative ID to filename
|
|
||||||
// NARRATIVE_TUTORIAL_INTRO -> tutorial_intro.json
|
|
||||||
const narrativeFileName = this._mapNarrativeIdToFileName(introId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load the narrative JSON file
|
let narrativeData;
|
||||||
const response = await fetch(
|
|
||||||
`assets/data/narrative/${narrativeFileName}.json`
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(`Failed to load narrative: ${narrativeFileName}`);
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const narrativeData = await response.json();
|
// Check for dynamic data first
|
||||||
|
if (
|
||||||
|
this.currentMissionDef.narrative._dynamic_data &&
|
||||||
|
this.currentMissionDef.narrative._dynamic_data[introId]
|
||||||
|
) {
|
||||||
|
console.log(`Using dynamic narrative data for ${introId}`);
|
||||||
|
narrativeData =
|
||||||
|
this.currentMissionDef.narrative._dynamic_data[introId];
|
||||||
|
} else {
|
||||||
|
// Fallback to loading from file
|
||||||
|
const narrativeFileName = this._mapNarrativeIdToFileName(introId);
|
||||||
|
const response = await fetch(
|
||||||
|
`assets/data/narrative/${narrativeFileName}.json`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to load narrative: ${narrativeFileName}`);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
narrativeData = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// Set up listener for narrative end
|
// Set up listener for narrative end
|
||||||
const onEnd = () => {
|
const onEnd = () => {
|
||||||
|
|
@ -483,7 +620,7 @@ export class MissionManager {
|
||||||
console.log(`Playing Narrative Intro: ${introId}`);
|
console.log(`Playing Narrative Intro: ${introId}`);
|
||||||
narrativeManager.startSequence(narrativeData);
|
narrativeManager.startSequence(narrativeData);
|
||||||
} catch (error) {
|
} 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
|
resolve(); // Resolve anyway to not block game start
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -909,6 +1046,15 @@ export class MissionManager {
|
||||||
if (this.currentMissionDef.narrative?.outro_success) {
|
if (this.currentMissionDef.narrative?.outro_success) {
|
||||||
await this.playOutro(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);
|
this.unlockClasses(classUnlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle special unlocks
|
// Handle special unlocks & Mission unlocks
|
||||||
specialUnlocks.forEach((unlock) => {
|
specialUnlocks.forEach((unlock) => {
|
||||||
if (unlock === "UNLOCK_PROCEDURAL_MISSIONS") {
|
if (unlock === "UNLOCK_PROCEDURAL_MISSIONS") {
|
||||||
// Procedural missions are now unlocked
|
// Procedural missions are now unlocked
|
||||||
// They will be generated when the mission board is accessed
|
// They will be generated when the mission board is accessed
|
||||||
console.log("Procedural missions unlocked!");
|
console.log("Procedural missions unlocked!");
|
||||||
|
} else if (unlock.startsWith("MISSION_")) {
|
||||||
|
this.unlockMission(unlock);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1105,4 +1253,52 @@ export class MissionManager {
|
||||||
updateTurn(turn) {
|
updateTurn(turn) {
|
||||||
this.currentTurn = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -79,6 +79,7 @@ export function createMockMissionManager(enemySpawns = []) {
|
||||||
getActiveMission: sinon.stub().returns(mockMissionDef),
|
getActiveMission: sinon.stub().returns(mockMissionDef),
|
||||||
setGridContext: sinon.stub(),
|
setGridContext: sinon.stub(),
|
||||||
populateZoneCoordinates: sinon.stub(),
|
populateZoneCoordinates: sinon.stub(),
|
||||||
|
resolveMissionObjectPositions: sinon.stub().returns([]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,7 +103,10 @@ export function createRunData(overrides = {}) {
|
||||||
* @returns {{ playerUnit: Object; enemyUnit: Object }}
|
* @returns {{ playerUnit: Object; enemyUnit: Object }}
|
||||||
*/
|
*/
|
||||||
export function setupCombatUnits(gameLoop) {
|
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.movement = 4;
|
||||||
playerUnit.baseStats.speed = 10;
|
playerUnit.baseStats.speed = 10;
|
||||||
playerUnit.currentAP = 10;
|
playerUnit.currentAP = 10;
|
||||||
|
|
@ -136,7 +140,7 @@ export function cleanupTurnSystem(gameLoop) {
|
||||||
// End combat first to stop any loops
|
// End combat first to stop any loops
|
||||||
gameLoop.turnSystem.endCombat();
|
gameLoop.turnSystem.endCombat();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then reset the turn system
|
// Then reset the turn system
|
||||||
if (typeof gameLoop.turnSystem.reset === "function") {
|
if (typeof gameLoop.turnSystem.reset === "function") {
|
||||||
gameLoop.turnSystem.reset();
|
gameLoop.turnSystem.reset();
|
||||||
|
|
@ -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 () => {
|
it("CoA 1: Should initialize with tutorial mission registered", async () => {
|
||||||
await manager._ensureMissionsLoaded();
|
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.activeMissionId).to.be.null;
|
||||||
expect(manager.completedMissions).to.be.instanceof(Set);
|
expect(manager.completedMissions).to.be.instanceof(Set);
|
||||||
});
|
});
|
||||||
|
|
@ -60,7 +60,7 @@ describe("Manager: MissionManager", () => {
|
||||||
const mission = await manager.getActiveMission();
|
const mission = await manager.getActiveMission();
|
||||||
|
|
||||||
expect(mission).to.exist;
|
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 () => {
|
it("CoA 4: getActiveMission should return active mission if set", async () => {
|
||||||
|
|
@ -83,7 +83,11 @@ describe("Manager: MissionManager", () => {
|
||||||
mission.objectives = {
|
mission.objectives = {
|
||||||
primary: [
|
primary: [
|
||||||
{ type: "ELIMINATE_ALL", target_count: 5 },
|
{ 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);
|
manager.setUnitManager(mockUnitManager);
|
||||||
await manager.setupActiveMission();
|
await manager.setupActiveMission();
|
||||||
manager.currentObjectives = [
|
manager.currentObjectives = [{ type: "ELIMINATE_ALL", complete: false }];
|
||||||
{ type: "ELIMINATE_ALL", complete: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" });
|
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", {
|
||||||
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_OTHER", defId: "ENEMY_OTHER" }); // Should not count
|
unitId: "ENEMY_GOBLIN",
|
||||||
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN", defId: "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].current).to.equal(2);
|
||||||
expect(manager.currentObjectives[0].complete).to.be.true;
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
||||||
|
|
@ -148,9 +159,9 @@ describe("Manager: MissionManager", () => {
|
||||||
manager.currentObjectives = [
|
manager.currentObjectives = [
|
||||||
{ type: "ELIMINATE_ALL", target_count: 2, current: 2, complete: true },
|
{ type: "ELIMINATE_ALL", target_count: 2, current: 2, complete: true },
|
||||||
];
|
];
|
||||||
manager.activeMissionId = "MISSION_TUTORIAL_01";
|
manager.activeMissionId = "MISSION_ACT1_01";
|
||||||
manager.currentMissionDef = {
|
manager.currentMissionDef = {
|
||||||
id: "MISSION_TUTORIAL_01",
|
id: "MISSION_ACT1_01",
|
||||||
rewards: { guaranteed: {} },
|
rewards: { guaranteed: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -163,9 +174,9 @@ describe("Manager: MissionManager", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 9: completeActiveMission should add mission to completed set", async () => {
|
it("CoA 9: completeActiveMission should add mission to completed set", async () => {
|
||||||
manager.activeMissionId = "MISSION_TUTORIAL_01";
|
manager.activeMissionId = "MISSION_ACT1_01";
|
||||||
manager.currentMissionDef = {
|
manager.currentMissionDef = {
|
||||||
id: "MISSION_TUTORIAL_01",
|
id: "MISSION_ACT1_01",
|
||||||
rewards: { guaranteed: {} },
|
rewards: { guaranteed: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -175,44 +186,48 @@ describe("Manager: MissionManager", () => {
|
||||||
|
|
||||||
await manager.completeActiveMission();
|
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.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);
|
window.removeEventListener("campaign-data-changed", eventSpy);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 10: load should restore completed missions", () => {
|
it("CoA 10: load should restore completed missions", () => {
|
||||||
const saveData = {
|
const saveData = {
|
||||||
completedMissions: ["MISSION_TUTORIAL_01", "MISSION_TEST_01"],
|
completedMissions: ["MISSION_ACT1_01", "MISSION_TEST_01"],
|
||||||
};
|
};
|
||||||
|
|
||||||
manager.load(saveData);
|
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;
|
expect(manager.completedMissions.has("MISSION_TEST_01")).to.be.true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 11: save should serialize completed missions", () => {
|
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");
|
manager.completedMissions.add("MISSION_TEST_01");
|
||||||
|
|
||||||
const saved = manager.save();
|
const saved = manager.save();
|
||||||
|
|
||||||
expect(saved.completedMissions).to.be.an("array");
|
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");
|
expect(saved.completedMissions).to.include("MISSION_TEST_01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 12: _mapNarrativeIdToFileName should convert narrative IDs to filenames", () => {
|
it("CoA 12: _mapNarrativeIdToFileName should convert narrative IDs to filenames", () => {
|
||||||
expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_INTRO")).to.equal(
|
expect(
|
||||||
"tutorial_intro"
|
manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_INTRO")
|
||||||
);
|
).to.equal("tutorial_intro");
|
||||||
expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_SUCCESS")).to.equal(
|
expect(
|
||||||
"tutorial_success"
|
manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_SUCCESS")
|
||||||
);
|
).to.equal("tutorial_success");
|
||||||
// The implementation converts NARRATIVE_UNKNOWN to narrative_unknown (lowercase with NARRATIVE_ prefix removed)
|
// 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 () => {
|
it("CoA 13: getActiveMission should expose enemy_spawns from mission definition", async () => {
|
||||||
|
|
@ -220,9 +235,7 @@ describe("Manager: MissionManager", () => {
|
||||||
const missionWithEnemies = {
|
const missionWithEnemies = {
|
||||||
id: "MISSION_TEST",
|
id: "MISSION_TEST",
|
||||||
config: { title: "Test Mission" },
|
config: { title: "Test Mission" },
|
||||||
enemy_spawns: [
|
enemy_spawns: [{ enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", count: 2 }],
|
||||||
{ enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", count: 2 },
|
|
||||||
],
|
|
||||||
objectives: { primary: [] },
|
objectives: { primary: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -233,7 +246,9 @@ describe("Manager: MissionManager", () => {
|
||||||
|
|
||||||
expect(mission.enemy_spawns).to.exist;
|
expect(mission.enemy_spawns).to.exist;
|
||||||
expect(mission.enemy_spawns).to.have.length(1);
|
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);
|
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.exist;
|
||||||
expect(mission.mission_objects).to.have.length(2);
|
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].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].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 () => {
|
it("CoA 15: getActiveMission should expose deployment constraints with tutorial hints", async () => {
|
||||||
|
|
@ -350,7 +371,9 @@ describe("Manager: MissionManager", () => {
|
||||||
window.addEventListener("mission-failure", failureSpy);
|
window.addEventListener("mission-failure", failureSpy);
|
||||||
|
|
||||||
manager.currentTurn = 11;
|
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", {});
|
manager.checkFailureConditions("TURN_END", {});
|
||||||
|
|
||||||
|
|
@ -564,9 +587,7 @@ describe("Manager: MissionManager", () => {
|
||||||
config: { title: "Test" },
|
config: { title: "Test" },
|
||||||
objectives: {
|
objectives: {
|
||||||
primary: [{ type: "ELIMINATE_ALL", id: "PRIMARY_1" }],
|
primary: [{ type: "ELIMINATE_ALL", id: "PRIMARY_1" }],
|
||||||
secondary: [
|
secondary: [{ type: "SURVIVE", turn_count: 10, id: "SECONDARY_1" }],
|
||||||
{ type: "SURVIVE", turn_count: 10, id: "SECONDARY_1" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -842,7 +863,7 @@ describe("Manager: MissionManager", () => {
|
||||||
it("CoA 31: Should lazy-load missions on first access", async () => {
|
it("CoA 31: Should lazy-load missions on first access", async () => {
|
||||||
// Create a fresh manager to test lazy loading
|
// Create a fresh manager to test lazy loading
|
||||||
const freshManager = new MissionManager(mockPersistence);
|
const freshManager = new MissionManager(mockPersistence);
|
||||||
|
|
||||||
// Initially, registry should be empty (missions not loaded)
|
// Initially, registry should be empty (missions not loaded)
|
||||||
expect(freshManager.missionRegistry.size).to.equal(0);
|
expect(freshManager.missionRegistry.size).to.equal(0);
|
||||||
|
|
||||||
|
|
@ -851,7 +872,7 @@ describe("Manager: MissionManager", () => {
|
||||||
|
|
||||||
// Now missions should be loaded
|
// Now missions should be loaded
|
||||||
expect(freshManager.missionRegistry.size).to.be.greaterThan(0);
|
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 () => {
|
it("CoA 32: Should not reload missions if already loaded", async () => {
|
||||||
|
|
@ -869,7 +890,7 @@ describe("Manager: MissionManager", () => {
|
||||||
it("CoA 33: Should handle lazy loading errors gracefully", async () => {
|
it("CoA 33: Should handle lazy loading errors gracefully", async () => {
|
||||||
// Create a manager with a failing persistence (if needed)
|
// Create a manager with a failing persistence (if needed)
|
||||||
const freshManager = new MissionManager(mockPersistence);
|
const freshManager = new MissionManager(mockPersistence);
|
||||||
|
|
||||||
// Should not throw even if missions fail to load
|
// Should not throw even if missions fail to load
|
||||||
try {
|
try {
|
||||||
await freshManager._ensureMissionsLoaded();
|
await freshManager._ensureMissionsLoaded();
|
||||||
|
|
@ -881,5 +902,70 @@ describe("Manager: MissionManager", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
describe("Dynamic Narrative Loading", () => {
|
||||||
|
it("CoA 35: playIntro should use dynamic narrative data if present", async () => {
|
||||||
|
const dynamicData = {
|
||||||
|
NARRATIVE_DYNAMIC_INTRO: {
|
||||||
|
id: "NARRATIVE_DYNAMIC_INTRO",
|
||||||
|
nodes: [{ id: "1", text: "Dynamic Text" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.activeMissionId = "MISSION_DYNAMIC_TEST";
|
||||||
|
manager.currentMissionDef = {
|
||||||
|
id: "MISSION_DYNAMIC_TEST",
|
||||||
|
narrative: {
|
||||||
|
intro_sequence: "NARRATIVE_DYNAMIC_INTRO",
|
||||||
|
_dynamic_data: dynamicData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock fetch to fail if called (should not be called)
|
||||||
|
const fetchStub = sinon
|
||||||
|
.stub(window, "fetch")
|
||||||
|
.rejects(new Error("Should not fetch"));
|
||||||
|
|
||||||
|
await manager.playIntro();
|
||||||
|
|
||||||
|
expect(mockNarrativeManager.startSequence.calledOnce).to.be.true;
|
||||||
|
expect(
|
||||||
|
mockNarrativeManager.startSequence.firstCall.args[0]
|
||||||
|
).to.deep.equal(dynamicData["NARRATIVE_DYNAMIC_INTRO"]);
|
||||||
|
expect(fetchStub.called).to.be.false;
|
||||||
|
|
||||||
|
fetchStub.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 36: playIntro should fallback to fetch if dynamic data matches but ID not found", async () => {
|
||||||
|
// This case checks if _dynamic_data exists but doesn't have the specific ID
|
||||||
|
manager.activeMissionId = "MISSION_FALLBACK_TEST";
|
||||||
|
manager.currentMissionDef = {
|
||||||
|
id: "MISSION_FALLBACK_TEST",
|
||||||
|
narrative: {
|
||||||
|
intro_sequence: "NARRATIVE_FILE_INTRO",
|
||||||
|
_dynamic_data: { OTHER_ID: {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock fetch to succeed
|
||||||
|
const mockResponse = new Response(
|
||||||
|
JSON.stringify({ id: "NARRATIVE_FILE_INTRO" }),
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
const fetchStub = sinon.stub(window, "fetch").resolves(mockResponse);
|
||||||
|
|
||||||
|
// Stub mapNarrativeIdToFileName to return simple name
|
||||||
|
manager._mapNarrativeIdToFileName = sinon
|
||||||
|
.stub()
|
||||||
|
.returns("narrative_file_intro");
|
||||||
|
|
||||||
|
await manager.playIntro();
|
||||||
|
|
||||||
|
expect(fetchStub.calledOnce).to.be.true;
|
||||||
|
expect(mockNarrativeManager.startSequence.calledOnce).to.be.true;
|
||||||
|
|
||||||
|
fetchStub.restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,15 @@ describe("Systems: MissionGenerator", function () {
|
||||||
|
|
||||||
describe("extractBaseName", () => {
|
describe("extractBaseName", () => {
|
||||||
it("should extract base name from mission title", () => {
|
it("should extract base name from mission title", () => {
|
||||||
expect(MissionGenerator.extractBaseName("Operation: Silent Viper")).to.equal("Silent Viper");
|
expect(
|
||||||
expect(MissionGenerator.extractBaseName("Operation: Silent Viper II")).to.equal("Silent Viper");
|
MissionGenerator.extractBaseName("Operation: Silent Viper")
|
||||||
expect(MissionGenerator.extractBaseName("Operation: Crimson Cache III")).to.equal("Crimson Cache");
|
).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 = [
|
const history = [
|
||||||
"Operation: Silent Viper",
|
"Operation: Silent Viper",
|
||||||
"Operation: Silent Viper II",
|
"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", () => {
|
it("should return 0 if no matches found", () => {
|
||||||
const history = ["Operation: Other Mission"];
|
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 = [];
|
const emptyHistory = [];
|
||||||
|
|
||||||
it("CoA 1: Should generate a mission with required structure", () => {
|
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("id");
|
||||||
expect(mission).to.have.property("type", "SIDE_QUEST");
|
expect(mission).to.have.property("type", "SIDE_QUEST");
|
||||||
|
|
@ -89,8 +103,16 @@ describe("Systems: MissionGenerator", function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 2: Should generate unique mission IDs", () => {
|
it("CoA 2: Should generate unique mission IDs", () => {
|
||||||
const mission1 = MissionGenerator.generateSideOp(1, unlockedRegions, emptyHistory);
|
const mission1 = MissionGenerator.generateSideOp(
|
||||||
const mission2 = MissionGenerator.generateSideOp(1, unlockedRegions, emptyHistory);
|
1,
|
||||||
|
unlockedRegions,
|
||||||
|
emptyHistory
|
||||||
|
);
|
||||||
|
const mission2 = MissionGenerator.generateSideOp(
|
||||||
|
1,
|
||||||
|
unlockedRegions,
|
||||||
|
emptyHistory
|
||||||
|
);
|
||||||
|
|
||||||
expect(mission1.id).to.not.equal(mission2.id);
|
expect(mission1.id).to.not.equal(mission2.id);
|
||||||
expect(mission1.id).to.match(/^SIDE_OP_\d+_[a-z0-9]+$/);
|
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", () => {
|
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: .+$/);
|
expect(mission.config.title).to.match(/^Operation: .+$/);
|
||||||
const parts = mission.config.title.replace("Operation: ", "").split(" ");
|
const parts = mission.config.title.replace("Operation: ", "").split(" ");
|
||||||
|
|
@ -111,7 +137,11 @@ describe("Systems: MissionGenerator", function () {
|
||||||
const objectiveTypes = new Set();
|
const objectiveTypes = new Set();
|
||||||
|
|
||||||
for (let i = 0; i < 20; i++) {
|
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];
|
const primaryObj = mission.objectives.primary[0];
|
||||||
objectiveTypes.add(primaryObj.type);
|
objectiveTypes.add(primaryObj.type);
|
||||||
|
|
||||||
|
|
@ -133,7 +163,11 @@ describe("Systems: MissionGenerator", function () {
|
||||||
|
|
||||||
it("CoA 5: Should generate series missions with Roman numerals", () => {
|
it("CoA 5: Should generate series missions with Roman numerals", () => {
|
||||||
const history = ["Operation: Silent Viper"];
|
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 it matches the base name, should have "II"
|
||||||
if (mission.config.title.includes("Silent Viper")) {
|
if (mission.config.title.includes("Silent Viper")) {
|
||||||
|
|
@ -143,48 +177,82 @@ describe("Systems: MissionGenerator", function () {
|
||||||
|
|
||||||
it("CoA 6: Should scale difficulty tier correctly", () => {
|
it("CoA 6: Should scale difficulty tier correctly", () => {
|
||||||
for (let tier = 1; tier <= 5; tier++) {
|
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.difficulty_tier).to.equal(tier);
|
||||||
expect(mission.config.recommended_level).to.be.a("number");
|
expect(mission.config.recommended_level).to.be.a("number");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 7: Should generate biome configuration", () => {
|
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("type");
|
||||||
expect(mission.biome).to.have.property("generator_config");
|
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("size");
|
||||||
expect(mission.biome.generator_config).to.have.property("room_count");
|
expect(mission.biome.generator_config).to.have.property("room_count");
|
||||||
expect(mission.biome.generator_config).to.have.property("density");
|
expect(mission.biome.generator_config).to.have.property("density");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 8: Should generate rewards with tier-based scaling", () => {
|
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).to.have.property("guaranteed");
|
||||||
expect(mission.rewards.guaranteed).to.have.property("xp");
|
expect(mission.rewards.guaranteed).to.have.property("xp");
|
||||||
expect(mission.rewards.guaranteed).to.have.property("currency");
|
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");
|
expect(mission.rewards).to.have.property("faction_reputation");
|
||||||
|
|
||||||
// Higher tier should have higher rewards
|
// Higher tier should have higher rewards
|
||||||
const lowTierMission = MissionGenerator.generateSideOp(1, unlockedRegions, emptyHistory);
|
const lowTierMission = MissionGenerator.generateSideOp(
|
||||||
const highTierMission = MissionGenerator.generateSideOp(5, unlockedRegions, emptyHistory);
|
1,
|
||||||
|
unlockedRegions,
|
||||||
|
emptyHistory
|
||||||
|
);
|
||||||
|
const highTierMission = MissionGenerator.generateSideOp(
|
||||||
|
5,
|
||||||
|
unlockedRegions,
|
||||||
|
emptyHistory
|
||||||
|
);
|
||||||
|
|
||||||
expect(highTierMission.rewards.guaranteed.currency.aether_shards)
|
expect(
|
||||||
.to.be.greaterThan(lowTierMission.rewards.guaranteed.currency.aether_shards);
|
highTierMission.rewards.guaranteed.currency.aether_shards
|
||||||
|
).to.be.greaterThan(
|
||||||
|
lowTierMission.rewards.guaranteed.currency.aether_shards
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 9: Should generate archetype-specific objectives", () => {
|
it("CoA 9: Should generate archetype-specific objectives", () => {
|
||||||
// Test Skirmish (ELIMINATE_ALL)
|
// Test Skirmish (ELIMINATE_ALL)
|
||||||
let foundSkirmish = false;
|
let foundSkirmish = false;
|
||||||
for (let i = 0; i < 30 && !foundSkirmish; i++) {
|
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") {
|
if (mission.objectives.primary[0].type === "ELIMINATE_ALL") {
|
||||||
foundSkirmish = true;
|
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;
|
expect(foundSkirmish).to.be.true;
|
||||||
|
|
@ -192,10 +260,16 @@ describe("Systems: MissionGenerator", function () {
|
||||||
// Test Salvage (INTERACT)
|
// Test Salvage (INTERACT)
|
||||||
let foundSalvage = false;
|
let foundSalvage = false;
|
||||||
for (let i = 0; i < 30 && !foundSalvage; i++) {
|
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") {
|
if (mission.objectives.primary[0].type === "INTERACT") {
|
||||||
foundSalvage = true;
|
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.least(3);
|
||||||
expect(mission.objectives.primary[0].target_count).to.be.at.most(5);
|
expect(mission.objectives.primary[0].target_count).to.be.at.most(5);
|
||||||
}
|
}
|
||||||
|
|
@ -205,11 +279,19 @@ describe("Systems: MissionGenerator", function () {
|
||||||
// Test Assassination (ELIMINATE_UNIT)
|
// Test Assassination (ELIMINATE_UNIT)
|
||||||
let foundAssassination = false;
|
let foundAssassination = false;
|
||||||
for (let i = 0; i < 30 && !foundAssassination; i++) {
|
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") {
|
if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") {
|
||||||
foundAssassination = true;
|
foundAssassination = true;
|
||||||
expect(mission.objectives.primary[0]).to.have.property("target_def_id");
|
expect(mission.objectives.primary[0]).to.have.property(
|
||||||
expect(mission.objectives.primary[0].description).to.include("High-Value Target");
|
"target_def_id"
|
||||||
|
);
|
||||||
|
expect(mission.objectives.primary[0].description).to.include(
|
||||||
|
"High-Value Target"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(foundAssassination).to.be.true;
|
expect(foundAssassination).to.be.true;
|
||||||
|
|
@ -217,7 +299,11 @@ describe("Systems: MissionGenerator", function () {
|
||||||
// Test Recon (REACH_ZONE)
|
// Test Recon (REACH_ZONE)
|
||||||
let foundRecon = false;
|
let foundRecon = false;
|
||||||
for (let i = 0; i < 30 && !foundRecon; i++) {
|
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") {
|
if (mission.objectives.primary[0].type === "REACH_ZONE") {
|
||||||
foundRecon = true;
|
foundRecon = true;
|
||||||
expect(mission.objectives.primary[0].target_count).to.equal(3);
|
expect(mission.objectives.primary[0].target_count).to.equal(3);
|
||||||
|
|
@ -235,7 +321,11 @@ describe("Systems: MissionGenerator", function () {
|
||||||
const configs = new Map();
|
const configs = new Map();
|
||||||
|
|
||||||
for (let i = 0; i < 50; i++) {
|
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;
|
const objType = mission.objectives.primary[0].type;
|
||||||
if (!configs.has(objType)) {
|
if (!configs.has(objType)) {
|
||||||
configs.set(objType, mission.biome.generator_config);
|
configs.set(objType, mission.biome.generator_config);
|
||||||
|
|
@ -256,15 +346,29 @@ describe("Systems: MissionGenerator", function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 11: Should map biome to faction reputation", () => {
|
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);
|
expect(mission.rewards.faction_reputation.COGWORK_CONCORD).to.equal(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 12: Should clamp tier to valid range (1-5)", () => {
|
it("CoA 12: Should clamp tier to valid range (1-5)", () => {
|
||||||
const lowMission = MissionGenerator.generateSideOp(0, unlockedRegions, emptyHistory);
|
const lowMission = MissionGenerator.generateSideOp(
|
||||||
const highMission = MissionGenerator.generateSideOp(10, unlockedRegions, emptyHistory);
|
0,
|
||||||
|
unlockedRegions,
|
||||||
|
emptyHistory
|
||||||
|
);
|
||||||
|
const highMission = MissionGenerator.generateSideOp(
|
||||||
|
10,
|
||||||
|
unlockedRegions,
|
||||||
|
emptyHistory
|
||||||
|
);
|
||||||
|
|
||||||
expect(lowMission.config.difficulty_tier).to.equal(1);
|
expect(lowMission.config.difficulty_tier).to.equal(1);
|
||||||
expect(highMission.config.difficulty_tier).to.equal(5);
|
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", () => {
|
it("CoA 13: Should fill board up to 5 missions", () => {
|
||||||
const emptyBoard = [];
|
const emptyBoard = [];
|
||||||
const refreshed = MissionGenerator.refreshBoard(emptyBoard, 2, unlockedRegions, emptyHistory);
|
const refreshed = MissionGenerator.refreshBoard(
|
||||||
|
emptyBoard,
|
||||||
|
2,
|
||||||
|
unlockedRegions,
|
||||||
|
emptyHistory
|
||||||
|
);
|
||||||
|
|
||||||
expect(refreshed.length).to.equal(5);
|
expect(refreshed.length).to.equal(5);
|
||||||
});
|
});
|
||||||
|
|
@ -286,45 +395,78 @@ describe("Systems: MissionGenerator", function () {
|
||||||
const existingMissions = [
|
const existingMissions = [
|
||||||
MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory),
|
MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory),
|
||||||
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);
|
expect(refreshed.length).to.equal(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 15: Should remove expired missions on daily reset", () => {
|
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
|
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
|
mission2.expiresIn = 3; // Still valid
|
||||||
|
|
||||||
const board = [mission1, mission2];
|
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
|
// Mission1 should be removed (expiresIn becomes 0), mission2 kept, then filled to 5
|
||||||
expect(refreshed.length).to.equal(5);
|
expect(refreshed.length).to.equal(5);
|
||||||
// Mission1 should not be in the list
|
// 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
|
// 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).to.exist;
|
||||||
expect(foundMission2.expiresIn).to.equal(2);
|
expect(foundMission2.expiresIn).to.equal(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 16: Should not remove missions when not daily reset", () => {
|
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;
|
mission1.expiresIn = 1;
|
||||||
const mission2 = MissionGenerator.generateSideOp(2, unlockedRegions, emptyHistory);
|
const mission2 = MissionGenerator.generateSideOp(
|
||||||
|
2,
|
||||||
|
unlockedRegions,
|
||||||
|
emptyHistory
|
||||||
|
);
|
||||||
mission2.expiresIn = 3;
|
mission2.expiresIn = 3;
|
||||||
|
|
||||||
const board = [mission1, mission2];
|
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
|
// Both should remain (not expired yet), expiresIn unchanged, then filled to 5
|
||||||
expect(refreshed.length).to.equal(5);
|
expect(refreshed.length).to.equal(5);
|
||||||
const foundMission1 = refreshed.find(m => m.id === mission1.id);
|
const foundMission1 = refreshed.find((m) => m.id === mission1.id);
|
||||||
const foundMission2 = refreshed.find(m => m.id === mission2.id);
|
const foundMission2 = refreshed.find((m) => m.id === mission2.id);
|
||||||
expect(foundMission1).to.exist;
|
expect(foundMission1).to.exist;
|
||||||
expect(foundMission2).to.exist;
|
expect(foundMission2).to.exist;
|
||||||
expect(foundMission1.expiresIn).to.equal(1);
|
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", () => {
|
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;
|
mission1.expiresIn = 3;
|
||||||
|
|
||||||
const board = [mission1];
|
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.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", () => {
|
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;
|
delete mission1.expiresIn;
|
||||||
|
|
||||||
const board = [mission1];
|
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
|
// Mission without expiresIn should be preserved
|
||||||
expect(refreshed.find(m => m.id === mission1.id)).to.exist;
|
expect(refreshed.find((m) => m.id === mission1.id)).to.exist;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -365,8 +526,9 @@ describe("Systems: MissionGenerator", function () {
|
||||||
for (let i = 0; i < 20 && !foundNonAssassination; i++) {
|
for (let i = 0; i < 20 && !foundNonAssassination; i++) {
|
||||||
const mission = MissionGenerator.generateSideOp(3, unlockedRegions, []);
|
const mission = MissionGenerator.generateSideOp(3, unlockedRegions, []);
|
||||||
const currency = mission.rewards.guaranteed.currency.aether_shards;
|
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) {
|
if (!isAssassination) {
|
||||||
foundNonAssassination = true;
|
foundNonAssassination = true;
|
||||||
expect(currency).to.be.at.least(100); // 50 * 2.5 * 0.8 = 100
|
expect(currency).to.be.at.least(100); // 50 * 2.5 * 0.8 = 100
|
||||||
|
|
@ -389,7 +551,8 @@ describe("Systems: MissionGenerator", function () {
|
||||||
const mission = MissionGenerator.generateSideOp(2, unlockedRegions, []);
|
const mission = MissionGenerator.generateSideOp(2, unlockedRegions, []);
|
||||||
if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") {
|
if (mission.objectives.primary[0].type === "ELIMINATE_UNIT") {
|
||||||
foundAssassination = true;
|
foundAssassination = true;
|
||||||
assassinationCurrency = mission.rewards.guaranteed.currency.aether_shards;
|
assassinationCurrency =
|
||||||
|
mission.rewards.guaranteed.currency.aether_shards;
|
||||||
} else {
|
} else {
|
||||||
otherCurrency = mission.rewards.guaranteed.currency.aether_shards;
|
otherCurrency = mission.rewards.guaranteed.currency.aether_shards;
|
||||||
}
|
}
|
||||||
|
|
@ -406,15 +569,111 @@ describe("Systems: MissionGenerator", function () {
|
||||||
let foundItem = false;
|
let foundItem = false;
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
const mission = MissionGenerator.generateSideOp(5, unlockedRegions, []);
|
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;
|
foundItem = true;
|
||||||
expect(mission.rewards.guaranteed.items[0]).to.match(/^ITEM_/);
|
expect(mission.rewards.guaranteed.items[0]).to.match(/^ITEM_/);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Tier 5 has 100% chance (5 * 0.2), so should always find one
|
// 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