Skip to main content

rpfm_extensions/startpos/world/
pools.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//! Mercenary pools (`FACTION/FACTION_MERCENARY_POOL`, `PROVINCE/PROVINCE_MERCENARY_POOL`) and levy pools
12//! (`FACTION/LEVY_MERCENARY_MANAGER`).
13//!
14//! Verified on Attila's main campaign: 118 of 118 faction pools, 62 of 62 province pools and 3 of 3 levy managers
15//! match. The game lists the units of a pool in an order not reproduced yet; these functions return them in table
16//! order, with the game's creation index.
17
18use getset::{CopyGetters, Getters};
19
20use std::collections::{BTreeSet, HashMap};
21
22use rpfm_lib::error::Result;
23
24use super::CampaignIndex;
25use crate::startpos::tables::StartposTables;
26
27#[cfg(test)] #[path = "pools_test.rs"] mod pools_test;
28
29/// Diplomatic stances that make a faction raise levies for its overlord.
30const SUBJECT_STANCES: [&str; 2] = ["vassal", "client_state"];
31
32//---------------------------------------------------------------------------//
33//                              Types
34//---------------------------------------------------------------------------//
35
36/// A unit of a mercenary pool (`MERCENARY_POOL_UNIT_ARRAY` entry).
37#[derive(Debug, Clone, PartialEq, Default, Getters, CopyGetters)]
38pub struct MercenaryUnit {
39
40    /// Unit key (`mercenary_unit_groups.unit_record`).
41    #[getset(get = "pub")]
42    unit: String,
43
44    /// Units available at the start (`initial_unit_count`).
45    #[getset(get_copy = "pub")]
46    initial_count: u32,
47
48    /// Chance to get a unit back each turn, as stored in the table.
49    #[getset(get_copy = "pub")]
50    replenish_chance: f32,
51
52    /// Maximum units in the pool. The startpos stores it twice.
53    #[getset(get_copy = "pub")]
54    max_count: u32,
55
56    /// Maximum units replenished per turn.
57    #[getset(get_copy = "pub")]
58    max_replenish_per_turn: u32,
59
60    /// Position of the unit in the order the game creates the pool's units: the key order of its
61    /// `mercenary_pool_to_groups_junctions` row (see [`crate::startpos::tables::Table::rows_in_key_order`]).
62    #[getset(get_copy = "pub")]
63    creation_index: u32,
64}
65
66/// A levy pool: an empty mercenary pool an overlord gets for each province where a subject owns regions.
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Getters)]
68#[getset(get = "pub")]
69pub struct LevyPool {
70
71    /// Overlord faction key.
72    overlord: String,
73
74    /// Subject faction key.
75    subject: String,
76
77    /// Province key.
78    province: String,
79}
80
81//---------------------------------------------------------------------------//
82//                              Implementations
83//---------------------------------------------------------------------------//
84
85/// Returns the mercenary pool of a faction.
86///
87/// # Arguments
88///
89/// * `tables` - Tables with `faction_to_mercenary_set_junctions`, `mercenary_pool_to_groups_junctions` and
90///   `mercenary_unit_groups`.
91/// * `faction` - Faction key.
92///
93/// # Returns
94///
95/// The units of every set of the faction, in table order. Empty for factions without sets.
96///
97/// # Errors
98///
99/// Returns an error if a table or column is missing.
100pub fn faction_mercenary_pool(tables: &StartposTables, faction: &str) -> Result<Vec<MercenaryUnit>> {
101    mercenary_pool(tables, "faction_to_mercenary_set_junctions", "faction", faction)
102}
103
104/// Returns the mercenary pool of a province.
105///
106/// # Arguments
107///
108/// * `tables` - Tables with `province_to_mercenary_set_junctions`, `mercenary_pool_to_groups_junctions` and
109///   `mercenary_unit_groups`.
110/// * `province` - Province key.
111///
112/// # Returns
113///
114/// The units of every set of the province, in table order. Empty for provinces without sets.
115///
116/// # Errors
117///
118/// Returns an error if a table or column is missing.
119pub fn province_mercenary_pool(tables: &StartposTables, province: &str) -> Result<Vec<MercenaryUnit>> {
120    mercenary_pool(tables, "province_to_mercenary_set_junctions", "province", province)
121}
122
123/// Returns the units of the mercenary sets assigned to an owner by a junction table.
124///
125/// The requirement columns of `mercenary_pool_to_groups_junctions` are blank in vanilla Attila and aren't applied.
126fn mercenary_pool(tables: &StartposTables, junctions: &str, owner_column: &str, owner: &str) -> Result<Vec<MercenaryUnit>> {
127    let mut groups = HashMap::new();
128    for row in tables.table("mercenary_unit_groups")?.rows() {
129        groups.insert(row.str("key")?.into_owned(), row);
130    }
131
132    let pool_rows = tables.table("mercenary_pool_to_groups_junctions")?;
133    let key_rank = pool_rows.rows_in_key_order("key")?.iter().enumerate()
134        .map(|(rank, row)| Ok((row.str("key")?.into_owned(), rank)))
135        .collect::<Result<HashMap<_, _>>>()?;
136    let mut units = vec![];
137    let mut ranks = vec![];
138    for set_row in tables.table(junctions)?.rows() {
139        if set_row.str(owner_column)? != owner {
140            continue;
141        }
142        let set = set_row.str("mercenary_set")?;
143        for row in pool_rows.rows() {
144            if row.str("pool")? != set {
145                continue;
146            }
147            let Some(group) = groups.get(row.str("group")?.as_ref()) else { continue };
148            ranks.push(key_rank.get(row.str("key")?.as_ref()).copied().unwrap_or_default());
149            units.push(MercenaryUnit {
150                unit: group.str("unit_record")?.into_owned(),
151                initial_count: count(row.int("initial_unit_count")?),
152                replenish_chance: group.f32("chance_to_replenish")?,
153                max_count: count(group.int("max_count")?),
154                max_replenish_per_turn: count(group.int("max_replenish_per_turn")?),
155                creation_index: 0,
156            });
157        }
158    }
159
160    // The creation index is the unit's place among the pool's units in key order.
161    let mut by_rank = (0..units.len()).collect::<Vec<_>>();
162    by_rank.sort_by_key(|&unit| ranks[unit]);
163    for (index, unit) in by_rank.into_iter().enumerate() {
164        units[unit].creation_index = u32::try_from(index).unwrap_or(u32::MAX);
165    }
166    Ok(units)
167}
168
169/// Returns the levy pools of every overlord.
170///
171/// Each `start_pos_diplomacy` row with stance `vassal` or `client_state` (`faction1` the subject, `faction2` the
172/// overlord) gives the overlord one pool per province where the subject owns regions.
173///
174/// # Arguments
175///
176/// * `tables` - Tables with `start_pos_diplomacy` and `region_to_province_junctions`.
177/// * `index` - The campaign's startpos rows.
178///
179/// # Returns
180///
181/// The pools, sorted by overlord, subject and province key. The game sorts them by the startpos ids of the subject
182/// and the province, which aren't known at this stage.
183///
184/// # Errors
185///
186/// Returns an error if a table or column is missing.
187pub fn levy_pools(tables: &StartposTables, index: &CampaignIndex) -> Result<Vec<LevyPool>> {
188    let mut province_of = HashMap::new();
189    for row in tables.table("region_to_province_junctions")?.rows() {
190        province_of.insert(row.str("region")?.into_owned(), row.str("province")?.into_owned());
191    }
192
193    let mut pools = BTreeSet::new();
194    for row in tables.table("start_pos_diplomacy")?.rows() {
195        if !SUBJECT_STANCES.contains(&row.str("stance")?.as_ref()) {
196            continue;
197        }
198        let (Some(subject), Some(overlord)) = (index.faction_by_id(&row.str("faction1")?), index.faction_by_id(&row.str("faction2")?)) else { continue };
199        for region in index.regions_of(subject.key()) {
200            if let Some(province) = province_of.get(region.key()) {
201                pools.insert(LevyPool {
202                    overlord: overlord.key().to_owned(),
203                    subject: subject.key().to_owned(),
204                    province: province.to_owned(),
205                });
206            }
207        }
208    }
209
210    Ok(pools.into_iter().collect())
211}
212
213/// Converts a table count to the unsigned value the startpos stores, clamping negative values to 0.
214fn count(value: i64) -> u32 {
215    u32::try_from(value.max(0)).unwrap_or(u32::MAX)
216}