Skip to main content

rpfm_extensions/startpos/
missions.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Mission scripts of a campaign (`victory_objectives.txt`, `missions.txt`) and the victory objectives they define.
12//!
13//! A script is a list of statements: words ended by `;` (`total 80;`), or words followed by a block of statements
14//! (`objective { ... }`). `//` starts a comment that runs to the end of the line. [`write_victory_objectives`] writes
15//! victory objectives back as a script.
16
17use getset::{CopyGetters, Getters};
18
19use rpfm_lib::error::{RLibError, Result};
20
21#[cfg(test)] #[path = "missions_test.rs"] mod missions_test;
22
23//---------------------------------------------------------------------------//
24//                              Types
25//---------------------------------------------------------------------------//
26
27/// A statement of a mission script.
28#[derive(Debug, Clone, PartialEq, Eq, Default, Getters)]
29#[getset(get = "pub")]
30pub struct ScriptItem {
31
32    /// Words of the statement: its key, then its values.
33    words: Vec<String>,
34
35    /// Statements of its block, if it has one.
36    block: Option<Vec<ScriptItem>>,
37}
38
39/// The victory missions of a faction in a kind of campaign (a `victory_objective` block).
40#[derive(Debug, Clone, PartialEq, Getters, CopyGetters)]
41pub struct VictoryObjectives {
42
43    /// Faction key.
44    #[getset(get = "pub")]
45    faction: String,
46
47    /// Kind of campaign the missions are for.
48    #[getset(get_copy = "pub")]
49    campaign_type: CampaignType,
50
51    /// Missions, in script order.
52    #[getset(get = "pub")]
53    missions: Vec<VictoryMission>,
54}
55
56/// Kinds of campaign with their own victory missions.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CampaignType {
59
60    /// Single player campaigns (`sp_normal`).
61    SinglePlayer,
62
63    /// Multiplayer campaigns against other players (`mp_vs_normal`).
64    MultiplayerVersus,
65
66    /// Cooperative multiplayer campaigns (`mp_co_op_normal`).
67    MultiplayerCoop,
68}
69
70/// A victory mission. Completing its objectives wins the game.
71#[derive(Debug, Clone, PartialEq, Getters)]
72#[getset(get = "pub")]
73pub struct VictoryMission {
74
75    /// Mission key.
76    key: String,
77
78    /// Kind of victory (`att_vc_1_short`...).
79    victory_type: String,
80
81    /// Who gives the mission (`CLAN_ELDERS`...).
82    issuer: String,
83
84    /// Objectives, in script order.
85    objectives: Vec<MissionObjective>,
86}
87
88/// An objective of a mission, by its script `type`. `total` is the amount the objective asks for.
89#[derive(Debug, Clone, PartialEq)]
90pub enum MissionObjective {
91
92    /// Reach a date (`year`, `week_of_year`).
93    ReachSpecifiedDate { year: u32, week: u32 },
94
95    /// Control a number of regions among some (`region`).
96    ControlNRegionsIncluding { total: u32, regions: Vec<String> },
97
98    /// Control a number of provinces among some (`province`).
99    ControlNProvincesIncluding { total: u32, provinces: Vec<String> },
100
101    /// Research a number of technologies of some categories (`technology_category`), with an optional text
102    /// (`override_text`).
103    ResearchNTechsOfTypeX { total: u32, categories: Vec<String>, text: Option<String> },
104
105    /// Earn an amount of money from buildings.
106    EarnXAmountFromBuildingWealth { total: u32 },
107
108    /// Earn an amount of money from raiding.
109    EarnXAmountFromRaiding { total: u32 },
110
111    /// Own a number of units (`OWN_N_UNITS`), or of naval units (`OWN_N_NAVAL_UNITS`).
112    OwnNUnits { total: u32, naval: bool },
113
114    /// Own a number of provinces where the faction's culture dominates.
115    OwnNProvincesWithCulturalDominance { total: u32 },
116
117    /// Construct a number of buildings among some (`building_level`), for a faction.
118    ConstructNBuildingsIncluding { total: u32, buildings: Vec<String>, faction: String },
119
120    /// Subjugate a number of factions.
121    SubjugateFactions { total: u32 },
122
123    /// Keep a number of client states.
124    MaintainNClientStates { total: u32 },
125
126    /// Loot or sack a number of settlements (`region` lists some; the game doesn't keep them).
127    LootOrSackNDifferentSettlementsIncluding { total: u32, regions: Vec<String> },
128
129    /// Raze or sack a number of settlements (`region` lists some; the game doesn't keep them).
130    RazeOrSackNDifferentSettlementsIncluding { total: u32, regions: Vec<String> },
131
132    /// Have a religion at a percentage in every province.
133    AtLeastXReligionInAllProvinces { total: f32, religion: String },
134
135    /// Reclaim a number of settlements among some (`region`).
136    ReclaimNDifferentSettlementsIncluding { total: u32, regions: Vec<String> },
137
138    /// Leave some factions (`faction`) without armies.
139    EnsureFactionsHaveNoMilitaryPresence { factions: Vec<String> },
140
141    /// Have public order at a level in some provinces (`province`).
142    AtLeastXPublicOrderInProvinces { total: f32, provinces: Vec<String> },
143
144    /// Stay a horde.
145    StayHorde,
146
147    /// Keep a number of alliances.
148    MaintainNAlliances { total: u32 },
149
150    /// Have an income of at least an amount (`income`).
151    IncomeAtLeastX { income: u32 },
152
153    /// Kill a faction's leader in battle (`faction`).
154    EliminateCharacterInBattle { faction: String },
155
156    /// Confederate a number of factions.
157    ConfederateFactions { total: u32 },
158
159    /// Reach a fame level (`fame_level`).
160    AttainFameLevel { level: u32 },
161}
162
163//---------------------------------------------------------------------------//
164//                              Implementations
165//---------------------------------------------------------------------------//
166
167impl ScriptItem {
168
169    /// Returns the key of the statement (its first word).
170    pub fn key(&self) -> &str {
171        self.words.first().map(String::as_str).unwrap_or_default()
172    }
173
174    /// Returns the values of the statement (the words after its key).
175    pub fn values(&self) -> &[String] {
176        self.words.get(1..).unwrap_or_default()
177    }
178
179    /// Returns the statements of the block, or none for statements without one.
180    pub fn children(&self) -> &[ScriptItem] {
181        self.block.as_deref().unwrap_or_default()
182    }
183
184    /// Returns the first value of each child statement with a key, in order.
185    pub fn child_values(&self, key: &str) -> Vec<String> {
186        self.children().iter()
187            .filter(|child| child.key() == key)
188            .filter_map(|child| child.values().first().cloned())
189            .collect()
190    }
191
192    /// Returns the first value of the first child statement with a key.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if there's no such statement, or it has no value.
197    pub fn child_value(&self, key: &str) -> Result<String> {
198        self.child_values(key).into_iter().next().ok_or_else(|| script_error(format!("{} lacks {key}", self.key())))
199    }
200
201    /// Returns the first value of the first child statement with a key, as a number.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if there's no such statement, or its value isn't a number.
206    pub fn child_number<T: std::str::FromStr>(&self, key: &str) -> Result<T> {
207        let value = self.child_value(key)?;
208        value.parse().map_err(|_| script_error(format!("{key} of {} isn't a number: {value}", self.key())))
209    }
210}
211
212impl VictoryObjectives {
213
214    /// Creates the victory objectives of a faction for a kind of campaign.
215    pub fn new(faction: String, campaign_type: CampaignType, missions: Vec<VictoryMission>) -> Self {
216        Self { faction, campaign_type, missions }
217    }
218}
219
220impl VictoryMission {
221
222    /// Creates a victory mission.
223    pub fn new(key: String, victory_type: String, issuer: String, objectives: Vec<MissionObjective>) -> Self {
224        Self { key, victory_type, issuer, objectives }
225    }
226}
227
228impl CampaignType {
229
230    /// Every campaign type.
231    const ALL: [Self; 3] = [Self::SinglePlayer, Self::MultiplayerVersus, Self::MultiplayerCoop];
232
233    /// Returns the campaign type with an id, as `VICTORY_MISSIONS_BY_TYPE_BLOCK` stores it.
234    pub fn from_id(id: u32) -> Option<Self> {
235        Self::ALL.into_iter().find(|campaign_type| campaign_type.id() == id)
236    }
237
238    /// Returns the script key of the campaign type.
239    pub fn key(&self) -> &'static str {
240        match self {
241            Self::SinglePlayer => "sp_normal",
242            Self::MultiplayerVersus => "mp_vs_normal",
243            Self::MultiplayerCoop => "mp_co_op_normal",
244        }
245    }
246
247    /// Returns the id of the campaign type, as `VICTORY_MISSIONS_BY_TYPE_BLOCK` stores it.
248    pub fn id(&self) -> u32 {
249        match self {
250            Self::SinglePlayer => 0,
251            Self::MultiplayerVersus => 2,
252            Self::MultiplayerCoop => 4,
253        }
254    }
255
256    /// Returns the campaign type of a script key (`sp_normal`, `mp_vs_normal`, `mp_co_op_normal`).
257    ///
258    /// # Errors
259    ///
260    /// Returns an error for unknown keys.
261    pub fn from_key(key: &str) -> Result<Self> {
262        Self::ALL.into_iter()
263            .find(|campaign_type| campaign_type.key() == key)
264            .ok_or_else(|| script_error(format!("unknown campaign type {key}")))
265    }
266}
267
268impl MissionObjective {
269
270    /// Returns the statements of the objective's script block, as (key, value) pairs in the order
271    /// [`Self::from_item`] reads them.
272    pub fn statements(&self) -> Vec<(&'static str, String)> {
273        let mut statements = vec![("type", self.type_key().to_owned())];
274        let mut add = |key: &'static str, values: &[String]| statements.extend(values.iter().map(|value| (key, value.to_owned())));
275        match self {
276            Self::ReachSpecifiedDate { year, week } => {
277                add("year", &[year.to_string()]);
278                add("week_of_year", &[week.to_string()]);
279            },
280            Self::ControlNRegionsIncluding { total, regions } |
281            Self::LootOrSackNDifferentSettlementsIncluding { total, regions } |
282            Self::RazeOrSackNDifferentSettlementsIncluding { total, regions } |
283            Self::ReclaimNDifferentSettlementsIncluding { total, regions } => {
284                add("total", &[total.to_string()]);
285                add("region", regions);
286            },
287            Self::ControlNProvincesIncluding { total, provinces } => {
288                add("total", &[total.to_string()]);
289                add("province", provinces);
290            },
291            Self::ResearchNTechsOfTypeX { total, categories, text } => {
292                add("override_text", text.as_slice());
293                add("total", &[total.to_string()]);
294                add("technology_category", categories);
295            },
296            Self::EarnXAmountFromBuildingWealth { total } |
297            Self::EarnXAmountFromRaiding { total } |
298            Self::OwnNUnits { total, .. } |
299            Self::OwnNProvincesWithCulturalDominance { total } |
300            Self::SubjugateFactions { total } |
301            Self::MaintainNClientStates { total } |
302            Self::MaintainNAlliances { total } |
303            Self::ConfederateFactions { total } => add("total", &[total.to_string()]),
304            Self::ConstructNBuildingsIncluding { total, buildings, faction } => {
305                add("total", &[total.to_string()]);
306                add("building_level", buildings);
307                add("faction", std::slice::from_ref(faction));
308            },
309            Self::AtLeastXReligionInAllProvinces { total, religion } => {
310                add("total", &[total.to_string()]);
311                add("religion", std::slice::from_ref(religion));
312            },
313            Self::EnsureFactionsHaveNoMilitaryPresence { factions } => add("faction", factions),
314            Self::AtLeastXPublicOrderInProvinces { total, provinces } => {
315                add("total", &[total.to_string()]);
316                add("province", provinces);
317            },
318            Self::StayHorde => {},
319            Self::IncomeAtLeastX { income } => add("income", &[income.to_string()]),
320            Self::EliminateCharacterInBattle { faction } => add("faction", std::slice::from_ref(faction)),
321            Self::AttainFameLevel { level } => add("fame_level", &[level.to_string()]),
322        }
323        statements
324    }
325
326    /// Returns the script key of the objective's type.
327    pub fn type_key(&self) -> &'static str {
328        match self {
329            Self::ReachSpecifiedDate { .. } => "REACH_SPECIFIED_DATE",
330            Self::ControlNRegionsIncluding { .. } => "CONTROL_N_REGIONS_INCLUDING",
331            Self::ControlNProvincesIncluding { .. } => "CONTROL_N_PROVINCES_INCLUDING",
332            Self::ResearchNTechsOfTypeX { .. } => "RESEARCH_N_TECHS_OF_TYPE_X",
333            Self::EarnXAmountFromBuildingWealth { .. } => "EARN_X_AMOUNT_FROM_BUILDING_WEALTH",
334            Self::EarnXAmountFromRaiding { .. } => "EARN_X_AMOUNT_FROM_RAIDING",
335            Self::OwnNUnits { naval: false, .. } => "OWN_N_UNITS",
336            Self::OwnNUnits { naval: true, .. } => "OWN_N_NAVAL_UNITS",
337            Self::OwnNProvincesWithCulturalDominance { .. } => "OWN_N_PROVINCES_WITH_CULTURAL_DOMINANCE",
338            Self::ConstructNBuildingsIncluding { .. } => "CONSTRUCT_N_BUILDINGS_INCLUDING",
339            Self::SubjugateFactions { .. } => "SUBJUGATE_FACTIONS",
340            Self::MaintainNClientStates { .. } => "MAINTAIN_N_CLIENT_STATES",
341            Self::LootOrSackNDifferentSettlementsIncluding { .. } => "LOOT_OR_SACK_N_DIFFERENT_SETTLEMENTS_INCLUDING",
342            Self::RazeOrSackNDifferentSettlementsIncluding { .. } => "RAZE_OR_SACK_N_DIFFERENT_SETTLEMENTS_INCLUDING",
343            Self::AtLeastXReligionInAllProvinces { .. } => "AT_LEAST_X_RELIGION_IN_ALL_PROVINCES",
344            Self::ReclaimNDifferentSettlementsIncluding { .. } => "RECLAIM_N_DIFFERENT_SETTLEMENTS_INCLUDING",
345            Self::EnsureFactionsHaveNoMilitaryPresence { .. } => "ENSURE_FACTIONS_HAVE_NO_MILITARY_PRESENCE",
346            Self::AtLeastXPublicOrderInProvinces { .. } => "AT_LEAST_X_PUBLIC_ORDER_IN_PROVINCES",
347            Self::StayHorde => "STAY_HORDE",
348            Self::MaintainNAlliances { .. } => "MAINTAIN_N_ALLIANCES",
349            Self::IncomeAtLeastX { .. } => "INCOME_AT_LEAST_X",
350            Self::EliminateCharacterInBattle { .. } => "ELIMINATE_CHARACTER_IN_BATTLE",
351            Self::ConfederateFactions { .. } => "CONFEDERATE_FACTIONS",
352            Self::AttainFameLevel { .. } => "ATTAIN_FAME_LEVEL",
353        }
354    }
355
356    /// Reads an objective from its script block.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if the objective's type is unsupported, or a value it needs is missing or malformed.
361    pub fn from_item(item: &ScriptItem) -> Result<Self> {
362        let total = || item.child_number::<u32>("total");
363        Ok(match item.child_value("type")?.as_str() {
364            "REACH_SPECIFIED_DATE" => Self::ReachSpecifiedDate { year: item.child_number("year")?, week: item.child_number("week_of_year")? },
365            "CONTROL_N_REGIONS_INCLUDING" => Self::ControlNRegionsIncluding { total: total()?, regions: item.child_values("region") },
366            "CONTROL_N_PROVINCES_INCLUDING" => Self::ControlNProvincesIncluding { total: total()?, provinces: item.child_values("province") },
367            "RESEARCH_N_TECHS_OF_TYPE_X" => Self::ResearchNTechsOfTypeX {
368                total: total()?,
369                categories: item.child_values("technology_category"),
370                text: item.child_values("override_text").into_iter().next(),
371            },
372            "EARN_X_AMOUNT_FROM_BUILDING_WEALTH" => Self::EarnXAmountFromBuildingWealth { total: total()? },
373            "EARN_X_AMOUNT_FROM_RAIDING" => Self::EarnXAmountFromRaiding { total: total()? },
374            "OWN_N_UNITS" => Self::OwnNUnits { total: total()?, naval: false },
375            "OWN_N_NAVAL_UNITS" => Self::OwnNUnits { total: total()?, naval: true },
376            "OWN_N_PROVINCES_WITH_CULTURAL_DOMINANCE" => Self::OwnNProvincesWithCulturalDominance { total: total()? },
377            "CONSTRUCT_N_BUILDINGS_INCLUDING" => Self::ConstructNBuildingsIncluding {
378                total: total()?,
379                buildings: item.child_values("building_level"),
380                faction: item.child_value("faction")?,
381            },
382            "SUBJUGATE_FACTIONS" => Self::SubjugateFactions { total: total()? },
383            "MAINTAIN_N_CLIENT_STATES" => Self::MaintainNClientStates { total: total()? },
384            "LOOT_OR_SACK_N_DIFFERENT_SETTLEMENTS_INCLUDING" => Self::LootOrSackNDifferentSettlementsIncluding { total: total()?, regions: item.child_values("region") },
385            "RAZE_OR_SACK_N_DIFFERENT_SETTLEMENTS_INCLUDING" => Self::RazeOrSackNDifferentSettlementsIncluding { total: total()?, regions: item.child_values("region") },
386            "AT_LEAST_X_RELIGION_IN_ALL_PROVINCES" => Self::AtLeastXReligionInAllProvinces { total: item.child_number("total")?, religion: item.child_value("religion")? },
387            "RECLAIM_N_DIFFERENT_SETTLEMENTS_INCLUDING" => Self::ReclaimNDifferentSettlementsIncluding { total: total()?, regions: item.child_values("region") },
388            "ENSURE_FACTIONS_HAVE_NO_MILITARY_PRESENCE" => Self::EnsureFactionsHaveNoMilitaryPresence { factions: item.child_values("faction") },
389            "AT_LEAST_X_PUBLIC_ORDER_IN_PROVINCES" => Self::AtLeastXPublicOrderInProvinces { total: item.child_number("total")?, provinces: item.child_values("province") },
390            "STAY_HORDE" => Self::StayHorde,
391            "MAINTAIN_N_ALLIANCES" => Self::MaintainNAlliances { total: total()? },
392            "INCOME_AT_LEAST_X" => Self::IncomeAtLeastX { income: item.child_number("income")? },
393            "ELIMINATE_CHARACTER_IN_BATTLE" => Self::EliminateCharacterInBattle { faction: item.child_value("faction")? },
394            "CONFEDERATE_FACTIONS" => Self::ConfederateFactions { total: total()? },
395            "ATTAIN_FAME_LEVEL" => Self::AttainFameLevel { level: item.child_number("fame_level")? },
396            other => return Err(script_error(format!("unsupported objective type {other}"))),
397        })
398    }
399}
400
401/// Parses a mission script into its statements.
402///
403/// # Errors
404///
405/// Returns an error if a block isn't closed, or a `}` closes nothing.
406pub fn parse_script(text: &str) -> Result<Vec<ScriptItem>> {
407    let mut tokens = vec![];
408    for line in text.lines() {
409        let line = line.split("//").next().unwrap_or_default();
410        for word in line.replace('{', " { ").replace('}', " } ").replace(';', " ; ").split_whitespace() {
411            tokens.push(word.to_owned());
412        }
413    }
414
415    parse_block(&mut tokens.into_iter(), false)
416}
417
418/// Parses the victory objectives of a `victory_objectives.txt` script.
419///
420/// # Errors
421///
422/// Returns an error if the script is malformed, or uses an objective or payload that isn't supported.
423pub fn parse_victory_objectives(text: &str) -> Result<Vec<VictoryObjectives>> {
424    let script = parse_script(text)?;
425    let mut objectives = vec![];
426    for block in script.iter().filter(|item| item.key() == "victory_objectives").flat_map(ScriptItem::children) {
427        if block.key() != "victory_objective" {
428            continue;
429        }
430
431        let mut missions = vec![];
432        for mission in block.children().iter().filter(|item| item.key() == "mission") {
433            let primary = mission.children().iter()
434                .find(|item| item.key() == "primary_objectives_and_payload")
435                .ok_or_else(|| script_error(format!("mission {} has no primary objectives", mission.child_value("key").unwrap_or_default())))?;
436            for payload in primary.children().iter().filter(|item| item.key() == "payload").flat_map(ScriptItem::children) {
437                if payload.key() != "game_victory" {
438                    return Err(script_error(format!("unsupported victory payload {}", payload.key())));
439                }
440            }
441
442            missions.push(VictoryMission {
443                key: mission.child_value("key")?,
444                victory_type: mission.child_value("victory_type")?,
445                issuer: mission.child_value("issuer")?,
446                objectives: primary.children().iter().filter(|item| item.key() == "objective").map(MissionObjective::from_item).collect::<Result<_>>()?,
447            });
448        }
449
450        objectives.push(VictoryObjectives {
451            faction: block.child_value("faction")?,
452            campaign_type: CampaignType::from_key(&block.child_value("campaign_type")?)?,
453            missions,
454        });
455    }
456    Ok(objectives)
457}
458
459/// Parses statements until the end of the current block (or of the script, at the top level).
460fn parse_block(tokens: &mut impl Iterator<Item = String>, nested: bool) -> Result<Vec<ScriptItem>> {
461    let mut items = vec![];
462    let mut words = vec![];
463    while let Some(token) = tokens.next() {
464        match token.as_str() {
465            ";" => items.push(ScriptItem { words: std::mem::take(&mut words), block: None }),
466            "{" => {
467                let block = parse_block(tokens, true)?;
468                items.push(ScriptItem { words: std::mem::take(&mut words), block: Some(block) });
469            },
470            "}" if nested => return Ok(items),
471            "}" => return Err(script_error("a } closes no block".to_owned())),
472            _ => words.push(token),
473        }
474    }
475    if nested {
476        return Err(script_error("a block isn't closed".to_owned()));
477    }
478    Ok(items)
479}
480
481/// Writes victory objectives as a `victory_objectives.txt` script, which [`parse_victory_objectives`] reads back.
482///
483/// # Arguments
484///
485/// * `objectives` - The victory objectives of each faction and kind of campaign.
486///
487/// # Returns
488///
489/// The script, with tabs for indentation.
490pub fn write_victory_objectives(objectives: &[VictoryObjectives]) -> String {
491    let mut script = String::from("victory_objectives\n{\n");
492    for faction_objectives in objectives {
493        script.push_str("\tvictory_objective\n\t{\n");
494        script.push_str(&format!("\t\tfaction {};\n\t\tcampaign_type {};\n", faction_objectives.faction, faction_objectives.campaign_type.key()));
495        for mission in &faction_objectives.missions {
496            script.push_str("\n\t\tmission\n\t\t{\n");
497            script.push_str(&format!("\t\t\tvictory_type {};\n\t\t\tkey {};\n\t\t\tissuer {};\n", mission.victory_type, mission.key, mission.issuer));
498            script.push_str("\t\t\tprimary_objectives_and_payload\n\t\t\t{\n");
499            for objective in &mission.objectives {
500                script.push_str("\t\t\t\tobjective\n\t\t\t\t{\n");
501                for (key, value) in objective.statements() {
502                    script.push_str(&format!("\t\t\t\t\t{key} {value};\n"));
503                }
504                script.push_str("\t\t\t\t}\n");
505            }
506            script.push_str("\t\t\t\tpayload\n\t\t\t\t{\n\t\t\t\t\tgame_victory;\n\t\t\t\t}\n");
507            script.push_str("\t\t\t}\n\t\t}\n");
508        }
509        script.push_str("\t}\n");
510    }
511    script.push_str("}\n");
512    script
513}
514
515/// Builds a script error.
516fn script_error(message: String) -> RLibError {
517    RLibError::StartposScriptError(message)
518}