Skip to main content

rpfm_extensions/startpos/
split.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//! Splitting of the `start_pos_*` tables by campaign, as the Assembly Kit's BOB does before building a startpos.
12//!
13//! The game builds a campaign's startpos from every row of the tables it gets, so tables holding the rows of several
14//! campaigns make the build fail. BOB only exports the rows of the campaign it builds:
15//!
16//! - Rows of tables with a `campaign` column belong to their campaign.
17//! - Other rows belong to the campaign if at least one of their references to a `start_pos_*` table points to a row
18//!   of the campaign (a character of one of its factions, a link from one of its characters to a settlement of
19//!   another campaign...). This runs until no more rows join.
20//! - Rows of tables without such references belong to the campaign if one of its rows references them (deal actions
21//!   of its diplomatic deals). Not seen in vanilla data, where the tables they're in are empty.
22//!
23//! Verified on Attila's main campaign: from the Assembly Kit's tables, the rows kept are exactly the ones BOB exports.
24
25use std::collections::{HashMap, HashSet};
26
27use rpfm_lib::error::Result;
28use rpfm_lib::files::db::DB;
29use rpfm_lib::schema::Schema;
30
31use super::tables::StartposTables;
32
33#[cfg(test)] #[path = "split_test.rs"] mod split_test;
34
35/// Prefix of the tables split by campaign.
36const STARTPOS_TABLE_PREFIX: &str = "start_pos_";
37
38/// Column of the tables that say which campaign their rows belong to.
39const CAMPAIGN_COLUMN: &str = "campaign";
40
41//---------------------------------------------------------------------------//
42//                              Types
43//---------------------------------------------------------------------------//
44
45/// The rows of the `start_pos_*` tables that belong to a campaign.
46#[derive(Debug, Clone, Default)]
47pub struct CampaignRows {
48
49    /// Campaign key.
50    campaign: String,
51
52    /// References of each table to other `start_pos_*` tables: (column, referenced table, referenced column).
53    references: HashMap<String, Vec<(String, String, String)>>,
54
55    /// Values of the referenced columns in the campaign's rows, by (table, column).
56    values: HashMap<(String, String), HashSet<String>>,
57
58    /// Values the campaign's rows reference in tables without references of their own, by (table, column).
59    referenced: HashMap<(String, String), HashSet<String>>,
60}
61
62//---------------------------------------------------------------------------//
63//                              Implementations
64//---------------------------------------------------------------------------//
65
66impl CampaignRows {
67
68    /// Finds the rows of the `start_pos_*` tables that belong to a campaign.
69    ///
70    /// # Arguments
71    ///
72    /// * `tables` - Every row the build sees: the game's and the pack's.
73    /// * `schema` - Schema with the references between the tables.
74    /// * `campaign` - Campaign key.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if a table lacks a column its definition has.
79    pub fn new(tables: &StartposTables, schema: &Schema, campaign: &str) -> Result<Self> {
80        let mut rows = Self { campaign: campaign.to_owned(), references: references(schema), ..Default::default() };
81        let referenced = rows.references.values().flatten().map(|(_, table, column)| (table.to_owned(), column.to_owned())).collect::<HashSet<_>>();
82
83        // Rows join as the rows they reference do, so this repeats until none joins.
84        loop {
85            let mut values = HashMap::<(String, String), HashSet<String>>::new();
86            for name in rows.references.keys() {
87                let Ok(table) = tables.table(name) else { continue };
88                for row in table.rows() {
89                    let value = |column: &str| row.str(column).ok().map(|value| value.into_owned());
90                    if !rows.belongs(name, value) {
91                        continue;
92                    }
93                    for (table_name, column) in referenced.iter().filter(|(table_name, _)| table_name == name) {
94                        if let Ok(value) = row.str(column) {
95                            values.entry((table_name.to_owned(), column.to_owned())).or_default().insert(value.into_owned());
96                        }
97                    }
98                }
99            }
100
101            let joined = values.values().map(HashSet::len).sum::<usize>() > rows.values.values().map(HashSet::len).sum::<usize>();
102            rows.values = values;
103            if !joined {
104                break;
105            }
106        }
107
108        rows.add_referenced_rows(tables)?;
109        Ok(rows)
110    }
111
112    /// Returns if a row of a table belongs to the campaign.
113    ///
114    /// # Arguments
115    ///
116    /// * `table` - Table name, without the `_tables` suffix.
117    /// * `value` - Returns the value of a column of the row, or `None` if the table has no such column.
118    pub fn belongs(&self, table: &str, value: impl Fn(&str) -> Option<String>) -> bool {
119        if let Some(campaign) = value(CAMPAIGN_COLUMN) {
120            return campaign == self.campaign;
121        }
122
123        let references = self.references.get(table).map(Vec::as_slice).unwrap_or_default();
124        if references.is_empty() {
125            return self.referenced.iter()
126                .filter(|((referenced_table, _), _)| referenced_table == table)
127                .any(|((_, column), values)| value(column).is_some_and(|value| values.contains(&value)));
128        }
129
130        references.iter().any(|(column, target_table, target_column)| {
131            value(column)
132                .filter(|value| !value.is_empty())
133                .is_some_and(|value| self.values.get(&(target_table.to_owned(), target_column.to_owned())).is_some_and(|values| values.contains(&value)))
134        })
135    }
136
137    /// Removes the rows of a `start_pos_*` table that don't belong to the campaign.
138    ///
139    /// # Arguments
140    ///
141    /// * `db` - The table. Tables that aren't `start_pos_*` ones are left as they are.
142    ///
143    /// # Returns
144    ///
145    /// The number of rows removed.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the rows can't be set back.
150    pub fn retain(&self, db: &mut DB) -> Result<usize> {
151        let name = db.table_name_without_tables();
152        if !name.starts_with(STARTPOS_TABLE_PREFIX) {
153            return Ok(0);
154        }
155
156        let positions = db.definition().fields_processed().iter().enumerate()
157            .map(|(position, field)| (field.name().to_owned(), position))
158            .collect::<HashMap<_, _>>();
159        let rows = db.data().to_vec();
160        let kept = rows.iter()
161            .filter(|row| self.belongs(&name, |column| positions.get(column).map(|position| row[*position].data_to_string().into_owned())))
162            .cloned()
163            .collect::<Vec<_>>();
164
165        let removed = rows.len() - kept.len();
166        if removed > 0 {
167            db.set_data(&kept)?;
168        }
169        Ok(removed)
170    }
171
172    /// Adds the values of the rows of tables without references that the campaign's rows reference.
173    fn add_referenced_rows(&mut self, tables: &StartposTables) -> Result<()> {
174        let mut added = HashMap::<(String, String), HashSet<String>>::new();
175        for (name, references) in &self.references {
176            let Ok(table) = tables.table(name) else { continue };
177            let targets = references.iter().filter(|(_, target, _)| self.references.get(target).is_none_or(Vec::is_empty)).collect::<Vec<_>>();
178            if targets.is_empty() {
179                continue;
180            }
181            for row in table.rows() {
182                if !self.belongs(name, |column| row.str(column).ok().map(|value| value.into_owned())) {
183                    continue;
184                }
185                for (column, target_table, target_column) in &targets {
186                    let value = row.str(column)?;
187                    if !value.is_empty() {
188                        added.entry((target_table.to_owned(), target_column.to_owned())).or_default().insert(value.into_owned());
189                    }
190                }
191            }
192        }
193        self.referenced = added;
194        Ok(())
195    }
196}
197
198/// Returns the references of every `start_pos_*` table of a schema to other `start_pos_*` tables, by table name
199/// (without the `_tables` suffix), from the newest definition of each table.
200fn references(schema: &Schema) -> HashMap<String, Vec<(String, String, String)>> {
201    schema.definitions().iter()
202        .filter(|(name, _)| name.starts_with(STARTPOS_TABLE_PREFIX))
203        .filter_map(|(name, definitions)| Some((name, definitions.iter().max_by_key(|definition| *definition.version())?)))
204        .map(|(name, definition)| {
205            let references = definition.fields_processed().iter()
206                .filter_map(|field| {
207                    let (table, column) = field.is_reference(Some(definition.patches()))?;
208                    table.starts_with(STARTPOS_TABLE_PREFIX).then(|| (field.name().to_owned(), table, column))
209                })
210                .collect();
211            (name.trim_end_matches("_tables").to_owned(), references)
212        })
213        .collect()
214}