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/// Range the startpos clamps the table loyalty to.
30pub const LOYALTY_RANGE: RangeInclusive<i64> = 3..=10;
31
32//---------------------------------------------------------------------------//
33// Types
34//---------------------------------------------------------------------------//
35
36/// Where a character starts on the campaign map.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Placement {
39
40 /// At the cell given by `startx`/`starty` (see [`HexCell::world_position`]).
41 Cell(HexCell),
42
43 /// At the settlement of a region (the first `start_pos_character_to_settlements` link owned by the faction).
44 Settlement(String),
45
46 /// With the faction's first general's army (factions without capital).
47 FirstArmy,
48
49 /// Off the map, at world position (0, 0).
50 OffMap,
51}
52
53//---------------------------------------------------------------------------//
54// Implementations
55//---------------------------------------------------------------------------//
56
57/// Returns a character's birth year: the campaign's start year minus its age.
58pub fn birth_year(start_year: i64, character: &CharacterEntry) -> i64 {
59 start_year - character.age()
60}
61
62/// Returns the loyalty the startpos stores: the table value clamped to [`LOYALTY_RANGE`].
63pub fn loyalty(character: &CharacterEntry) -> i64 {
64 character.loyalty().clamp(*LOYALTY_RANGE.start(), *LOYALTY_RANGE.end())
65}
66
67/// Returns the political party of a character: its table party, except for the dead and the widowed, which the game
68/// changes. Dead women and widows have no party. Dead men without a party join their faction's ruling party, and
69/// widowers in it move to the faction's other party. Seen on Attila's main campaign, with one widow and one widower.
70///
71/// # Arguments
72///
73/// * `character` - The character.
74/// * `is_male` - The character is a man.
75/// * `widowed` - The character is alive and married to a dead character.
76/// * `party` - The character's `start_pos_characters.political_party`.
77/// * `parties` - The political parties of the character's faction.
78pub fn political_party<'a>(character: &CharacterEntry, is_male: bool, widowed: bool, party: &'a str, parties: &'a [PoliticalParty]) -> &'a str {
79 let ruling = parties.iter().find(|candidate| candidate.playable()).map(|ruling| ruling.key().as_str());
80 let other = parties.iter().find(|candidate| !candidate.playable()).map(|other| other.key().as_str());
81 let dead = !character.death_type().is_empty();
82 match (dead, widowed, is_male) {
83 (true, _, false) | (false, true, false) => "",
84 (true, _, true) if party.is_empty() => ruling.unwrap_or_default(),
85 (false, true, true) if Some(party) == ruling => other.unwrap_or(party),
86 _ => party,
87 }
88}
89
90/// Returns where a character starts on the campaign map. An army stands where its general starts.
91///
92/// # Arguments
93///
94/// * `tables` - Tables with `start_pos_character_to_settlements`.
95/// * `index` - The campaign's startpos rows.
96/// * `character` - The character.
97///
98/// # Returns
99///
100/// The cell given in the table if any; otherwise the first linked settlement owned by the character's faction
101/// (links to unknown settlements are ignored); otherwise the faction's first army for factions without capital;
102/// otherwise off the map.
103///
104/// # Errors
105///
106/// Returns an error if a table or column is missing.
107pub fn placement(tables: &StartposTables, index: &CampaignIndex, character: &CharacterEntry) -> Result<Placement> {
108 if character.start_x() != 0 || character.start_y() != 0 {
109 return Ok(Placement::Cell(HexCell::new(character.start_x(), character.start_y())));
110 }
111
112 let owned = index.regions_of(character.faction()).map(|region| region.key().as_str()).collect::<HashSet<_>>();
113 for row in tables.table("start_pos_character_to_settlements")?.rows() {
114 if row.str("character")? != *character.id() {
115 continue;
116 }
117 let settlement_id = row.str("settlement")?;
118 if let Some(settlement) = index.settlements().iter().find(|settlement| *settlement.id() == settlement_id) {
119 if owned.contains(settlement.region().as_str()) {
120 return Ok(Placement::Settlement(settlement.region().to_owned()));
121 }
122 }
123 }
124
125 if index.capital_of(character.faction()).is_none() {
126 return Ok(Placement::FirstArmy);
127 }
128
129 Ok(Placement::OffMap)
130}