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}