rpfm_extensions/startpos/world/characters.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//! Per-character values of the `CHARACTER` record that come from the tables: birth year, loyalty and where the
12//! character starts on the map.
13//!
14//! Verified on Attila's main campaign: birth years and loyalty match for 332 of 334 linked generals, placements for
15//! 332 of 334 (two Sassanid generals linked to an occupied Ctesiphon are moved by the game to a free spot nearby).
16
17use std::collections::HashSet;
18use std::ops::RangeInclusive;
19
20use rpfm_lib::error::Result;
21
22use super::{CampaignIndex, CharacterEntry};
23use super::government::PoliticalParty;
24use crate::startpos::coords::HexCell;
25use crate::startpos::tables::StartposTables;
26
27#[cfg(test)] #[path = "characters_test.rs"] mod characters_test;
28
29/// Campaign variable of the lowest loyalty the startpos keeps (Attila 3, Thrones 2).
30const LOW_LOYALTY_VARIABLE: &str = "loyalty_risk_low";
31
32/// Campaign variable of the highest loyalty.
33const MAXIMUM_LOYALTY_VARIABLE: &str = "maximum_loyalty";
34
35//---------------------------------------------------------------------------//
36// Types
37//---------------------------------------------------------------------------//
38
39/// Where a character starts on the campaign map.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Placement {
42
43 /// At the cell given by `startx`/`starty` (see [`HexCell::world_position`]).
44 Cell(HexCell),
45
46 /// At the settlement of a region (the first `start_pos_character_to_settlements` link owned by the faction).
47 Settlement(String),
48
49 /// With the faction's first general's army (factions without capital).
50 FirstArmy,
51
52 /// Off the map, at world position (0, 0).
53 OffMap,
54}
55
56//---------------------------------------------------------------------------//
57// Implementations
58//---------------------------------------------------------------------------//
59
60/// Returns a character's birth year: the campaign's start year minus its age.
61pub fn birth_year(start_year: i64, character: &CharacterEntry) -> i64 {
62 start_year - character.age()
63}
64
65/// Returns the loyalty the startpos stores: the table value clamped to a range (see [`loyalty_range`]).
66pub fn loyalty(character: &CharacterEntry, range: &RangeInclusive<i64>) -> i64 {
67 character.loyalty().clamp(*range.start(), *range.end())
68}
69
70/// Returns the range the startpos clamps the table loyalty to: from the `loyalty_risk_low` campaign variable to the
71/// `maximum_loyalty` one (Attila 3 to 10, Thrones 2 to 10). Verified on Thrones: 359 of its 363 living characters.
72///
73/// # Errors
74///
75/// Returns an error if the table or a column is missing.
76pub fn loyalty_range(tables: &StartposTables) -> Result<RangeInclusive<i64>> {
77 let (mut low, mut high) = (0, 0);
78 for row in tables.table("campaign_variables")?.rows() {
79 match row.str("variable_key")?.as_ref() {
80 LOW_LOYALTY_VARIABLE => low = row.number("value")? as i64,
81 MAXIMUM_LOYALTY_VARIABLE => high = row.number("value")? as i64,
82 _ => {},
83 }
84 }
85 Ok(low..=high.max(low))
86}
87
88/// Returns the political party of a character: its table party, except for the dead and the widowed, which the game
89/// changes. Dead women and widows have no party. Dead men without a party join their faction's ruling party, and
90/// widowers in it move to the faction's other party. Seen on Attila's main campaign, with one widow and one widower.
91///
92/// # Arguments
93///
94/// * `character` - The character.
95/// * `is_male` - The character is a man.
96/// * `widowed` - The character is alive and married to a dead character.
97/// * `party` - The character's `start_pos_characters.political_party`.
98/// * `parties` - The political parties of the character's faction.
99pub fn political_party<'a>(character: &CharacterEntry, is_male: bool, widowed: bool, party: &'a str, parties: &'a [PoliticalParty]) -> &'a str {
100 let ruling = parties.iter().find(|candidate| candidate.playable()).map(|ruling| ruling.key().as_str());
101 let other = parties.iter().find(|candidate| !candidate.playable()).map(|other| other.key().as_str());
102 let dead = !character.death_type().is_empty();
103 match (dead, widowed, is_male) {
104 (true, _, false) | (false, true, false) => "",
105 (true, _, true) if party.is_empty() => ruling.unwrap_or_default(),
106 (false, true, true) if Some(party) == ruling => other.unwrap_or(party),
107 _ => party,
108 }
109}
110
111/// Returns where a character starts on the campaign map. An army stands where its general starts.
112///
113/// # Arguments
114///
115/// * `tables` - Tables with `start_pos_character_to_settlements`.
116/// * `index` - The campaign's startpos rows.
117/// * `character` - The character.
118///
119/// # Returns
120///
121/// The cell given in the table if any; otherwise the first linked settlement owned by the character's faction
122/// (links to unknown settlements are ignored); otherwise the faction's first army for factions without capital;
123/// otherwise off the map.
124///
125/// # Errors
126///
127/// Returns an error if a table or column is missing.
128pub fn placement(tables: &StartposTables, index: &CampaignIndex, character: &CharacterEntry) -> Result<Placement> {
129 if character.start_x() != 0 || character.start_y() != 0 {
130 return Ok(Placement::Cell(HexCell::new(character.start_x(), character.start_y())));
131 }
132
133 let owned = index.regions_of(character.faction()).map(|region| region.key().as_str()).collect::<HashSet<_>>();
134 for row in tables.table("start_pos_character_to_settlements")?.rows() {
135 if row.str("character")? != *character.id() {
136 continue;
137 }
138 let settlement_id = row.str("settlement")?;
139 if let Some(settlement) = index.settlements().iter().find(|settlement| *settlement.id() == settlement_id) {
140 if owned.contains(settlement.region().as_str()) {
141 return Ok(Placement::Settlement(settlement.region().to_owned()));
142 }
143 }
144 }
145
146 if index.capital_of(character.faction()).is_none() {
147 return Ok(Placement::FirstArmy);
148 }
149
150 Ok(Placement::OffMap)
151}