From 5f1492a66ac6c579f089ec248cf0f90b26c2c388 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 14 Feb 2026 14:16:23 +0000 Subject: [PATCH] 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). --- src/resolve/merge.rs | 224 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 1 deletion(-) diff --git a/src/resolve/merge.rs b/src/resolve/merge.rs index 3cde3da..b59449a 100644 --- a/src/resolve/merge.rs +++ b/src/resolve/merge.rs @@ -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> { + 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) -> 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, + 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")); + } + } }