Skip to main content

rpfm_extensions/startpos/world/
agents.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//! Agent data of characters and factions: abilities (`CHARACTER_DETAILS/AGENT_ABILITIES`), attributes
12//! (`AGENT_ATTRIBUTES`), skill sets and starting skills (`CAMPAIGN_SKILLS`) and the agent types of the recruitment
13//! pools (`FACTION/CHARACTER_RECRUITMENT_POOL_MANAGER`).
14//!
15//! Verified on Attila's main campaign for every living character and every faction. The game stores the
16//! recruitment pools in hash order.
17
18use std::collections::HashMap;
19
20use rpfm_lib::error::Result;
21
22use super::{CampaignIndex, FactionEntry};
23use crate::startpos::tables::StartposTables;
24
25#[cfg(test)] #[path = "agents_test.rs"] mod agents_test;
26
27/// Agent abilities, in the order the startpos stores them.
28pub const ABILITIES: [&str; 9] = [
29    "command_force",
30    "assist_army",
31    "assist_province",
32    "hinder_army",
33    "hinder_character",
34    "hinder_agent",
35    "hinder_province",
36    "hinder_settlement",
37    "passive_ability",
38];
39
40/// Agent attributes, in the order the startpos stores them.
41pub const ATTRIBUTES: [&str; 3] = ["subterfuge", "zeal", "authority"];
42
43//---------------------------------------------------------------------------//
44//                              Implementations
45//---------------------------------------------------------------------------//
46
47/// Returns which abilities an agent type has, in [`ABILITIES`] order (`agent_to_agent_abilities`).
48///
49/// # Arguments
50///
51/// * `tables` - Tables with `agent_to_agent_abilities`.
52/// * `agent` - Agent type.
53///
54/// # Errors
55///
56/// Returns an error if a table or column is missing.
57pub fn agent_abilities(tables: &StartposTables, agent: &str) -> Result<[bool; ABILITIES.len()]> {
58    let mut abilities = [false; ABILITIES.len()];
59    for row in tables.table("agent_to_agent_abilities")?.rows() {
60        if row.str("agent")? == agent {
61            let ability = row.str("ability")?;
62            if let Some(index) = ABILITIES.iter().position(|known| *known == ability) {
63                abilities[index] = true;
64            }
65        }
66    }
67    Ok(abilities)
68}
69
70/// Returns the starting attributes of an agent type, in [`ATTRIBUTES`] order (`agent_to_agent_attributes`, 0 when
71/// the agent has no row for an attribute).
72///
73/// # Arguments
74///
75/// * `tables` - Tables with `agent_to_agent_attributes`.
76/// * `agent` - Agent type.
77///
78/// # Errors
79///
80/// Returns an error if a table or column is missing.
81pub fn agent_attributes(tables: &StartposTables, agent: &str) -> Result<[i64; ATTRIBUTES.len()]> {
82    let mut attributes = [0; ATTRIBUTES.len()];
83    for row in tables.table("agent_to_agent_attributes")?.rows() {
84        if row.str("agent")? == agent {
85            let attribute = row.str("attribute")?;
86            if let Some(index) = ATTRIBUTES.iter().position(|known| *known == attribute) {
87                attributes[index] = row.int("default_value")?;
88            }
89        }
90    }
91    Ok(attributes)
92}
93
94/// Returns the skill set of a character: the `character_skill_node_sets` row for its agent type and faction, else its
95/// subculture, else every faction.
96///
97/// # Arguments
98///
99/// * `tables` - Tables with `character_skill_node_sets`.
100/// * `index` - The campaign's startpos rows.
101/// * `agent` - Agent type.
102/// * `faction` - The character's faction.
103///
104/// # Returns
105///
106/// The skill set key, or `None` if there isn't one (colonels).
107///
108/// # Errors
109///
110/// Returns an error if a table or column is missing.
111pub fn skill_set(tables: &StartposTables, index: &CampaignIndex, agent: &str, faction: &FactionEntry) -> Result<Option<String>> {
112
113    // Best match: 0 for the faction's own set, 1 for its subculture's, 2 for a set of every faction.
114    let mut best: Option<(u8, String)> = None;
115    for row in tables.table("character_skill_node_sets")?.rows() {
116        let campaign = row.str("campaign_key")?;
117        let (row_faction, row_subculture) = (row.str("faction_key")?, row.str("subculture")?);
118        if row.str("agent_key")? != agent || !(campaign.is_empty() || campaign == *index.campaign()) {
119            continue;
120        }
121        let rank = match (row_faction.is_empty(), row_subculture.is_empty()) {
122            (false, _) if row_faction == *faction.key() && (row_subculture.is_empty() || row_subculture == *faction.subculture()) => 0,
123            (true, false) if row_subculture == *faction.subculture() => 1,
124            (true, true) => 2,
125            _ => continue,
126        };
127        if best.as_ref().is_none_or(|(best_rank, _)| rank < *best_rank) {
128            best = Some((rank, row.str("key")?.into_owned()));
129        }
130    }
131    Ok(best.map(|(_, key)| key))
132}
133
134/// Returns the rank and skill points of a character with some experience: how many of its agent type's
135/// `character_experience_skill_tiers` thresholds the experience reaches, and their skill points. Verified on
136/// Thrones' campaign: 512 of 512 characters.
137///
138/// # Errors
139///
140/// Returns an error if a table or column is missing.
141pub fn experience_rank(tables: &StartposTables, index: &CampaignIndex, agent: &str, experience: i64) -> Result<(u32, u32)> {
142    let (mut rank, mut points) = (0, 0);
143    for row in tables.table("character_experience_skill_tiers")?.rows() {
144        let campaign = row.str("optional_campaign_key")?;
145        if row.str("agent_key")? == agent && !row.bool("for_army")? && !row.bool("for_navy")?
146            && (campaign.is_empty() || campaign == *index.campaign())
147            && row.int("experience_threshold")? <= experience {
148            rank += 1;
149            points += u32::try_from(row.int("skill_points")?).unwrap_or_default();
150        }
151    }
152    Ok((rank, points))
153}
154
155/// A skill a character starts with: a node of its skill set with points on creation.
156#[derive(Debug, Clone, PartialEq)]
157pub struct StartingSkill {
158
159    /// Skill key.
160    pub skill: String,
161
162    /// Tier of the node in the skill set.
163    pub tier: u32,
164
165    /// Indent of the node in the skill set.
166    pub indent: f32,
167
168    /// Points the skill starts with.
169    pub points: u32,
170}
171
172/// Returns the skills a character starts with: the nodes of its skill set with points on creation, by tier.
173///
174/// # Arguments
175///
176/// * `tables` - Tables with `character_skill_nodes`.
177/// * `index` - The campaign's startpos rows.
178/// * `skill_set` - The character's skill set.
179/// * `faction` - The character's faction.
180///
181/// # Errors
182///
183/// Returns an error if a table or column is missing.
184pub fn starting_skills(tables: &StartposTables, index: &CampaignIndex, skill_set: &str, faction: &FactionEntry) -> Result<Vec<StartingSkill>> {
185    let mut skills = vec![];
186    for row in tables.table("character_skill_nodes")?.rows() {
187        let points = row.int("points_on_creation")?;
188        if points <= 0 || row.str("character_skill_node_set_key")? != skill_set {
189            continue;
190        }
191        let (campaign, row_faction, subculture) = (row.str("campaign_key")?, row.str("faction_key")?, row.str("subculture")?);
192        if (campaign.is_empty() || campaign == *index.campaign())
193            && (row_faction.is_empty() || row_faction == *faction.key())
194            && (subculture.is_empty() || subculture == *faction.subculture()) {
195            skills.push(StartingSkill {
196                skill: row.str("character_skill_key")?.into_owned(),
197                tier: row.f32("tier")? as u32,
198                indent: row.f32("indent")?,
199                points: u32::try_from(points).unwrap_or_default(),
200            });
201        }
202    }
203    skills.sort_by_key(|skill| skill.tier);
204    Ok(skills)
205}
206
207/// Returns the agent types with a recruitment pool in a faction.
208///
209/// They're the agent types of the faction's culture in `agent_culture_details` whose `agents.faction_total_cap`
210/// is above 0 (which leaves out colonels and ministers).
211///
212/// # Arguments
213///
214/// * `tables` - Tables with `agent_culture_details` and `agents`.
215/// * `faction` - The faction.
216///
217/// # Returns
218///
219/// The agent types, in `agent_culture_details` order, without repeats.
220///
221/// # Errors
222///
223/// Returns an error if a table or column is missing.
224pub fn recruitment_pool_agents(tables: &StartposTables, faction: &FactionEntry) -> Result<Vec<String>> {
225    let mut caps = HashMap::new();
226    for row in tables.table("agents")?.rows() {
227        caps.insert(row.str("key")?.into_owned(), row.int("faction_total_cap")?);
228    }
229
230    let mut agents = vec![];
231    for row in tables.table("agent_culture_details")?.rows() {
232        let agent = row.str("agent")?.into_owned();
233        if row.str("culture")? == *faction.culture() && caps.get(&agent).is_some_and(|&cap| cap > 0) && !agents.contains(&agent) {
234            agents.push(agent);
235        }
236    }
237
238    Ok(agents)
239}