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}