Skip to main content

rpfm_extensions/startpos/world/
unit_bonuses.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//! Equipment stat bonuses of garrison units from their settlements' buildings
12//! (`UNIT/UNIT_STATISTICS_BONUSES/STATS_BLOCK`).
13//!
14//! The game computes a unit's bonus for each equipment stat (`FUN_10b90310`) as the largest of its sources: the
15//! effects with the stat's basic bonus value, the ones for the unit's category and the ones for its class
16//! (`FUN_10b86000`). The stats are, by id, armour, shield, weapon, ammunition and horses. A garrison takes the
17//! effects of its faction's buildings in the province (`province_to_province_own`,
18//! `region_to_region_own_provincewide`) and in its own region (`region_to_region_own`). Verified on Thrones: the 166
19//! bonuses of its 83 garrison units with any (forges and military ports: bronze armour and weapons +5).
20
21use std::collections::HashMap;
22
23use rpfm_lib::error::Result;
24
25use crate::startpos::tables::StartposTables;
26
27#[cfg(test)] #[path = "unit_bonuses_test.rs"] mod unit_bonuses_test;
28
29/// Bonus value ids of the equipment stats, by stat id.
30const STAT_BONUS_IDS: [&str; 5] = ["unit_stat_bonus_armour", "unit_stat_bonus_shield", "unit_stat_bonus_weapon", "unit_stat_bonus_ammunition", "unit_stat_bonus_horses"];
31
32/// Effect scopes reaching every region of the province the building is in.
33const PROVINCE_SCOPES: [&str; 2] = ["province_to_province_own", "region_to_region_own_provincewide"];
34
35/// Effect scope reaching only the building's region.
36const REGION_SCOPE: &str = "region_to_region_own";
37
38//---------------------------------------------------------------------------//
39//                              Types
40//---------------------------------------------------------------------------//
41
42/// Which units a stat effect applies to, by bonus source.
43#[derive(Debug, Clone, PartialEq, Eq)]
44enum Target {
45
46    /// Every unit (`effect_bonus_value_basic_junction`).
47    Basic,
48
49    /// Units of a category (`effect_bonus_value_unit_category_junction`).
50    Category(String),
51
52    /// Units of a class (`effect_bonus_value_unit_class_junction`).
53    Class(String),
54}
55
56/// A building's effect on an equipment stat.
57#[derive(Debug, Clone, PartialEq)]
58struct StatEffect {
59
60    /// Stat id (position in [`STAT_BONUS_IDS`]).
61    stat: u32,
62
63    /// Units it applies to.
64    target: Target,
65
66    /// Bonus.
67    value: i64,
68
69    /// It reaches the whole province, not only the building's region.
70    provincewide: bool,
71}
72
73/// What the stat bonuses of units are worked out from.
74#[derive(Debug, Clone, Default)]
75pub struct StatBonusRules {
76
77    /// Stat effects of each building level.
78    effects: HashMap<String, Vec<StatEffect>>,
79
80    /// Category and class of each unit, by `main_units` key.
81    kinds: HashMap<String, (String, String)>,
82}
83
84//---------------------------------------------------------------------------//
85//                              Implementations
86//---------------------------------------------------------------------------//
87
88impl StatBonusRules {
89
90    /// Collects the stat effects of the buildings and the categories and classes of the units.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if a table or column is missing.
95    pub fn new(tables: &StartposTables) -> Result<Self> {
96        let sources = [
97            ("effect_bonus_value_basic_junction", None),
98            ("effect_bonus_value_unit_category_junction", Some("unit_category")),
99            ("effect_bonus_value_unit_class_junction", Some("unit_class")),
100        ];
101        let mut targets = HashMap::<String, Vec<(u32, Target)>>::new();
102        for (table, column) in sources {
103            for row in tables.table(table)?.rows() {
104                let bonus = row.str("bonus_value_id")?;
105                let Some(stat) = STAT_BONUS_IDS.iter().position(|id| *id == bonus) else { continue };
106                let target = match column {
107                    Some("unit_category") => Target::Category(row.str("unit_category")?.into_owned()),
108                    Some(column) => Target::Class(row.str(column)?.into_owned()),
109                    None => Target::Basic,
110                };
111                targets.entry(row.str("effect")?.into_owned()).or_default().push((stat as u32, target));
112            }
113        }
114
115        let mut effects = HashMap::<String, Vec<StatEffect>>::new();
116        for row in tables.table("building_effects_junction")?.rows() {
117            let Some(effect_targets) = targets.get(row.str("effect")?.as_ref()) else { continue };
118            let scope = row.str("effect_scope")?;
119            let provincewide = PROVINCE_SCOPES.contains(&scope.as_ref());
120            if !provincewide && scope != REGION_SCOPE {
121                continue;
122            }
123            let value = row.number("value")? as i64;
124            let building = effects.entry(row.str("building")?.into_owned()).or_default();
125            building.extend(effect_targets.iter().map(|(stat, target)| StatEffect { stat: *stat, target: target.clone(), value, provincewide }));
126        }
127
128        let mut unit_kinds = HashMap::new();
129        for table in ["land_units", "naval_units"] {
130            for row in tables.table(table)?.rows() {
131                unit_kinds.insert(row.str("key")?.into_owned(), (row.str("category")?.into_owned(), row.str("class")?.into_owned()));
132            }
133        }
134        let mut kinds = HashMap::new();
135        for row in tables.table("main_units")?.rows() {
136            let column = if row.bool("is_naval")? { "naval_unit" } else { "land_unit" };
137            if let Some(kind) = unit_kinds.get(row.str(column)?.as_ref()) {
138                kinds.insert(row.str("unit")?.into_owned(), kind.clone());
139            }
140        }
141
142        Ok(Self { effects, kinds })
143    }
144
145    /// Returns the stat bonuses of a garrison unit.
146    ///
147    /// # Arguments
148    ///
149    /// * `unit` - `main_units` key.
150    /// * `buildings` - The faction's buildings in the garrison's province, each with whether it's in the garrison's
151    ///   own region.
152    ///
153    /// # Returns
154    ///
155    /// The (stat id, bonus) pairs with a bonus, by stat id.
156    pub fn bonuses<'b>(&self, unit: &str, buildings: impl IntoIterator<Item = (&'b str, bool)>) -> Vec<(u32, i64)> {
157        let Some((category, class)) = self.kinds.get(unit) else { return vec![] };
158        let mut sums = HashMap::<(u32, u8), i64>::new();
159        for (building, own_region) in buildings {
160            for effect in self.effects.get(building).into_iter().flatten().filter(|effect| effect.provincewide || own_region) {
161                let source = match &effect.target {
162                    Target::Basic => 0,
163                    Target::Category(target) if target == category => 1,
164                    Target::Class(target) if target == class => 2,
165                    _ => continue,
166                };
167                *sums.entry((effect.stat, source)).or_default() += effect.value;
168            }
169        }
170
171        (0..STAT_BONUS_IDS.len() as u32)
172            .filter_map(|stat| (0..3).filter_map(|source| sums.get(&(stat, source)).copied()).max().filter(|value| *value > 0).map(|value| (stat, value)))
173            .collect()
174    }
175}