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}