feat: Implement core game loop, state management, and mission system with initial mission data.

This commit is contained in:
Matthew Mone 2026-01-02 15:25:26 -08:00
parent aeace34d05
commit 964a12fa47
50 changed files with 4126 additions and 1356 deletions

71
specs/Mission_flow.md Normal file
View 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;
```

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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,9 +36,7 @@
}, },
"rewards": { "rewards": {
"guaranteed": { "guaranteed": {
"unlocks": [ "unlocks": ["CLASS_TINKER", "MISSION_ACT1_02"],
"CLASS_TINKER"
],
"currency": { "currency": {
"aether_shards": 100 "aether_shards": 100
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -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,9 +48,7 @@
}, },
"rewards": { "rewards": {
"guaranteed": { "guaranteed": {
"unlocks": [ "unlocks": ["CLASS_BATTLE_MAGE", "MISSION_STORY_05"],
"CLASS_BATTLE_MAGE"
],
"currency": { "currency": {
"ancient_cores": 1 "ancient_cores": 1
} }

View file

@ -33,7 +33,8 @@
"guaranteed": { "guaranteed": {
"currency": { "currency": {
"aether_shards": 500 "aether_shards": 500
} },
"unlocks": ["MISSION_STORY_06"]
} }
} }
} }

View file

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

View file

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

View file

@ -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,12 +31,11 @@
}, },
"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

View file

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

View file

@ -30,9 +30,8 @@
}, },
"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

View file

@ -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,17 +25,15 @@
}, },
{ {
"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

View file

@ -30,9 +30,7 @@
}, },
"rewards": { "rewards": {
"guaranteed": { "guaranteed": {
"unlocks": [ "unlocks": ["CLASS_WARLORD", "MISSION_STORY_19"]
"MASTERY_VANGUARD"
]
}, },
"faction_reputation": { "faction_reputation": {
"IRON_LEGION": 75 "IRON_LEGION": 75

View file

@ -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,9 +41,8 @@
}, },
"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

View file

@ -39,7 +39,8 @@
"guaranteed": { "guaranteed": {
"currency": { "currency": {
"aether_shards": 700 "aether_shards": 700
} },
"unlocks": ["MISSION_STORY_15"]
}, },
"faction_reputation": { "faction_reputation": {
"GOLDEN_EXCHANGE": 45 "GOLDEN_EXCHANGE": 45

View file

@ -30,9 +30,8 @@
}, },
"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

View file

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

View file

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

View file

@ -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,9 +28,7 @@
}, },
"rewards": { "rewards": {
"guaranteed": { "guaranteed": {
"unlocks": [ "unlocks": ["CLASS_CUSTODIAN_MASTERY", "MISSION_STORY_19"]
"MASTERY_CUSTODIAN"
]
}, },
"faction_reputation": { "faction_reputation": {
"SILENT_SANCTUARY": 75 "SILENT_SANCTUARY": 75

View file

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

View file

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

View file

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

View file

@ -27,9 +27,8 @@
}, },
"rewards": { "rewards": {
"guaranteed": { "guaranteed": {
"items": [ "items": ["ITEM_CORRUPTED_IDOL"],
"ITEM_CORRUPTED_IDOL" "unlocks": ["MISSION_STORY_23"]
]
} }
} }
} }

View file

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

View file

@ -31,7 +31,8 @@
"guaranteed": { "guaranteed": {
"currency": { "currency": {
"ancient_cores": 3 "ancient_cores": 3
} },
"unlocks": ["MISSION_STORY_25"]
} }
} }
} }

View file

@ -31,9 +31,7 @@
}, },
"rewards": { "rewards": {
"guaranteed": { "guaranteed": {
"unlocks": [ "unlocks": ["ACCESS_ACT_4", "MISSION_STORY_26"]
"ACCESS_ACT_4"
]
} }
} }
} }

View file

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

View file

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

View file

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

View file

@ -26,9 +26,8 @@
}, },
"rewards": { "rewards": {
"guaranteed": { "guaranteed": {
"items": [ "items": ["ITEM_VOID_ESSENCE"],
"ITEM_VOID_ESSENCE" "unlocks": ["MISSION_STORY_30"]
]
} }
} }
} }

View file

@ -32,7 +32,8 @@
"guaranteed": { "guaranteed": {
"currency": { "currency": {
"ancient_cores": 5 "ancient_cores": 5
} },
"unlocks": ["MISSION_STORY_31"]
} }
} }
} }

View file

@ -34,9 +34,7 @@
}, },
"rewards": { "rewards": {
"guaranteed": { "guaranteed": {
"unlocks": [ "unlocks": ["LIMIT_BREAK_MAX_LEVEL", "MISSION_STORY_32"]
"LIMIT_BREAK_MAX_LEVEL"
]
} }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -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);
@ -2224,7 +2328,7 @@ export class GameLoop {
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);
@ -2239,7 +2343,9 @@ export class GameLoop {
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;
} }
@ -2266,7 +2372,7 @@ export class GameLoop {
this.missionManager.onGameEvent("INTERACT", { this.missionManager.onGameEvent("INTERACT", {
objectId: objectId, objectId: objectId,
unitId: unit.id, unitId: unit.id,
position: unitPos position: unitPos,
}); });
} }
@ -2490,14 +2596,18 @@ export class GameLoop {
// 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;
@ -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,
@ -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

View file

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

View file

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

View file

@ -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;
@ -154,4 +158,3 @@ export function cleanupTurnSystem(gameLoop) {
} }
} }
} }

View file

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

View file

@ -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,7 +526,8 @@ 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;
@ -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");
}); });
}); });
}); });