feat(resolve): implement species->template->character override chain
When a template has a species_base, its fields are loaded first as the lowest priority layer. Override order: species -> includes -> template -> character (last-one-wins for field values).
This commit is contained in:
@@ -59,7 +59,13 @@ pub fn resolve_template_includes(
|
||||
|
||||
let mut merged_fields = Vec::new();
|
||||
|
||||
// Resolve all includes first
|
||||
// Load species fields first (lowest priority in the chain)
|
||||
if let Some(species_name) = &template.species_base {
|
||||
let species_fields = resolve_species_fields(species_name, all_files, name_table)?;
|
||||
merged_fields = merge_field_lists(merged_fields, species_fields);
|
||||
}
|
||||
|
||||
// Resolve all includes next
|
||||
for include_name in &template.includes {
|
||||
// Look up the included template
|
||||
let entry = name_table
|
||||
@@ -103,6 +109,37 @@ pub fn resolve_template_includes(
|
||||
Ok(merged_fields)
|
||||
}
|
||||
|
||||
/// Look up a species by name and return its fields
|
||||
///
|
||||
/// Species fields form the base layer of the override chain:
|
||||
/// species -> includes -> template -> character
|
||||
fn resolve_species_fields(
|
||||
species_name: &str,
|
||||
all_files: &[crate::syntax::ast::File],
|
||||
name_table: &NameTable,
|
||||
) -> Result<Vec<Field>> {
|
||||
let entry = name_table
|
||||
.lookup(std::slice::from_ref(&species_name.to_string()))
|
||||
.ok_or_else(|| ResolveError::NameNotFound {
|
||||
name: species_name.to_string(),
|
||||
suggestion: name_table.find_suggestion(species_name),
|
||||
})?;
|
||||
|
||||
match &all_files[entry.file_index].declarations[entry.decl_index] {
|
||||
| Declaration::Species(s) => Ok(s.fields.clone()),
|
||||
| _ => Err(ResolveError::ValidationError {
|
||||
message: format!(
|
||||
"Cannot use '{}' as species base: it's not a species",
|
||||
species_name
|
||||
),
|
||||
help: Some(format!(
|
||||
"The ':' in template declaration specifies a species base type. '{}' must be a species declaration.",
|
||||
species_name
|
||||
)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Resource Linking Merge =====
|
||||
|
||||
/// Merge behavior links from templates into character
|
||||
@@ -852,4 +889,189 @@ mod tests {
|
||||
assert!(result.iter().any(|f| f.name == "age"));
|
||||
assert!(result.iter().any(|f| f.name == "health"));
|
||||
}
|
||||
|
||||
// ===== Species Override Chain Tests =====
|
||||
|
||||
use crate::syntax::ast::Species;
|
||||
|
||||
fn make_species(name: &str, fields: Vec<Field>) -> Species {
|
||||
Species {
|
||||
name: name.to_string(),
|
||||
includes: vec![],
|
||||
fields,
|
||||
span: Span::new(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_template_with_species(
|
||||
name: &str,
|
||||
species_base: &str,
|
||||
fields: Vec<Field>,
|
||||
includes: Vec<&str>,
|
||||
) -> Template {
|
||||
Template {
|
||||
name: name.to_string(),
|
||||
species_base: Some(species_base.to_string()),
|
||||
fields,
|
||||
includes: includes.iter().map(|s| s.to_string()).collect(),
|
||||
strict: false,
|
||||
uses_behaviors: None,
|
||||
uses_schedule: None,
|
||||
span: Span::new(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_species_fields_as_base_layer() {
|
||||
let species = make_species("Human", vec![make_field("lifespan", 80)]);
|
||||
let template =
|
||||
make_template_with_species("Person", "Human", vec![make_field("age", 30)], vec![]);
|
||||
|
||||
let declarations = vec![
|
||||
Declaration::Species(species),
|
||||
Declaration::Template(template.clone()),
|
||||
];
|
||||
let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
let files = vec![make_file(declarations)];
|
||||
let result =
|
||||
resolve_template_includes(&template, &files, &name_table, &mut visited).unwrap();
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result
|
||||
.iter()
|
||||
.any(|f| f.name == "lifespan" && f.value == Value::Number(80)));
|
||||
assert!(result
|
||||
.iter()
|
||||
.any(|f| f.name == "age" && f.value == Value::Number(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_three_tier_override_chain() {
|
||||
// Species provides default, template overrides, character overrides
|
||||
let species = make_species(
|
||||
"Human",
|
||||
vec![make_field("age", 0), make_field("lifespan", 80)],
|
||||
);
|
||||
let template = make_template_with_species(
|
||||
"Person",
|
||||
"Human",
|
||||
vec![make_field("age", 25)], // Override species default
|
||||
vec![],
|
||||
);
|
||||
let character = make_character(
|
||||
"Martha",
|
||||
vec![make_field("age", 34)], // Override template default
|
||||
vec!["Person"],
|
||||
);
|
||||
|
||||
let declarations = vec![
|
||||
Declaration::Species(species),
|
||||
Declaration::Template(template),
|
||||
Declaration::Character(character.clone()),
|
||||
];
|
||||
let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap();
|
||||
|
||||
let files = vec![make_file(declarations)];
|
||||
let result = merge_character_templates(&character, &files, &name_table).unwrap();
|
||||
|
||||
// age: species(0) -> template(25) -> character(34) = 34
|
||||
let age = result.iter().find(|f| f.name == "age").unwrap();
|
||||
assert_eq!(age.value, Value::Number(34));
|
||||
|
||||
// lifespan: species(80) -> template(no override) -> character(no override) = 80
|
||||
let lifespan = result.iter().find(|f| f.name == "lifespan").unwrap();
|
||||
assert_eq!(lifespan.value, Value::Number(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_species_with_includes_override_order() {
|
||||
// Override order: species -> includes -> template
|
||||
let species = make_species("Human", vec![make_field("age", 0), make_field("type", 1)]);
|
||||
let base_template = make_template(
|
||||
"Being",
|
||||
vec![make_field("age", 10)], // Override species default
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
let template = make_template_with_species(
|
||||
"Person",
|
||||
"Human",
|
||||
vec![make_field("age", 25)], // Override include default
|
||||
vec!["Being"],
|
||||
);
|
||||
|
||||
let declarations = vec![
|
||||
Declaration::Species(species),
|
||||
Declaration::Template(base_template),
|
||||
Declaration::Template(template.clone()),
|
||||
];
|
||||
let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
let files = vec![make_file(declarations)];
|
||||
let result =
|
||||
resolve_template_includes(&template, &files, &name_table, &mut visited).unwrap();
|
||||
|
||||
// age: species(0) -> include Being(10) -> template(25) = 25
|
||||
let age = result.iter().find(|f| f.name == "age").unwrap();
|
||||
assert_eq!(age.value, Value::Number(25));
|
||||
|
||||
// type: species(1) -> include Being(no override) -> template(no override) = 1
|
||||
let type_field = result.iter().find(|f| f.name == "type").unwrap();
|
||||
assert_eq!(type_field.value, Value::Number(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_species_base_not_found() {
|
||||
let template = make_template_with_species(
|
||||
"Person",
|
||||
"NonExistent",
|
||||
vec![make_field("age", 30)],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let declarations = vec![Declaration::Template(template.clone())];
|
||||
let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
let files = vec![make_file(declarations)];
|
||||
let result = resolve_template_includes(&template, &files, &name_table, &mut visited);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_species_base_wrong_type() {
|
||||
// Try to use a Character as species base - should fail
|
||||
let character = Character {
|
||||
name: "NotASpecies".to_string(),
|
||||
species: None,
|
||||
fields: vec![make_field("age", 25)],
|
||||
template: None,
|
||||
uses_behaviors: None,
|
||||
uses_schedule: None,
|
||||
span: Span::new(0, 10),
|
||||
};
|
||||
let template = make_template_with_species(
|
||||
"Person",
|
||||
"NotASpecies",
|
||||
vec![make_field("age", 30)],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let declarations = vec![
|
||||
Declaration::Character(character),
|
||||
Declaration::Template(template.clone()),
|
||||
];
|
||||
let name_table = NameTable::from_file(&make_file(declarations.clone())).unwrap();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
let files = vec![make_file(declarations)];
|
||||
let result = resolve_template_includes(&template, &files, &name_table, &mut visited);
|
||||
assert!(result.is_err());
|
||||
if let Err(ResolveError::ValidationError { message, .. }) = result {
|
||||
assert!(message.contains("not a species"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user