#![allow(dead_code)]
use crate::game::eu4::Eu4;
use crate::game::eu4::content::MergeKeySource;
use crate::game::eu4::script::parser::{AstStatement, AstValue};
use crate::game::eu4::script::{ParsedScriptFile, is_decision_container_key};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use crate::input::ResolvedInputContributor;
use crate::model::GamePathBuf;
use super::normalize::normalize_defines_file;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyContributor {
pub mod_id: String,
pub file_path: GamePathBuf,
pub precedence: usize,
pub is_base_game: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FamilyKeyConflict {
pub key: String,
pub family_id: String,
pub contributors: Vec<KeyContributor>,
}
#[derive(Clone, Debug, Default)]
pub struct FamilyKeyIndex {
pub family_id: String,
pub entries: HashMap<String, Vec<KeyContributor>>,
}
fn extract_keys(parsed: &ParsedScriptFile, merge_key_source: MergeKeySource) -> Vec<String> {
match merge_key_source {
MergeKeySource::AssignmentKey => extract_assignment_keys(parsed),
MergeKeySource::FieldValue(field) => extract_field_value_keys(parsed, field),
MergeKeySource::ContainerChildKey => extract_container_child_keys(parsed),
MergeKeySource::ContainerChildFieldValue {
containers,
child_key_field,
child_types,
} => extract_container_child_field_value_keys(
parsed,
containers,
child_key_field,
child_types,
),
MergeKeySource::ChildFieldValue { .. } => extract_assignment_keys(parsed),
MergeKeySource::LeafPath => extract_defines_keys(parsed),
}
}
fn extract_assignment_keys(parsed: &ParsedScriptFile) -> Vec<String> {
parsed
.ast
.statements
.iter()
.filter_map(|stmt| match stmt {
AstStatement::Assignment {
key,
value: AstValue::Block { .. },
..
} => Some(key.clone()),
_ => None,
})
.collect()
}
fn extract_field_value_keys(parsed: &ParsedScriptFile, field: &str) -> Vec<String> {
parsed
.ast
.statements
.iter()
.filter_map(|stmt| {
let AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
} = stmt
else {
return None;
};
scalar_assignment_value(items, field)
})
.collect()
}
fn extract_container_child_keys(parsed: &ParsedScriptFile) -> Vec<String> {
let mut keys = Vec::new();
for stmt in &parsed.ast.statements {
let AstStatement::Assignment {
key,
value: AstValue::Block { items, .. },
..
} = stmt
else {
continue;
};
if !is_decision_container_key(key) {
continue;
}
for item in items {
if let AstStatement::Assignment {
key: child_key,
value: AstValue::Block { .. },
..
} = item
{
keys.push(child_key.clone());
}
}
}
keys
}
fn extract_container_child_field_value_keys(
parsed: &ParsedScriptFile,
containers: &[&str],
child_key_field: &str,
child_types: &[&str],
) -> Vec<String> {
let mut keys = Vec::new();
for stmt in &parsed.ast.statements {
let AstStatement::Assignment { key, value, .. } = stmt else {
continue;
};
if !containers.contains(&key.as_str()) {
keys.push(key.clone());
continue;
}
let AstValue::Block { items, .. } = value else {
continue;
};
for child in items {
if let Some(key) = container_child_field_value_key(child, child_key_field, child_types)
{
keys.push(key);
}
}
}
keys
}
fn container_child_field_value_key(
stmt: &AstStatement,
child_key_field: &str,
child_types: &[&str],
) -> Option<String> {
let AstStatement::Assignment { key, value, .. } = stmt else {
return None;
};
if (child_types.is_empty() || child_types.contains(&key.as_str()))
&& let AstValue::Block { items, .. } = value
&& let Some(field_value) = scalar_assignment_value(items, child_key_field)
{
return Some(format!("{key}:{field_value}"));
}
Some(key.clone())
}
fn extract_defines_keys(parsed: &ParsedScriptFile) -> Vec<String> {
normalize_defines_file(parsed)
.map(|fragments| fragments.into_iter().map(|f| f.merge_key).collect())
.unwrap_or_default()
}
fn scalar_assignment_value(items: &[AstStatement], expected_key: &str) -> Option<String> {
for item in items {
let AstStatement::Assignment { key, value, .. } = item else {
continue;
};
if key != expected_key {
continue;
}
if let AstValue::Scalar { value, .. } = value {
return Some(value.as_text());
}
}
None
}
pub(crate) fn build_family_key_index(
family_id: &str,
merge_key_source: MergeKeySource,
contributors_by_path: &BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>,
_profile: &Eu4,
) -> FamilyKeyIndex {
let mut index = FamilyKeyIndex {
family_id: family_id.to_string(),
..Default::default()
};
for contributors in contributors_by_path.values() {
for contributor in contributors {
let parsed = crate::game::eu4::script::parse_script_file(
&contributor.mod_id,
&contributor.root_path,
&contributor.relative_path,
);
let keys = extract_keys(&parsed, merge_key_source);
for key in keys {
index.entries.entry(key).or_default().push(KeyContributor {
mod_id: contributor.mod_id.clone(),
file_path: contributor.relative_path.clone(),
precedence: contributor.precedence,
is_base_game: contributor.is_base_game,
});
}
}
}
index
}
pub fn detect_key_conflicts(index: &FamilyKeyIndex) -> Vec<FamilyKeyConflict> {
let mut conflicts = Vec::new();
for (key, contributors) in &index.entries {
let distinct_mod_count = contributors
.iter()
.filter(|c| !c.is_base_game)
.map(|c| &c.mod_id)
.collect::<std::collections::HashSet<_>>()
.len();
if distinct_mod_count >= 2 {
conflicts.push(FamilyKeyConflict {
key: key.clone(),
family_id: index.family_id.clone(),
contributors: contributors.clone(),
});
}
}
conflicts.sort_by(|a, b| a.key.cmp(&b.key));
conflicts
}
pub(crate) fn group_by_family(
file_inventory: &BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>,
profile: &Eu4,
) -> HashMap<String, BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>> {
let mut grouped: HashMap<String, BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>> =
HashMap::new();
for (rel_path, contributors) in file_inventory {
if let Some(descriptor) = profile.classify_content_family(rel_path) {
grouped
.entry(descriptor.id.as_str().to_string())
.or_default()
.insert(rel_path.clone(), contributors.clone());
}
}
grouped
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_contributor(mod_id: &str, precedence: usize, is_base_game: bool) -> KeyContributor {
KeyContributor {
mod_id: mod_id.to_string(),
file_path: GamePathBuf::parse(&format!("common/test/{mod_id}.txt"))
.expect("valid game path"),
precedence,
is_base_game,
}
}
fn make_index(family_id: &str, entries: Vec<(&str, Vec<KeyContributor>)>) -> FamilyKeyIndex {
FamilyKeyIndex {
family_id: family_id.to_string(),
entries: entries
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect(),
}
}
#[test]
fn single_mod_no_conflicts() {
let index = make_index(
"scripted_triggers",
vec![
("trigger_a", vec![make_contributor("mod_a", 1, false)]),
("trigger_b", vec![make_contributor("mod_a", 1, false)]),
],
);
let conflicts = detect_key_conflicts(&index);
assert!(
conflicts.is_empty(),
"single mod should produce no conflicts"
);
}
#[test]
fn two_mods_same_key_same_file_detected() {
let index = make_index(
"scripted_triggers",
vec![(
"my_trigger",
vec![
KeyContributor {
mod_id: "mod_a".to_string(),
file_path: GamePathBuf::parse("common/scripted_triggers/shared.txt")
.expect("valid game path"),
precedence: 1,
is_base_game: false,
},
KeyContributor {
mod_id: "mod_b".to_string(),
file_path: GamePathBuf::parse("common/scripted_triggers/shared.txt")
.expect("valid game path"),
precedence: 2,
is_base_game: false,
},
],
)],
);
let conflicts = detect_key_conflicts(&index);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].key, "my_trigger");
assert_eq!(conflicts[0].contributors.len(), 2);
}
#[test]
fn two_mods_same_key_different_files_detected() {
let index = make_index(
"scripted_triggers",
vec![(
"my_trigger",
vec![
KeyContributor {
mod_id: "mod_a".to_string(),
file_path: GamePathBuf::parse("common/scripted_triggers/a.txt")
.expect("valid game path"),
precedence: 1,
is_base_game: false,
},
KeyContributor {
mod_id: "mod_b".to_string(),
file_path: GamePathBuf::parse("common/scripted_triggers/b.txt")
.expect("valid game path"),
precedence: 2,
is_base_game: false,
},
],
)],
);
let conflicts = detect_key_conflicts(&index);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].key, "my_trigger");
let paths: Vec<&str> = conflicts[0]
.contributors
.iter()
.map(|c| c.file_path.as_str())
.collect();
assert!(paths.contains(&"common/scripted_triggers/a.txt"));
assert!(paths.contains(&"common/scripted_triggers/b.txt"));
}
#[test]
fn base_game_plus_one_mod_not_a_conflict() {
let index = make_index(
"scripted_triggers",
vec![(
"vanilla_trigger",
vec![
make_contributor("base_game", 0, true),
make_contributor("mod_a", 1, false),
],
)],
);
let conflicts = detect_key_conflicts(&index);
assert!(
conflicts.is_empty(),
"base game + one mod should not be a conflict (mod overrides base)"
);
}
#[test]
fn base_game_plus_two_mods_is_conflict() {
let index = make_index(
"diplomatic_actions",
vec![(
"declarewar",
vec![
make_contributor("base_game", 0, true),
make_contributor("imperial_circles", 1, false),
make_contributor("europa_expanded", 2, false),
],
)],
);
let conflicts = detect_key_conflicts(&index);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].key, "declarewar");
assert_eq!(conflicts[0].contributors.len(), 3);
}
#[test]
fn group_by_family_groups_correctly() {
use crate::game::eu4::content::eu4;
let profile = eu4();
let mut inventory: BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>> = BTreeMap::new();
let game_path = |text: &str| GamePathBuf::parse(text).expect("valid game path");
let trigger_path = game_path("common/scripted_triggers/my_mod.txt");
let effect_path = game_path("common/scripted_effects/my_mod.txt");
let unclassified_path = game_path("some/random/file.txt");
let contributor = |relative_path: &GamePathBuf| ResolvedInputContributor {
mod_id: "test_mod".to_string(),
root_path: PathBuf::from("/mods/test"),
relative_path: relative_path.clone(),
precedence: 1,
is_base_game: false,
is_synthetic_base: false,
parse_ok_hint: None,
mod_hash: Some("hash-test_mod".to_string()),
};
for path in [&trigger_path, &effect_path, &unclassified_path] {
inventory.insert(path.clone(), vec![contributor(path)]);
}
let grouped = group_by_family(&inventory, profile);
for paths in grouped.values() {
assert!(
!paths.contains_key(&unclassified_path),
"unclassified path should not appear in any family"
);
}
let trigger_family = grouped
.values()
.find(|paths| paths.contains_key(&trigger_path));
assert!(
trigger_family.is_some(),
"scripted_triggers path should be grouped"
);
let effect_family = grouped
.values()
.find(|paths| paths.contains_key(&effect_path));
assert!(
effect_family.is_some(),
"scripted_effects path should be grouped"
);
}
#[test]
fn conflicts_are_sorted_by_key() {
let index = make_index(
"test",
vec![
(
"zebra",
vec![
make_contributor("mod_a", 1, false),
make_contributor("mod_b", 2, false),
],
),
(
"alpha",
vec![
make_contributor("mod_a", 1, false),
make_contributor("mod_b", 2, false),
],
),
],
);
let conflicts = detect_key_conflicts(&index);
assert_eq!(conflicts.len(), 2);
assert_eq!(conflicts[0].key, "alpha");
assert_eq!(conflicts[1].key, "zebra");
}
}