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:
2026-02-14 14:16:23 +00:00
parent 9e2cdea6f4
commit 5f1492a66a

View File

@@ -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"));
}
}
}