Skip to main content

rpfm_extensions/startpos/world/
mod.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//! World stage of the startpos build: the content of `CAMPAIGN_MODEL/WORLD` (factions, characters, armies,
12//! regions, provinces and sea regions) derived from the `start_pos_*` tables, the game tables and the map.
13//!
14//! The rules were reverse-engineered from game-built startposes of Attila's main campaign. The generators here
15//! return keys and table values, not the startpos's runtime ids: ids are global counters assigned when the
16//! records are written. Where the game draws from its RNG or stores hash-ordered lists, the generators make a
17//! deterministic choice that gives a compatible startpos.
18//!
19//! # Submodules
20//!
21//! - [`agents`]: agent abilities, attributes, skill sets and recruitment pools.
22//! - [`availability`]: the building chains each faction can have.
23//! - [`bodyguards`]: the default general unit of each faction.
24//! - [`calendar`]: the campaign's start date and turns per year.
25//! - [`characters`]: per-character values (birth year, loyalty, faction leader, map position).
26//! - [`diplomacy`]: diplomatic relationships between factions.
27//! - [`factions`]: per-faction values (capital, subjects, victory regions, flags and colours...).
28//! - [`garrisons`]: the garrison army and navy of each settlement.
29//! - [`government`]: government posts and political parties.
30//! - [`legacy`]: army legacies (names, emblems, numbering).
31//! - [`pools`]: mercenary and levy pools.
32//! - [`provinces`]: per-faction province managers and sea regions.
33//! - [`rebels`]: the rebel setup of each region.
34//! - [`writer`]: the writer of the `WORLD` record, from the generators above and the record layouts.
35
36use getset::{CopyGetters, Getters};
37
38use std::collections::{HashMap, HashSet};
39
40use rpfm_lib::error::{RLibError, Result};
41
42use super::tables::{Row, StartposTables};
43use self::availability::available_chains;
44
45pub mod agents;
46pub mod availability;
47pub mod bodyguards;
48pub mod calendar;
49pub mod characters;
50pub mod diplomacy;
51pub mod factions;
52pub mod garrisons;
53pub mod government;
54pub mod legacy;
55pub mod pools;
56pub mod provinces;
57pub mod rebels;
58pub mod writer;
59
60#[cfg(test)] pub(crate) mod world_test;
61
62/// Relationship of married characters in `start_pos_family_relationships`.
63const SPOUSE_RELATIONSHIP: &str = "spouse";
64
65/// `start_pos_characters` name columns, by the kind the startpos stores with each name. A name's kind must be its
66/// `names.type`.
67pub const NAME_COLUMNS: [&str; 4] = ["Name", "Surname", "clan_name", "other_name"];
68
69/// Building instance (`building_levels.building_instance_key`) of the primary buildings of major settlements.
70const MAJOR_SETTLEMENT_INSTANCE: &str = "att_city_major";
71
72//---------------------------------------------------------------------------//
73//                              Types
74//---------------------------------------------------------------------------//
75
76/// The `start_pos_*` rows of one campaign, with their references resolved to keys.
77#[derive(Debug, Clone, Default, Getters)]
78#[getset(get = "pub")]
79pub struct CampaignIndex {
80
81    /// Campaign key.
82    campaign: String,
83
84    /// Factions of the campaign, in `start_pos_factions` order.
85    factions: Vec<FactionEntry>,
86
87    /// Regions of the campaign, in `start_pos_regions` order.
88    regions: Vec<RegionEntry>,
89
90    /// Settlements of the campaign's regions, in `start_pos_settlements` order.
91    settlements: Vec<SettlementEntry>,
92
93    /// Characters of the campaign's factions, in `start_pos_characters` order.
94    characters: Vec<CharacterEntry>,
95
96    /// Positions in `factions`, by faction key.
97    #[getset(skip)]
98    faction_positions: HashMap<String, usize>,
99
100    /// Positions in `regions`, by region key.
101    #[getset(skip)]
102    region_positions: HashMap<String, usize>,
103}
104
105/// A faction of the campaign (`start_pos_factions` plus its `factions` and `cultures_subcultures` data).
106#[derive(Debug, Clone, PartialEq, Default, Getters, CopyGetters)]
107pub struct FactionEntry {
108
109    /// Row id in `start_pos_factions`.
110    #[getset(get = "pub")]
111    id: String,
112
113    /// Faction key.
114    #[getset(get = "pub")]
115    key: String,
116
117    /// The faction can be picked by players.
118    #[getset(get_copy = "pub")]
119    playable: bool,
120
121    /// Sorting value of the faction list.
122    #[getset(get_copy = "pub")]
123    starting_order: i64,
124
125    /// Subculture key.
126    #[getset(get = "pub")]
127    subculture: String,
128
129    /// Culture key.
130    #[getset(get = "pub")]
131    culture: String,
132
133    /// Military group, used for unit permissions.
134    #[getset(get = "pub")]
135    military_group: String,
136}
137
138/// A region of the campaign (`start_pos_regions`).
139#[derive(Debug, Clone, PartialEq, Default, Getters, CopyGetters)]
140pub struct RegionEntry {
141
142    /// Row id in `start_pos_regions`.
143    #[getset(get = "pub")]
144    id: String,
145
146    /// Region key.
147    #[getset(get = "pub")]
148    key: String,
149
150    /// Owner faction key. `None` for regions owned by the rebels.
151    #[getset(get = "pub")]
152    owner: Option<String>,
153
154    /// The region is flagged as a faction capital.
155    #[getset(get_copy = "pub")]
156    capital_flag: bool,
157}
158
159/// A settlement of the campaign (`start_pos_settlements`).
160#[derive(Debug, Clone, PartialEq, Default, Getters)]
161#[getset(get = "pub")]
162pub struct SettlementEntry {
163
164    /// Row id in `start_pos_settlements`.
165    id: String,
166
167    /// Region key.
168    region: String,
169
170    /// Primary building level key.
171    primary_building: String,
172
173    /// Port building level key, empty without port.
174    port_building: String,
175
176    /// Other building level keys (`building1` to `building5`), without the empty ones.
177    buildings: Vec<String>,
178
179    /// Slave points the settlement starts with.
180    slave_points: i64,
181
182    /// The primary building is a major settlement one.
183    #[getset(skip)]
184    major: bool,
185}
186
187/// A character of the campaign (`start_pos_characters`).
188#[derive(Debug, Clone, PartialEq, Default, Getters, CopyGetters)]
189pub struct CharacterEntry {
190
191    /// Row id in `start_pos_characters`.
192    #[getset(get = "pub")]
193    id: String,
194
195    /// Faction key.
196    #[getset(get = "pub")]
197    faction: String,
198
199    /// Agent type (`general`, `spy`...).
200    #[getset(get = "pub")]
201    agent: String,
202
203    /// Age, in years.
204    #[getset(get_copy = "pub")]
205    age: i64,
206
207    /// Names, in [`NAME_COLUMNS`] order. Names whose `names.type` isn't their column's kind are empty: the game drops
208    /// them.
209    #[getset(get = "pub")]
210    names: Vec<String>,
211
212    /// Starting cell column (`startx`), 0 if not set.
213    #[getset(get_copy = "pub")]
214    start_x: u16,
215
216    /// Starting cell row (`starty`), 0 if not set.
217    #[getset(get_copy = "pub")]
218    start_y: u16,
219
220    /// Ministerial position key, empty if none.
221    #[getset(get = "pub")]
222    ministerial_position: String,
223
224    /// Loyalty, as written in the table.
225    #[getset(get_copy = "pub")]
226    loyalty: i64,
227
228    /// Death type, empty for living characters.
229    #[getset(get = "pub")]
230    death_type: String,
231
232    /// The character starts its faction's family tree.
233    #[getset(get_copy = "pub")]
234    progenitor: bool,
235
236    /// Unit of the character's bodyguard, overriding the faction's default. Empty for the default.
237    #[getset(get = "pub")]
238    override_general_unit: String,
239
240    /// The character is alive and married to a dead character.
241    #[getset(get_copy = "pub")]
242    widowed: bool,
243
244    /// Faction of the character's husband, for women married to a man of another faction. The game creates them in
245    /// their own faction, then moves them and their armies to the end of their husband's.
246    #[getset(get = "pub")]
247    husband_faction: Option<String>,
248}
249
250//---------------------------------------------------------------------------//
251//                              Implementations
252//---------------------------------------------------------------------------//
253
254impl CampaignIndex {
255
256    /// Indexes the startpos rows of a campaign.
257    ///
258    /// # Arguments
259    ///
260    /// * `tables` - Tables with `start_pos_factions`, `start_pos_regions`, `start_pos_settlements`,
261    ///   `start_pos_characters`, `factions` and `cultures_subcultures`.
262    /// * `campaign` - Campaign key.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if a table or column is missing, or a faction lacks its `factions` row.
267    pub fn new(tables: &StartposTables, campaign: &str) -> Result<Self> {
268        let cultures = tables.table("cultures_subcultures")?.rows()
269            .map(|row| Ok((row.str("subculture")?.into_owned(), row.str("culture")?.into_owned())))
270            .collect::<Result<HashMap<_, _>>>()?;
271        let game_factions = tables.table("factions")?.rows()
272            .map(|row| Ok((row.str("key")?.into_owned(), row)))
273            .collect::<Result<HashMap<_, _>>>()?;
274
275        let mut factions = vec![];
276        for row in tables.table("start_pos_factions")?.rows() {
277            if row.str("campaign")? != campaign {
278                continue;
279            }
280            let key = row.str("faction")?.into_owned();
281            let game = game_factions.get(&key).ok_or_else(|| RLibError::StartposInvalidValue("start_pos_factions".to_owned(), "faction".to_owned(), key.to_owned()))?;
282            let subculture = game.str("subculture")?.into_owned();
283            factions.push(FactionEntry {
284                id: row.str("ID")?.into_owned(),
285                playable: row.bool("playable")?,
286                starting_order: row.int("starting_order")?,
287                culture: cultures.get(&subculture).cloned().unwrap_or_default(),
288                military_group: game.str("military_group")?.into_owned(),
289                subculture,
290                key,
291            });
292        }
293        let faction_of_id = factions.iter().map(|faction| (faction.id.to_owned(), faction.key.to_owned())).collect::<HashMap<_, _>>();
294
295        let mut regions = vec![];
296        for row in tables.table("start_pos_regions")?.rows() {
297            if row.str("campaign")? != campaign {
298                continue;
299            }
300            regions.push(RegionEntry {
301                id: row.str("id")?.into_owned(),
302                key: row.str("region")?.into_owned(),
303                owner: faction_of_id.get(row.str("owning_faction")?.as_ref()).cloned(),
304                capital_flag: row.bool("faction_capital")?,
305            });
306        }
307        let region_of_id = regions.iter().map(|region| (region.id.to_owned(), region.key.to_owned())).collect::<HashMap<_, _>>();
308
309        let major_buildings = tables.table("building_levels")?.rows()
310            .filter(|row| row.str("building_instance_key").is_ok_and(|instance| instance == MAJOR_SETTLEMENT_INSTANCE))
311            .map(|row| row.str("level_name").map(|level| level.into_owned()))
312            .collect::<Result<HashSet<_>>>()?;
313
314        // The game drops the starting buildings whose chain the settlement's owner can't have.
315        let mut available = HashMap::new();
316        for faction in &factions {
317            if let Some(chains) = available_chains(tables, campaign, faction)? {
318                available.insert(faction.key.to_owned(), chains);
319            }
320        }
321        let mut chain_of = HashMap::new();
322        if !available.is_empty() {
323            for row in tables.table("building_levels")?.rows() {
324                chain_of.insert(row.str("level_name")?.into_owned(), row.str("chain")?.into_owned());
325            }
326        }
327        let owner_of = regions.iter().map(|region| (region.key.as_str(), region.owner.as_deref())).collect::<HashMap<_, _>>();
328
329        let mut settlements = vec![];
330        for row in tables.table("start_pos_settlements")?.rows() {
331            let Some(region) = region_of_id.get(row.str("region")?.as_ref()) else { continue };
332            let chains = owner_of.get(region.as_str()).copied().flatten().and_then(|owner| available.get(owner));
333            let allowed = |building: &str| chains.is_none_or(|chains| chain_of.get(building).is_some_and(|chain| chains.contains(chain)));
334
335            let mut buildings = vec![];
336            for column in ["building1", "building2", "building3", "building4", "building5"] {
337                let building = row.str(column)?;
338                if !building.is_empty() && allowed(&building) {
339                    buildings.push(building.into_owned());
340                }
341            }
342            let port_building = row.str("port_building")?;
343            let primary_building = row.str("primary_building")?.into_owned();
344            settlements.push(SettlementEntry {
345                id: row.str("id")?.into_owned(),
346                region: region.to_owned(),
347                major: major_buildings.contains(&primary_building),
348                primary_building,
349                port_building: if allowed(&port_building) { port_building.into_owned() } else { String::new() },
350                buildings,
351                slave_points: row.int("startpos_slave_points")?,
352            });
353        }
354
355        let name_kinds = name_kinds(tables)?;
356        let mut characters = vec![];
357        let mut women = HashSet::new();
358        for row in tables.table("start_pos_characters")?.rows() {
359            let Some(faction) = faction_of_id.get(row.str("faction")?.as_ref()) else { continue };
360            if row.bool("is_male").is_ok_and(|is_male| !is_male) {
361                women.insert(row.str("ID")?.into_owned());
362            }
363            characters.push(CharacterEntry {
364                id: row.str("ID")?.into_owned(),
365                faction: faction.to_owned(),
366                agent: row.str("Type")?.into_owned(),
367                age: row.int("Age")?,
368                names: character_names(&row, name_kinds.as_ref())?,
369                start_x: cell_coordinate(&row, "startx")?,
370                start_y: cell_coordinate(&row, "starty")?,
371                ministerial_position: row.str("ministerial_position")?.into_owned(),
372                loyalty: row.int("loyalty")?,
373                death_type: row.str("death_type")?.into_owned(),
374                progenitor: row.bool("progenitor")?,
375                override_general_unit: row.str("override_general_unit")?.into_owned(),
376                husband_faction: None,
377                widowed: false,
378            });
379        }
380
381        let husband_factions = husband_factions(tables, &characters, &women)?;
382        let widowed = widowed(tables, &characters)?;
383        for character in &mut characters {
384            character.husband_faction = husband_factions.get(&character.id).cloned();
385            character.widowed = widowed.contains(&character.id);
386        }
387
388        Ok(Self {
389            campaign: campaign.to_owned(),
390            faction_positions: factions.iter().enumerate().map(|(index, faction)| (faction.key.to_owned(), index)).collect(),
391            region_positions: regions.iter().enumerate().map(|(index, region)| (region.key.to_owned(), index)).collect(),
392            factions,
393            regions,
394            settlements,
395            characters,
396        })
397    }
398
399    /// Returns a faction by key.
400    pub fn faction(&self, key: &str) -> Option<&FactionEntry> {
401        self.faction_positions.get(key).map(|&index| &self.factions[index])
402    }
403
404    /// Returns a faction by its `start_pos_factions` row id.
405    pub fn faction_by_id(&self, id: &str) -> Option<&FactionEntry> {
406        self.factions.iter().find(|faction| faction.id == id)
407    }
408
409    /// Returns a region by key.
410    pub fn region(&self, key: &str) -> Option<&RegionEntry> {
411        self.region_positions.get(key).map(|&index| &self.regions[index])
412    }
413
414    /// Returns the regions owned by a faction, in table order.
415    pub fn regions_of<'a>(&'a self, faction: &'a str) -> impl Iterator<Item = &'a RegionEntry> + 'a {
416        self.regions.iter().filter(move |region| region.owner.as_deref() == Some(faction))
417    }
418
419    /// Returns the capital of a faction: the first of its regions flagged as capital, in table order.
420    pub fn capital_of<'a>(&'a self, faction: &'a str) -> Option<&'a RegionEntry> {
421        self.regions_of(faction).find(|region| region.capital_flag)
422    }
423
424    /// Returns the settlement of a region.
425    pub fn settlement_of(&self, region: &str) -> Option<&SettlementEntry> {
426        self.settlements.iter().find(|settlement| settlement.region == region)
427    }
428
429    /// Returns the characters of a faction, in table order.
430    pub fn characters_of<'a>(&'a self, faction: &'a str) -> impl Iterator<Item = &'a CharacterEntry> + 'a {
431        self.characters.iter().filter(move |character| character.faction == faction)
432    }
433
434    /// Returns the factions in startpos order: playable factions first, each group by `starting_order`.
435    ///
436    /// The game sorts with an unstable sort, so factions with the same starting order may come out in another
437    /// order in game-built startposes.
438    pub fn factions_in_startpos_order(&self) -> Vec<&FactionEntry> {
439        let mut factions = self.factions.iter().collect::<Vec<_>>();
440        factions.sort_by_key(|faction| (!faction.playable, faction.starting_order));
441        factions
442    }
443}
444
445impl SettlementEntry {
446
447    /// Returns if the settlement is a major one (its primary building is a [`MAJOR_SETTLEMENT_INSTANCE`]). Major
448    /// settlements have walls and are the main region of their province.
449    pub fn is_major(&self) -> bool {
450        self.major
451    }
452
453    /// Returns every building level of the settlement: primary, port (if any) and the others, in that order.
454    pub fn all_buildings(&self) -> impl Iterator<Item = &str> {
455        std::iter::once(self.primary_building.as_str())
456            .chain(std::iter::once(self.port_building.as_str()))
457            .chain(self.buildings.iter().map(String::as_str))
458            .filter(|building| !building.is_empty())
459    }
460}
461
462/// Returns the faction of the husband of each woman married to a man of another faction, by her id.
463fn husband_factions(tables: &StartposTables, characters: &[CharacterEntry], women: &HashSet<String>) -> Result<HashMap<String, String>> {
464    let Ok(relationships) = tables.table("start_pos_family_relationships") else { return Ok(HashMap::new()) };
465    let faction_of = characters.iter().map(|character| (character.id.as_str(), character.faction.as_str())).collect::<HashMap<_, _>>();
466
467    let mut moves = HashMap::new();
468    for row in relationships.rows() {
469        if row.str("relationship")? != SPOUSE_RELATIONSHIP {
470            continue;
471        }
472        let (character, related) = (row.str("character")?, row.str("related_to")?);
473        for (wife, husband) in [(&character, &related), (&related, &character)] {
474            if !women.contains(wife.as_ref()) || women.contains(husband.as_ref()) {
475                continue;
476            }
477            if let (Some(wife_faction), Some(husband_faction)) = (faction_of.get(wife.as_ref()), faction_of.get(husband.as_ref())) {
478                if wife_faction != husband_faction {
479                    moves.insert(wife.to_string(), husband_faction.to_string());
480                }
481            }
482        }
483    }
484    Ok(moves)
485}
486
487/// Returns the ids of the living characters married to a dead one.
488fn widowed(tables: &StartposTables, characters: &[CharacterEntry]) -> Result<HashSet<String>> {
489    let Ok(relationships) = tables.table("start_pos_family_relationships") else { return Ok(HashSet::new()) };
490    let dead = characters.iter().filter(|character| !character.death_type.is_empty()).map(|character| character.id.as_str()).collect::<HashSet<_>>();
491    let living = characters.iter().filter(|character| character.death_type.is_empty()).map(|character| character.id.as_str()).collect::<HashSet<_>>();
492
493    let mut widowed = HashSet::new();
494    for row in relationships.rows() {
495        if row.str("relationship")? != SPOUSE_RELATIONSHIP {
496            continue;
497        }
498        let (character, related) = (row.str("character")?, row.str("related_to")?);
499        for (survivor, spouse) in [(&character, &related), (&related, &character)] {
500            if living.contains(survivor.as_ref()) && dead.contains(spouse.as_ref()) {
501                widowed.insert(survivor.to_string());
502            }
503        }
504    }
505    Ok(widowed)
506}
507
508/// Returns the kinds (`names.type`) of each name id, or `None` if there's no `names` table to check names with.
509fn name_kinds(tables: &StartposTables) -> Result<Option<HashMap<String, HashSet<i64>>>> {
510    let Ok(names) = tables.table("names") else { return Ok(None) };
511    let mut kinds = HashMap::<String, HashSet<i64>>::new();
512    for row in names.rows() {
513        kinds.entry(row.str("id")?.into_owned()).or_default().insert(row.int("type")?);
514    }
515    Ok(Some(kinds))
516}
517
518/// Reads the names of a character, in [`NAME_COLUMNS`] order, leaving out the ones whose kind doesn't fit their column.
519fn character_names(row: &Row, kinds: Option<&HashMap<String, HashSet<i64>>>) -> Result<Vec<String>> {
520    NAME_COLUMNS.iter().zip(0..).map(|(column, kind)| {
521        let name = row.str(column)?;
522        let fits = kinds.is_none_or(|kinds| kinds.get(name.as_ref()).is_some_and(|name_kinds| name_kinds.contains(&kind)));
523        Ok(if fits { name.into_owned() } else { String::new() })
524    }).collect()
525}
526
527/// Reads a starting cell coordinate, stored as a float in `start_pos_characters`.
528fn cell_coordinate(row: &Row, column: &str) -> Result<u16> {
529    let value = row.f32(column)?;
530    if !(0.0..=f32::from(u16::MAX)).contains(&value) {
531        return Err(RLibError::StartposInvalidValue("start_pos_characters".to_owned(), column.to_owned(), value.to_string()));
532    }
533    Ok(value as u16)
534}