1use std::collections::{HashMap, HashSet};
29use std::fmt;
30
31use rpfm_lib::schema::{Definition, Schema};
32
33use super::cai::technologies::faction_technologies;
34use super::tables::{Row, StartposTables};
35use super::world::{CampaignIndex, NAME_COLUMNS};
36
37#[cfg(test)] #[path = "validation_test.rs"] mod validation_test;
38
39pub const FATAL_REFERENCES: [(&str, &str); 22] = [
42 ("start_pos_characters", "faction"),
43 ("start_pos_characters", "Type"),
44 ("start_pos_characters", "Name"),
45 ("start_pos_characters", "Surname"),
46 ("start_pos_characters", "clan_name"),
47 ("start_pos_characters", "other_name"),
48 ("start_pos_characters", "political_party"),
49 ("start_pos_characters", "override_general_unit"),
50 ("start_pos_characters", "death_type"),
51 ("start_pos_character_traits", "trait_level"),
52 ("start_pos_land_units", "unit_type"),
53 ("start_pos_naval_units", "unit_type"),
54 ("start_pos_horde_details", "primary_building"),
55 ("start_pos_factions", "faction"),
56 ("start_pos_factions", "government_type"),
57 ("start_pos_factions", "state_religion"),
58 ("start_pos_factions", "cai_personality_group"),
59 ("start_pos_factions", "cdir_military_generator_config"),
60 ("start_pos_regions", "region"),
61 ("start_pos_regions", "cultural_originator"),
62 ("start_pos_settlements", "region"),
63 ("start_pos_settlements", "primary_building"),
64];
65
66pub const ADULT_AGE: f64 = 16.0;
68
69const VASSAL_STANCE: &str = "vassal";
71
72const STARTPOS_TABLE_PREFIX: &str = "start_pos_";
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
81pub enum Severity {
82
83 Error,
85
86 Warning,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct StartposIssue {
93
94 pub severity: Severity,
96
97 pub table: String,
99
100 pub row: String,
102
103 pub column: Option<String>,
105
106 pub message: String,
108}
109
110impl fmt::Display for StartposIssue {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 let severity = match self.severity {
117 Severity::Error => "Error",
118 Severity::Warning => "Warning",
119 };
120 match &self.column {
121 Some(column) => write!(f, "{severity}: {}, row {}, column {column}: {}", self.table, self.row, self.message),
122 None => write!(f, "{severity}: {}, row {}: {}", self.table, self.row, self.message),
123 }
124 }
125}
126
127pub fn referenced_tables(schema: &Schema) -> HashSet<String> {
129 schema.definitions().iter()
130 .filter(|(name, _)| name.starts_with(STARTPOS_TABLE_PREFIX))
131 .filter_map(|(_, definitions)| latest(definitions))
132 .flat_map(|definition| definition.fields_processed().iter().filter_map(|field| field.is_reference(Some(definition.patches()))))
133 .map(|(table, _)| table)
134 .collect()
135}
136
137pub fn validate(tables: &StartposTables, schema: &Schema, campaign: &str) -> Vec<StartposIssue> {
150 let mut issues = vec![];
151 validate_campaign(tables, campaign, &mut issues);
152 validate_references(tables, schema, &mut issues);
153 validate_generals_pool(tables, &mut issues);
154 validate_adoptions(tables, &mut issues);
155 validate_name_kinds(tables, &mut issues);
156 validate_technologies(tables, campaign, &mut issues);
157 validate_vassal_pacts(tables, &mut issues);
158 issues.sort_by_key(|issue| issue.severity);
159 issues
160}
161
162fn validate_campaign(tables: &StartposTables, campaign: &str, issues: &mut Vec<StartposIssue>) {
164 let exists = tables.table("campaigns").is_ok_and(|table| table.rows().any(|row| row.str("campaign_name").is_ok_and(|name| name == campaign)));
165 if !exists {
166 issues.push(StartposIssue {
167 severity: Severity::Error,
168 table: "campaigns".to_owned(),
169 row: campaign.to_owned(),
170 column: None,
171 message: format!("the campaign {campaign} isn't in the table, so the game skips the build."),
172 });
173 }
174}
175
176fn validate_references(tables: &StartposTables, schema: &Schema, issues: &mut Vec<StartposIssue>) {
178 let mut values = HashMap::<(String, String), Option<HashSet<String>>>::new();
179 let mut names = schema.definitions().keys().filter(|name| name.starts_with(STARTPOS_TABLE_PREFIX)).collect::<Vec<_>>();
180 names.sort();
181
182 for name in names {
183 let table_name = name.trim_end_matches("_tables");
184 let (Ok(table), Some(definition)) = (tables.table(table_name), latest(&schema.definitions()[name])) else { continue };
185 let fields = definition.fields_processed();
186 let keys = fields.iter().filter(|field| field.is_key(Some(definition.patches()))).map(|field| field.name().to_owned()).collect::<Vec<_>>();
187
188 for field in fields.iter() {
189 let Some((target_table, target_column)) = field.is_reference(Some(definition.patches())) else { continue };
190 if table.column(field.name()).is_err() {
191 continue;
192 }
193 let targets = values.entry((target_table.to_owned(), target_column.to_owned()))
194 .or_insert_with(|| column_values(tables, &target_table, &target_column));
195 let Some(targets) = targets else { continue };
196
197 let fatal = FATAL_REFERENCES.contains(&(table_name, field.name()));
198 for (position, row) in table.rows().enumerate() {
199 let Ok(value) = row.str(field.name()) else { continue };
200 if value.is_empty() || targets.contains(value.as_ref()) {
201 continue;
202 }
203
204 issues.push(StartposIssue {
205 severity: if fatal { Severity::Error } else { Severity::Warning },
206 table: table_name.to_owned(),
207 row: row_name(&row, &keys, position),
208 column: Some(field.name().to_owned()),
209 message: format!("{value} isn't in {target_table}.{target_column}. {}", if fatal {
210 "The game quits or hangs while building the startpos."
211 } else {
212 "The game drops the row or ignores the value."
213 }),
214 });
215 }
216 }
217 }
218}
219
220fn validate_generals_pool(tables: &StartposTables, issues: &mut Vec<StartposIssue>) {
222 let Ok(characters) = tables.table("start_pos_characters") else { return };
223 let placed = column_values(tables, "start_pos_character_to_settlements", "character").unwrap_or_default();
224 let generals = column_values(tables, "start_pos_land_units", "general").unwrap_or_default();
225
226 for (position, row) in characters.rows().enumerate() {
227 let (Ok(id), Ok(true)) = (row.str("ID"), row.bool("is_in_generals_pool")) else { continue };
228 if placed.contains(id.as_ref()) && generals.contains(id.as_ref()) {
229 issues.push(StartposIssue {
230 severity: Severity::Error,
231 table: "start_pos_characters".to_owned(),
232 row: row_name(&row, &["ID".to_owned()], position),
233 column: Some("is_in_generals_pool".to_owned()),
234 message: "the character is in the generals pool, but also placed on the map with an army. The game quits while building the startpos.".to_owned(),
235 });
236 }
237 }
238}
239
240fn validate_adoptions(tables: &StartposTables, issues: &mut Vec<StartposIssue>) {
242 let (Ok(relationships), Ok(characters)) = (tables.table("start_pos_family_relationships"), tables.table("start_pos_characters")) else { return };
243 let mut ages = HashMap::new();
244 let mut progenitors = HashSet::new();
245 for row in characters.rows() {
246 let Ok(id) = row.str("ID") else { continue };
247 if let Ok(age) = row.number("Age") {
248 ages.insert(id.to_string(), age);
249 }
250 if row.bool("progenitor").unwrap_or_default() {
251 progenitors.insert(id.into_owned());
252 }
253 }
254
255 for (position, row) in relationships.rows().enumerate() {
256 let (Ok(child), Ok(parent)) = (row.str("character"), row.str("related_to")) else { continue };
257 let is_adoption = row.str("relationship").is_ok_and(|relationship| relationship == "child") && row.bool("adopted").unwrap_or_default();
258 let Some(age) = ages.get(child.as_ref()).filter(|age| **age < ADULT_AGE) else { continue };
259 if is_adoption && progenitors.contains(parent.as_ref()) {
260 issues.push(StartposIssue {
261 severity: Severity::Error,
262 table: "start_pos_family_relationships".to_owned(),
263 row: row_name(&row, &["character".to_owned(), "related_to".to_owned()], position),
264 column: Some("adopted".to_owned()),
265 message: format!("the progenitor {parent} adopts {child}, who is {age} years old. The game crashes when a progenitor adopts a child under {ADULT_AGE}."),
266 });
267 }
268 }
269}
270
271fn validate_name_kinds(tables: &StartposTables, issues: &mut Vec<StartposIssue>) {
273 let (Ok(names), Ok(characters)) = (tables.table("names"), tables.table("start_pos_characters")) else { return };
274 let mut kinds = HashMap::<String, HashSet<i64>>::new();
275 for row in names.rows() {
276 if let (Ok(id), Ok(kind)) = (row.str("id"), row.int("type")) {
277 kinds.entry(id.into_owned()).or_default().insert(kind);
278 }
279 }
280
281 for (position, row) in characters.rows().enumerate() {
282 for (column, kind) in NAME_COLUMNS.iter().zip(0..) {
283 let Ok(name) = row.str(column) else { continue };
284 let Some(name_kinds) = kinds.get(name.as_ref()) else { continue };
285 if !name_kinds.contains(&kind) {
286 issues.push(StartposIssue {
287 severity: Severity::Warning,
288 table: "start_pos_characters".to_owned(),
289 row: row_name(&row, &["ID".to_owned()], position),
290 column: Some(column.to_string()),
291 message: format!("the name {name} isn't of type {kind} in names, so the game drops it."),
292 });
293 }
294 }
295 }
296}
297
298fn validate_technologies(tables: &StartposTables, campaign: &str, issues: &mut Vec<StartposIssue>) {
300 let (Ok(technologies), Ok(index)) = (tables.table("start_pos_technologies"), CampaignIndex::new(tables, campaign)) else { return };
301 let mut trees = HashMap::new();
302 for (position, row) in technologies.rows().enumerate() {
303 let (Ok(faction_id), Ok(technology)) = (row.str("faction"), row.str("technology")) else { continue };
304 let Some(faction) = index.faction_by_id(&faction_id) else { continue };
305 let tree = trees.entry(faction.key().to_owned()).or_insert_with(|| faction_technologies(tables, campaign, faction).unwrap_or_default());
306 if !tree.iter().any(|known| *known == technology) {
307 issues.push(StartposIssue {
308 severity: Severity::Warning,
309 table: "start_pos_technologies".to_owned(),
310 row: row_name(&row, &["faction".to_owned(), "technology".to_owned()], position),
311 column: Some("technology".to_owned()),
312 message: format!("{technology} isn't in the technology tree of {}, so the game drops it.", faction.key()),
313 });
314 }
315 }
316}
317
318fn validate_vassal_pacts(tables: &StartposTables, issues: &mut Vec<StartposIssue>) {
320 let Ok(diplomacy) = tables.table("start_pos_diplomacy") else { return };
321 let vassals = diplomacy.rows()
322 .filter(|row| row.str("stance").is_ok_and(|stance| stance == VASSAL_STANCE))
323 .filter_map(|row| row.str("faction1").ok().map(|faction| faction.into_owned()))
324 .collect::<HashSet<_>>();
325
326 for (position, row) in diplomacy.rows().enumerate() {
327 let (Ok(first), Ok(second), Ok(stance)) = (row.str("faction1"), row.str("faction2"), row.str("stance")) else { continue };
328 if stance == VASSAL_STANCE || !(vassals.contains(first.as_ref()) || vassals.contains(second.as_ref())) {
329 continue;
330 }
331 for column in ["non_aggression_pact", "grants_military_access"] {
332 if row.bool(column).unwrap_or_default() {
333 issues.push(StartposIssue {
334 severity: Severity::Warning,
335 table: "start_pos_diplomacy".to_owned(),
336 row: row_name(&row, &["key".to_owned()], position),
337 column: Some(column.to_owned()),
338 message: "one of the factions is a vassal, and vassals can't have non-aggression pacts or military access with other factions, so the game drops it.".to_owned(),
339 });
340 }
341 }
342 }
343}
344
345fn column_values(tables: &StartposTables, table: &str, column: &str) -> Option<HashSet<String>> {
347 let table = tables.table(table).ok()?;
348 table.column(column).ok()?;
349 Some(table.rows().filter_map(|row| row.str(column).ok().map(|value| value.into_owned())).collect())
350}
351
352fn row_name(row: &Row, keys: &[String], position: usize) -> String {
354 let values = keys.iter().filter_map(|key| row.str(key).ok().map(|value| value.into_owned())).collect::<Vec<_>>();
355 if values.is_empty() { format!("#{}", position + 1) } else { values.join("|") }
356}
357
358fn latest(definitions: &[Definition]) -> Option<&Definition> {
360 definitions.iter().max_by_key(|definition| *definition.version())
361}