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