From 84b8e3d6d52e58e0af793a2c8cf424e7ab0b729d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:25:47 +0200 Subject: [PATCH 1/5] grammar-reader: read Aetheria's catalog into a GrammarBinding and keyed factions read_aetheria_catalog opens the catalog read-only, mirrors aetheria.verse_grammar, aetheria.verse_verb and aetheria.faction by integer key, and refuses unknown enums, bad verb or role names and a missing grammar by record key. Fixture written by Aetheria's own fixture writer at master 92b82e9a (provenance beside it). Also the NFD/NFC position sweep for import keys. Not yet built or run when committed. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria.rs | 267 +++++++++++++ crates/ghostlight/src/aetheria/tests.rs | 355 ++++++++++++++++++ crates/ghostlight/src/import_key_tests.rs | 49 +++ crates/ghostlight/src/lib.rs | 4 + .../tests/fixtures/aetheria-verse-catalog.cc | Bin 0 -> 6217 bytes .../aetheria-verse-catalog.provenance.txt | 16 + 6 files changed, 691 insertions(+) create mode 100644 crates/ghostlight/src/aetheria.rs create mode 100644 crates/ghostlight/src/aetheria/tests.rs create mode 100644 crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc create mode 100644 crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt diff --git a/crates/ghostlight/src/aetheria.rs b/crates/ghostlight/src/aetheria.rs new file mode 100644 index 0000000..0a826d6 --- /dev/null +++ b/crates/ghostlight/src/aetheria.rs @@ -0,0 +1,267 @@ +//! Aetheria's catalog file, read once into the two things Ghostlight takes from +//! it: the verse grammar, as a `GrammarBinding`, and the factions, as records +//! keyed for import. +//! +//! The catalog is Aetheria's: this reader opens it read-only and never writes +//! it, and it ignores every schema it does not mirror. The mirrors below decode +//! `aetheria.verse_grammar` v1, `aetheria.verse_verb` v1 and `aetheria.faction` +//! v1 by integer key: a MessagePack-keyed object is an array indexed by key, +//! nil in the gaps, so a field Aetheria adds later at a higher key is ignored +//! and a field it drops is an error naming the record and the key. A record +//! that does not decode, an enum value outside the mirror and a verb that +//! breaks the grammar's name or role rules are errors, never skipped rows. + +use crate::ImportKey; +use crate::grammar::{ + GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath, +}; +use cultcache_rs::SingleFileMessagePackBackingStore; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +const GRAMMAR_SCHEMA: &str = "aetheria.verse_grammar"; +const VERB_SCHEMA: &str = "aetheria.verse_verb"; +const FACTION_SCHEMA: &str = "aetheria.faction"; + +const VERB_NAME_MAX: usize = 48; +const ROLE_NAME_MAX: usize = 32; +/// The invoking subject, never a role. +const ACTOR_ROLE: &str = "actor"; + +/// What Ghostlight takes from Aetheria's catalog. +#[derive(Clone, Debug, PartialEq)] +pub struct AetheriaCatalog { + pub grammar: GrammarBinding, + /// In record-key order. + pub factions: Vec, +} + +/// One `aetheria.faction` record. `allegiance` is carried and read by no +/// consumer yet. +#[derive(Clone, Debug, PartialEq)] +pub struct AetheriaFaction { + pub key: ImportKey, + pub name: String, + pub short_name: String, + pub description: String, + pub allegiance: BTreeMap, +} + +/// Which of the grammar's name or role rules a verb broke. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum VerbRule { + #[error("verb name is not canonical")] + Name, + #[error("role name is not canonical")] + RoleName, + #[error("role name is reserved for the invoking subject")] + ReservedRole, + #[error("role name is declared twice")] + DuplicateRole, +} + +/// Why a catalog could not be read. Records are named by record key and fields +/// by integer key; no error carries a value the catalog held. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum CatalogReadError { + #[error("the catalog file cannot be read")] + Unreadable, + #[error("the catalog holds no aetheria.verse_grammar global")] + NoGrammar, + #[error("the catalog holds more than one aetheria.verse_grammar record")] + MultipleGrammars, + #[error("record {record:?} field {field} is missing or not the mirrored type")] + MalformedRecord { record: String, field: u8 }, + #[error("record {record:?} field {field} is not a known render path")] + UnknownRenderPath { record: String, field: u8 }, + #[error("record {record:?} field {field} is not a known referent kind")] + UnknownReferentKind { record: String, field: u8 }, + #[error("verb record {record:?} breaks a grammar rule: {rule}")] + InvalidVerb { record: String, rule: VerbRule }, + #[error("the verbs do not bind a grammar: {0}")] + Grammar(#[from] GrammarError), +} + +/// Reads the catalog at `path` without writing it. An absent file is +/// `Unreadable`, not an empty catalog. +pub fn read_aetheria_catalog(path: impl AsRef) -> Result { + let path = path.as_ref(); + std::fs::metadata(path).map_err(|_| CatalogReadError::Unreadable)?; + let mut rows = SingleFileMessagePackBackingStore::new(path) + .pull_all_read_only_snapshot() + .map_err(|_| CatalogReadError::Unreadable)?; + rows.sort_by(|a, b| a.key.cmp(&b.key)); + + let mut revision = None; + let mut verbs = Vec::new(); + let mut factions = Vec::new(); + for row in &rows { + match row.r#type.as_str() { + GRAMMAR_SCHEMA => { + if revision.is_some() { + return Err(CatalogReadError::MultipleGrammars); + } + let cells = Cells::of(&row.key, &row.payload)?; + revision = Some(u32::try_from(cells.int(1)?).map_err(|_| cells.malformed(1))?); + } + VERB_SCHEMA => verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?), + FACTION_SCHEMA => { + factions.push(read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?) + } + _ => {} + } + } + let revision = revision.ok_or(CatalogReadError::NoGrammar)?; + Ok(AetheriaCatalog { + grammar: GrammarBinding::new(GRAMMAR_SCHEMA, revision, verbs)?, + factions, + }) +} + +/// A record's payload: the positional array of its keyed fields. +struct Cells<'a> { + record: &'a str, + cells: Vec, +} + +impl<'a> Cells<'a> { + fn of(record: &'a str, payload: &[u8]) -> Result { + match rmp_serde::from_slice::(payload) { + Ok(Value::Array(cells)) => Ok(Self { record, cells }), + _ => Err(CatalogReadError::MalformedRecord { + record: record.to_owned(), + field: 0, + }), + } + } + + fn malformed(&self, field: u8) -> CatalogReadError { + CatalogReadError::MalformedRecord { + record: self.record.to_owned(), + field, + } + } + + fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> { + self.cells + .get(usize::from(field)) + .ok_or_else(|| self.malformed(field)) + } + + fn int(&self, field: u8) -> Result { + self.cell(field)? + .as_i64() + .ok_or_else(|| self.malformed(field)) + } + + fn text(&self, field: u8) -> Result { + self.cell(field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| self.malformed(field)) + } + + /// A string Aetheria may leave null; null reads as empty. + fn text_or_empty(&self, field: u8) -> Result { + match self.cell(field)? { + Value::Null => Ok(String::new()), + Value::String(text) => Ok(text.clone()), + _ => Err(self.malformed(field)), + } + } + + fn array(&self, field: u8) -> Result<&Vec, CatalogReadError> { + self.cell(field)? + .as_array() + .ok_or_else(|| self.malformed(field)) + } +} + +fn read_verb(cells: &Cells) -> Result<(String, GrammarVerb), CatalogReadError> { + let invalid = |rule| CatalogReadError::InvalidVerb { + record: cells.record.to_owned(), + rule, + }; + let name = cells.text(1)?; + if !canonical_name(&name, VERB_NAME_MAX) { + return Err(invalid(VerbRule::Name)); + } + let render_path = match cells.int(2)? { + 1 => RenderPath::ShipAction, + 2 => RenderPath::Conversation, + _ => { + return Err(CatalogReadError::UnknownRenderPath { + record: cells.record.to_owned(), + field: 2, + }); + } + }; + let mut roles: Vec = Vec::new(); + for role in cells.array(3)? { + let role = Cells { + record: cells.record, + cells: role.as_array().cloned().ok_or_else(|| cells.malformed(3))?, + }; + let role_name = role.text(1).map_err(|_| cells.malformed(3))?; + if !canonical_name(&role_name, ROLE_NAME_MAX) { + return Err(invalid(VerbRule::RoleName)); + } + if role_name == ACTOR_ROLE { + return Err(invalid(VerbRule::ReservedRole)); + } + if roles.iter().any(|seen| seen.role == role_name) { + return Err(invalid(VerbRule::DuplicateRole)); + } + let referent = match role.int(2).map_err(|_| cells.malformed(3))? { + 1 => GrammarReferentKind::Person, + 2 => GrammarReferentKind::Faction, + 3 => GrammarReferentKind::Population, + 4 => GrammarReferentKind::Place, + 5 => GrammarReferentKind::Cargo, + _ => { + return Err(CatalogReadError::UnknownReferentKind { + record: cells.record.to_owned(), + field: 3, + }); + } + }; + roles.push(GrammarRole { + role: role_name, + referent, + }); + } + Ok((name, GrammarVerb { render_path, roles })) +} + +/// A lower-case letter, then lower-case letters, digits and underscores. +fn canonical_name(name: &str, max_len: usize) -> bool { + let mut chars = name.chars(); + name.len() <= max_len + && chars.next().is_some_and(|c| c.is_ascii_lowercase()) + && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +fn read_faction(key: &str, cells: &Cells) -> Result { + let Some(allegiance) = cells.cell(12)?.as_object() else { + return Err(cells.malformed(12)); + }; + let mut standing = BTreeMap::new(); + for (other, weight) in allegiance { + let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?; + standing.insert( + ImportKey::new(FACTION_SCHEMA, other.as_str()), + weight as f32, + ); + } + Ok(AetheriaFaction { + key: ImportKey::new(FACTION_SCHEMA, key), + name: cells.text(1)?, + short_name: cells.text_or_empty(2)?, + description: cells.text_or_empty(3)?, + allegiance: standing, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs new file mode 100644 index 0000000..748ba52 --- /dev/null +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -0,0 +1,355 @@ +use super::*; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; +use serde_json::{Value, json}; + +/// Written by Aetheria's own fixture writer (`VerseGrammarTests.WriteFixture`) +/// at Aetheria master 92b82e9a; never edited. See `tests/fixtures/aetheria-verse-catalog.provenance.txt`. +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); + +fn fixture_rows() -> Vec { + SingleFileMessagePackBackingStore::new(FIXTURE) + .pull_all_read_only_snapshot() + .unwrap() +} + +fn row_of<'a>(rows: &'a mut [CultCacheEnvelope], key: &str) -> &'a mut CultCacheEnvelope { + rows.iter_mut().find(|row| row.key == key).unwrap() +} + +/// Re-encodes one record's positional payload after `edit`. +fn edit(rows: &mut [CultCacheEnvelope], key: &str, change: impl FnOnce(&mut Vec)) { + let row = row_of(rows, key); + let Value::Array(mut cells) = rmp_serde::from_slice(&row.payload).unwrap() else { + panic!("{key} is not a positional record"); + }; + change(&mut cells); + row.payload = rmp_serde::to_vec(&Value::Array(cells)).unwrap(); +} + +fn catalog_of(rows: &[CultCacheEnvelope]) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + for row in rows { + store.push(row).unwrap(); + } + (dir, path) +} + +fn read(rows: &[CultCacheEnvelope]) -> Result { + let (_dir, path) = catalog_of(rows); + read_aetheria_catalog(&path) +} + +fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb { + GrammarVerb { + render_path: path, + roles: roles + .iter() + .map(|(role, referent)| GrammarRole { + role: (*role).into(), + referent: *referent, + }) + .collect(), + } +} + +/// What Aetheria's fixture writer authored, spelled out by hand. +fn authored_verbs() -> Vec<(String, GrammarVerb)> { + vec![ + ( + "haul".into(), + verb( + RenderPath::ShipAction, + &[ + ("cargo", GrammarReferentKind::Cargo), + ("destination", GrammarReferentKind::Place), + ], + ), + ), + ( + "attack".into(), + verb( + RenderPath::ShipAction, + &[("target", GrammarReferentKind::Faction)], + ), + ), + ("speak".into(), verb(RenderPath::Conversation, &[])), + ] +} + +fn faction_key(record: &str) -> ImportKey { + ImportKey::new("aetheria.faction", record) +} + +#[test] +fn reads_the_aetheria_written_fixture() { + let catalog = read_aetheria_catalog(FIXTURE).unwrap(); + + let identity = catalog.grammar.identity(); + assert_eq!(identity.schema_id, "aetheria.verse_grammar"); + assert_eq!(identity.revision, 1); + for (kind, expected) in authored_verbs() { + assert_eq!(catalog.grammar.verb(&kind), Some(&expected), "{kind}"); + } + assert!(catalog.grammar.verb("mine").is_none()); + + assert_eq!( + catalog.factions, + vec![ + AetheriaFaction { + key: faction_key("faction-adrasteia"), + name: "Adrasteia".into(), + short_name: "ADR".into(), + description: "First.".into(), + allegiance: BTreeMap::from([(faction_key("faction-brannoch"), 0.75)]), + }, + AetheriaFaction { + key: faction_key("faction-brannoch"), + name: "Brannoch".into(), + short_name: "BRN".into(), + description: "Second.".into(), + allegiance: BTreeMap::from([(faction_key("faction-adrasteia"), 0.25)]), + }, + ] + ); +} + +#[test] +fn grammar_digest_matches_a_binding_built_by_hand() { + let by_hand = GrammarBinding::new("aetheria.verse_grammar", 1, authored_verbs()).unwrap(); + let read = read_aetheria_catalog(FIXTURE).unwrap().grammar; + assert_eq!(read.identity().digest, by_hand.identity().digest); + assert_eq!(read, by_hand); + + // The digest follows the verbs: a role the file drops is a different grammar. + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", |cells| { + cells[3].as_array_mut().unwrap().pop(); + }); + assert_ne!( + read_catalog_digest(&rows), + by_hand.identity().digest, + "dropping a role did not move the digest" + ); +} + +fn read_catalog_digest(rows: &[CultCacheEnvelope]) -> String { + read(rows).unwrap().grammar.identity().digest +} + +#[test] +fn refuses_a_catalog_without_a_grammar() { + let mut rows = fixture_rows(); + rows.retain(|row| row.r#type != "aetheria.verse_grammar"); + assert_eq!(read(&rows), Err(CatalogReadError::NoGrammar)); +} + +#[test] +fn refuses_a_second_grammar_record() { + let mut rows = fixture_rows(); + let mut second = rows + .iter() + .find(|row| row.r#type == "aetheria.verse_grammar") + .unwrap() + .clone(); + second.key.push_str("-again"); + rows.push(second); + assert_eq!(read(&rows), Err(CatalogReadError::MultipleGrammars)); +} + +#[test] +fn refuses_an_unknown_render_path() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", |cells| cells[2] = json!(3)); + let error = read(&rows).unwrap_err(); + assert_eq!( + error, + CatalogReadError::UnknownRenderPath { + record: "verb-haul".into(), + field: 2 + } + ); + assert!(!error.to_string().contains('3'), "the value was echoed"); +} + +#[test] +fn refuses_an_unknown_referent_kind() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-attack", |cells| { + cells[3][0][2] = json!(9); + }); + assert_eq!( + read(&rows), + Err(CatalogReadError::UnknownReferentKind { + record: "verb-attack".into(), + field: 3 + }) + ); +} + +#[test] +fn a_verb_breaking_the_name_or_role_rules_is_refused_by_key() { + let broken = |mutate: fn(&mut Vec)| { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", mutate); + read(&rows).unwrap_err() + }; + let invalid = |rule| CatalogReadError::InvalidVerb { + record: "verb-haul".into(), + rule, + }; + assert_eq!( + broken(|cells| cells[1] = json!("Haul")), + invalid(VerbRule::Name) + ); + assert_eq!( + broken(|cells| cells[1] = json!("1haul")), + invalid(VerbRule::Name) + ); + assert_eq!( + broken(|cells| cells[1] = json!("")), + invalid(VerbRule::Name) + ); + assert_eq!( + broken(|cells| cells[1] = json!("h".repeat(49))), + invalid(VerbRule::Name) + ); + assert_eq!( + broken(|cells| cells[3][0][1] = json!("Cargo")), + invalid(VerbRule::RoleName) + ); + assert_eq!( + broken(|cells| cells[3][0][1] = json!("c".repeat(33))), + invalid(VerbRule::RoleName) + ); + assert_eq!( + broken(|cells| cells[3][0][1] = json!("actor")), + invalid(VerbRule::ReservedRole) + ); + assert_eq!( + broken(|cells| cells[3][1][1] = json!("cargo")), + invalid(VerbRule::DuplicateRole) + ); +} + +#[test] +fn the_longest_canonical_names_are_admitted() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", |cells| { + cells[1] = json!(format!("h{}9", "_9".repeat(23))); + cells[3][0][1] = json!(format!("c{}", "z".repeat(31))); + }); + let catalog = read(&rows).unwrap(); + assert_eq!(catalog.grammar.identity().revision, 1); +} + +#[test] +fn two_verbs_with_one_name_do_not_bind() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-haul", |cells| cells[1] = json!("attack")); + assert_eq!( + read(&rows), + Err(CatalogReadError::Grammar(GrammarError::DuplicateVerb)) + ); +} + +#[test] +fn a_record_that_is_not_the_mirrored_shape_is_named_by_key_and_field() { + let malformed = |record: &str, field| CatalogReadError::MalformedRecord { + record: record.into(), + field, + }; + + let mut rows = fixture_rows(); + row_of(&mut rows, "faction-brannoch").payload = + rmp_serde::to_vec(&json!("not an array")).unwrap(); + assert_eq!(read(&rows), Err(malformed("faction-brannoch", 0))); + + let mut rows = fixture_rows(); + edit(&mut rows, "faction-brannoch", |cells| { + cells[1] = Value::Null + }); + assert_eq!(read(&rows), Err(malformed("faction-brannoch", 1))); + + let mut rows = fixture_rows(); + edit(&mut rows, "faction-brannoch", |cells| { + cells[12] = json!({"faction-adrasteia": "heavy"}); + }); + assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12))); + + let mut rows = fixture_rows(); + edit(&mut rows, "faction-brannoch", |cells| cells.truncate(12)); + assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12))); + + let mut rows = fixture_rows(); + let grammar = rows + .iter() + .find(|row| row.r#type == "aetheria.verse_grammar") + .unwrap() + .key + .clone(); + edit(&mut rows, &grammar, |cells| cells[1] = json!(-1)); + assert_eq!(read(&rows), Err(malformed(&grammar, 1))); +} + +#[test] +fn fields_aetheria_adds_and_schemas_it_does_not_mirror_are_ignored() { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-speak", |cells| cells.push(json!("later"))); + edit(&mut rows, "faction-adrasteia", |cells| cells.push(json!(7))); + let mut stranger = rows[0].clone(); + stranger.key = "ship-1".into(); + stranger.r#type = "aetheria.ship".into(); + stranger.schema_id = Some("sha256:not-mirrored".into()); + stranger.payload = vec![0xc1]; + rows.push(stranger); + + assert_eq!( + read(&rows).unwrap(), + read_aetheria_catalog(FIXTURE).unwrap() + ); +} + +#[test] +fn a_null_short_name_or_description_reads_as_empty() { + let mut rows = fixture_rows(); + edit(&mut rows, "faction-adrasteia", |cells| { + cells[2] = Value::Null; + cells[3] = Value::Null; + }); + let catalog = read(&rows).unwrap(); + assert_eq!(catalog.factions[0].short_name, ""); + assert_eq!(catalog.factions[0].description, ""); +} + +#[test] +fn an_absent_file_is_unreadable_not_an_empty_catalog() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + read_aetheria_catalog(dir.path().join("missing.cc")), + Err(CatalogReadError::Unreadable) + ); +} + +#[test] +fn reading_leaves_the_catalog_and_its_directory_untouched() { + let (dir, path) = catalog_of(&fixture_rows()); + let bytes = std::fs::read(&path).unwrap(); + let listing = |dir: &std::path::Path| { + let mut names: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + names.sort(); + names + }; + let before = listing(dir.path()); + read_aetheria_catalog(&path).unwrap(); + read_aetheria_catalog(&path).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(listing(dir.path()), before, "the reader created a file"); +} diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs index ba5d86d..43ca2bb 100644 --- a/crates/ghostlight/src/import_key_tests.rs +++ b/crates/ghostlight/src/import_key_tests.rs @@ -701,3 +701,52 @@ fn admitting_a_resolved_effect_refuses_two_subjects_with_one_key() { ); assert!(!format!("{error:?}").contains("Beta"), "the refusal echoed the key"); } + +/// A decomposable character at every position of a real-shaped key: the NFD +/// spelling is refused wherever it sits, the NFC spelling is admitted, so the +/// rule cannot be a prefix, suffix or first-character check. +#[test] +fn a_decomposable_character_is_refused_in_nfd_at_every_position() { + let (_dir, mut kernel) = keyed_world(); + let place = commons(&kernel); + let template: Vec = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart" + .chars() + .collect(); + let submit = |kernel: &mut WorldKernel, handle: &str, record: String| { + submit_as( + kernel, + CallerId::Principal(owner()), + None, + patch_of(vec![mirror( + handle, + Some(key("aetheria.lore", &record)), + place, + )]), + ) + }; + let spelled = |position: usize, inserted: &str| { + let mut record: String = template[..position].iter().collect(); + record.push_str(inserted); + record.extend(&template[position..]); + record + }; + for position in 0..=template.len() { + let nfd = spelled(position, "e\u{301}"); + assert!( + !unicode_normalization::is_nfc(&nfd), + "position {position} is not NFD" + ); + let set = rejected(submit(&mut kernel, "bad", nfd)); + assert_eq!(set, vec![malformed("bad")], "NFD at position {position}"); + } + for position in 0..=template.len() { + let nfc = spelled(position, "\u{e9}"); + submit(&mut kernel, &format!("good{position}"), nfc) + .unwrap_or_else(|_| panic!("NFC at position {position} was refused")); + } + assert_eq!( + keys_in(&kernel).len(), + 1 + template.len() + 1, + "the genesis key and one NFC key per position are held, no NFD key" + ); +} diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index ade7a1e..727733e 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -56,6 +56,7 @@ //! ``` mod action; +mod aetheria; mod clock; mod consumer; mod controllers; @@ -94,6 +95,9 @@ pub use controllers::{ TracingInferencePort, open_controller_work, open_inference, }; pub(crate) use cover::{CellId, Constituent, Resolution}; +pub use aetheria::{ + AetheriaCatalog, AetheriaFaction, CatalogReadError, VerbRule, read_aetheria_catalog, +}; pub use cover::{AgencyGraph, Cell, Cover, CoverBudget, CoverBudgetError, TickIndex, derive_cover}; pub use elaboration::{ ElaborationCheckpoint, ElaborationRunner, ElaboratorSession, EvidenceError, EvidenceQuery, diff --git a/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc new file mode 100644 index 0000000000000000000000000000000000000000..f408315006e713df1b5e0dd5b90d313755a257e7 GIT binary patch literal 6217 zcmd5=TW=dh6i(7qQ0@;%yavC3M7o-py?L#wB~6r2iI&O%BtR9KnVpRn*1OT}y3|6I z%0HlW3VA|Bz0lO9Z9sx2BrD-5{~#4VhO>9ab}r!5sT4W(&g?ngne(07w0`3aC*+jZ zgcO9HFQm)3_2c$JP@_cEDu|m*#SEEcS*C&*L?MVFD$5ojiXmH!D@ez%NwyTlfW1^# z6|P~VFf22fKcOP53E!rYMR{m@?$0=vO;s5SZdjUO=!`RAsuZajhW8N>T;*jAQ(~$* zVwQr+D$*>>6jrvlVls^o`t(Hg+fo3)>+}Mxi&CZ3tE@Dgu74{00F=WP4A1JK&fr0* z@&&x_cwwo6r%P@kB9=5=3Y*K3z{O?|in_EI`nJ0SZ`wgsSi%=BhgFL@fdFaLZRS5h z>Mx0Ku?v*ja2$BiHN}+~$EKh)eG(*~L%$)ePG>+!4rH&o_Z+r@AE1gup@82*v5?sf?BIf+J4%(lR1c#>(V-KPihy zSuViSOhqJdSHAW*L@$oPx9eaNSnT=5IYb7Xr(sR99FK-_FWAG0p&m@-9Md8krE5MP zsEi!df&jYI#k}o^;-L1SLX-)j7xr{_dYXIrJ5p>ViVy5#jB@B_y&(7iGTMQ0N1Nwp zNQcE;134zhi3oI1ZsH=XpK~pz5vA=pI|yl%zI&6{IX9F^B>nJHd=EWwk>qC}J1p4) z@eG$a=96lB$jKtD;s418MG5)nvhct0e8)WB2yEWxfKNu~fufAedUZy_qeoPflPJWF z3eTmh*Yoyl*w*i-)$uz~*7`LoS8)@yvC-M=tWK=u?M>hEMrZX{yQ|$8v|;j2RQ?C8 z9B+3O&F!yJ-NOY_m&D!6@aE=UdHXr2sDfE9JiORdBOmvaMmQPR^WvkOHEZy$6YZ`V zviYb-;2lLz#*oGUA#BBNDP0QB}nx zS{ccPiF70k%rNZ0mWHTNrlu%srX`rJC>9bjHP9cq&me&Y;>#tU)}hnto}?Hf6oY&j z1~kOVG7Ml^S-?Y8Sk%A@;uKkiWfId+Oj8#I>_)nY6rpilTOYOyRu4O`FWNzUeW+`C)Z&)abtD-es;720&HTR1X_@jV{90fuFI+* z8YMs>Y7j#vCZ-CtkZKu3)nx))RLe+LRfI6nH6RYsOoemx_a1TrUsxA2b;>Lz3tdww z z$331;3emB3_tU!>w3Jlb>{tO*Vszm$|wxeNNU*Ukzx1Grl4-0tzP&gY10D>m1IoLA4hFGe5m^vvV&|JAS>@xtCmwOsqo}4@joyR7x*tWi3WnqYY;i z_>?R_jGLW1k<#%StAhcZ?-L+2sIZJ)NITf!>g&tsDb|@Fp%7Ykj euq^0kj#N!O{MOCR-B|0y_YW6o699$r&E>zxxM>0a literal 0 HcmV?d00001 diff --git a/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt new file mode 100644 index 0000000..7ba8196 --- /dev/null +++ b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt @@ -0,0 +1,16 @@ +aetheria-verse-catalog.cc was written by Aetheria's own types, not by hand and not by Ghostlight. + +Writer: tests/Aetheria.Shared.Tests/VerseGrammarTests.cs, WriteFixture (xunit test + Fixture_writer_is_deterministic_and_emits_what_the_grammar_reader_needs), + which authors VerseGrammar, three VerseVerb and two Faction records through + Aetheria's CultCache types and leaves the file at AETHERIA_VERSE_FIXTURE_PATH. +Aetheria: GameCult/Aetheria origin/master 92b82e9aa5bb422c568c4bfc85a8f641426a41fc + (contains aetheria-release cut verse-grammar, merged at 35af2930). +Command: AETHERIA_VERSE_FIXTURE_PATH=/tmp/aetheria-verse-catalog.cc + dotnet test tests/Aetheria.Shared.Tests + -p:CultLibRoot= -p:CultMathRoot= + --filter "FullyQualifiedName~Fixture_writer_is_deterministic" + run on Yggdrasil through the verify stopgap (dotnet/sdk:10.0), 2026-10-10. +Content: six records; two writes of the writer agree on content, though not bytes + (CultCache stamps storedAt from the wall clock). Regenerate and diff on content, + never hand-edit. From b5edc05b1fcc2d1ca981f2b567461fcc5178e9f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:14:50 +0200 Subject: [PATCH 2/5] grammar-reader: pin every referent code (Person and Population arms survived mutation) The Aetheria-written fixture binds only Faction, Cargo and Place, so mutants deleting the Person and Population arms survived. The new test authors each code at the role's key and reads it back as its own kind. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria/tests.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 748ba52..460e03a 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -353,3 +353,25 @@ fn reading_leaves_the_catalog_and_its_directory_untouched() { assert_eq!(std::fs::read(&path).unwrap(), bytes); assert_eq!(listing(dir.path()), before, "the reader created a file"); } + +#[test] +fn every_referent_code_maps_to_its_own_kind() { + // The fixture binds only Faction, Cargo and Place; Person and Population + // are authored here at the role's integer key, each read back as itself. + for (code, expected) in [ + (1, GrammarReferentKind::Person), + (2, GrammarReferentKind::Faction), + (3, GrammarReferentKind::Population), + (4, GrammarReferentKind::Place), + (5, GrammarReferentKind::Cargo), + ] { + let mut rows = fixture_rows(); + edit(&mut rows, "verb-attack", |cells| cells[3][0][2] = json!(code)); + let catalog = read(&rows).unwrap(); + assert_eq!( + catalog.grammar.verb("attack"), + Some(&verb(RenderPath::ShipAction, &[("target", expected)])), + "code {code}" + ); + } +} From 3eb59068c762e2c5e4d0de0f3fef206925bcc9fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 01:13:17 +0200 Subject: [PATCH 3/5] grammar-reader fix 1: canonical keys through the one predicate, duplicate factions refused, bin/ext growth tolerated, guards pinned The reader mints every faction and allegiance ImportKey and refuses a non-canonical one as a typed NonCanonicalKey naming record and field (0 or 12); two faction rows under one record key are DuplicateFaction. Payloads decode through rmpv so a MessagePack bin or ext cell at a higher key (C# byte[], DateTime) no longer fails the record. Tests pin the record-key sort and a non-map Allegiance. Schema-version check (item 2) not done: CultLib gap. All commits build (not yet run; the suite runs next). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/ghostlight/Cargo.toml | 1 + crates/ghostlight/src/aetheria.rs | 52 +++++++--- crates/ghostlight/src/aetheria/tests.rs | 122 +++++++++++++++++++++++- 4 files changed, 164 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1e2e0e..dfffd46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,6 +668,7 @@ dependencies = [ "ghostlight-persona-projection", "reqwest", "rmp-serde", + "rmpv", "serde", "serde_bytes", "serde_json", diff --git a/crates/ghostlight/Cargo.toml b/crates/ghostlight/Cargo.toml index 7a643a6..7fca500 100644 --- a/crates/ghostlight/Cargo.toml +++ b/crates/ghostlight/Cargo.toml @@ -12,6 +12,7 @@ cultcache-rs.workspace = true ghostlight-persona-projection = { path = "../ghostlight-persona-projection" } reqwest.workspace = true rmp-serde.workspace = true +rmpv = "1.3" serde.workspace = true serde_bytes = "0.11" serde_json.workspace = true diff --git a/crates/ghostlight/src/aetheria.rs b/crates/ghostlight/src/aetheria.rs index 0a826d6..4db8eaa 100644 --- a/crates/ghostlight/src/aetheria.rs +++ b/crates/ghostlight/src/aetheria.rs @@ -16,7 +16,7 @@ use crate::grammar::{ GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath, }; use cultcache_rs::SingleFileMessagePackBackingStore; -use serde_json::Value; +use rmpv::Value; use std::collections::BTreeMap; use std::path::Path; @@ -73,6 +73,10 @@ pub enum CatalogReadError { MultipleGrammars, #[error("record {record:?} field {field} is missing or not the mirrored type")] MalformedRecord { record: String, field: u8 }, + #[error("record {record:?} field {field} is not a canonical import key")] + NonCanonicalKey { record: String, field: u8 }, + #[error("record {record:?} is a second aetheria.faction under one record key")] + DuplicateFaction { record: String }, #[error("record {record:?} field {field} is not a known render path")] UnknownRenderPath { record: String, field: u8 }, #[error("record {record:?} field {field} is not a known referent kind")] @@ -107,7 +111,16 @@ pub fn read_aetheria_catalog(path: impl AsRef) -> Result verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?), FACTION_SCHEMA => { - factions.push(read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?) + let faction = read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?; + if factions + .last() + .is_some_and(|seen: &AetheriaFaction| seen.key == faction.key) + { + return Err(CatalogReadError::DuplicateFaction { + record: row.key.clone(), + }); + } + factions.push(faction); } _ => {} } @@ -127,7 +140,8 @@ struct Cells<'a> { impl<'a> Cells<'a> { fn of(record: &'a str, payload: &[u8]) -> Result { - match rmp_serde::from_slice::(payload) { + let mut payload = payload; + match rmpv::decode::read_value(&mut payload) { Ok(Value::Array(cells)) => Ok(Self { record, cells }), _ => Err(CatalogReadError::MalformedRecord { record: record.to_owned(), @@ -143,6 +157,13 @@ impl<'a> Cells<'a> { } } + fn non_canonical(&self, field: u8) -> CatalogReadError { + CatalogReadError::NonCanonicalKey { + record: self.record.to_owned(), + field, + } + } + fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> { self.cells .get(usize::from(field)) @@ -165,8 +186,11 @@ impl<'a> Cells<'a> { /// A string Aetheria may leave null; null reads as empty. fn text_or_empty(&self, field: u8) -> Result { match self.cell(field)? { - Value::Null => Ok(String::new()), - Value::String(text) => Ok(text.clone()), + Value::Nil => Ok(String::new()), + Value::String(text) => text + .as_str() + .map(str::to_owned) + .ok_or_else(|| self.malformed(field)), _ => Err(self.malformed(field)), } } @@ -243,19 +267,25 @@ fn canonical_name(name: &str, max_len: usize) -> bool { } fn read_faction(key: &str, cells: &Cells) -> Result { - let Some(allegiance) = cells.cell(12)?.as_object() else { + let own = ImportKey::new(FACTION_SCHEMA, key); + if !own.is_canonical() { + return Err(cells.non_canonical(0)); + } + let Some(allegiance) = cells.cell(12)?.as_map() else { return Err(cells.malformed(12)); }; let mut standing = BTreeMap::new(); for (other, weight) in allegiance { + let other = other.as_str().ok_or_else(|| cells.malformed(12))?; let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?; - standing.insert( - ImportKey::new(FACTION_SCHEMA, other.as_str()), - weight as f32, - ); + let other = ImportKey::new(FACTION_SCHEMA, other); + if !other.is_canonical() { + return Err(cells.non_canonical(12)); + } + standing.insert(other, weight as f32); } Ok(AetheriaFaction { - key: ImportKey::new(FACTION_SCHEMA, key), + key: own, name: cells.text(1)?, short_name: cells.text_or_empty(2)?, description: cells.text_or_empty(3)?, diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 460e03a..e022017 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -366,7 +366,9 @@ fn every_referent_code_maps_to_its_own_kind() { (5, GrammarReferentKind::Cargo), ] { let mut rows = fixture_rows(); - edit(&mut rows, "verb-attack", |cells| cells[3][0][2] = json!(code)); + edit(&mut rows, "verb-attack", |cells| { + cells[3][0][2] = json!(code) + }); let catalog = read(&rows).unwrap(); assert_eq!( catalog.grammar.verb("attack"), @@ -375,3 +377,121 @@ fn every_referent_code_maps_to_its_own_kind() { ); } } + +fn faction_with_key(record: &str) -> Vec { + let mut rows = fixture_rows(); + row_of(&mut rows, "faction-brannoch").key = record.into(); + rows +} + +#[test] +fn a_faction_record_key_that_is_not_canonical_text_is_refused_by_key_and_field() { + for bad in [ + "faction-cafe\u{301}", + "faction-brannoch ", + "faction-\u{202e}brannoch", + "", + ] { + assert_eq!( + read(&faction_with_key(bad)), + Err(CatalogReadError::NonCanonicalKey { + record: bad.into(), + field: 0 + }), + "{bad:?}" + ); + } +} + +#[test] +fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() { + for bad in ["faction-cafe\u{301}", "faction-adrasteia ", "\u{202e}"] { + let mut rows = fixture_rows(); + edit(&mut rows, "faction-brannoch", |cells| { + cells[12] = json!({ bad: 0.5 }); + }); + assert_eq!( + read(&rows), + Err(CatalogReadError::NonCanonicalKey { + record: "faction-brannoch".into(), + field: 12 + }), + "{bad:?}" + ); + } +} + +#[test] +fn two_faction_rows_under_one_record_key_are_refused() { + let mut rows = fixture_rows(); + let twin = row_of(&mut rows, "faction-adrasteia").clone(); + rows.push(twin); + assert_eq!( + read(&rows), + Err(CatalogReadError::DuplicateFaction { + record: "faction-adrasteia".into() + }) + ); +} + +#[test] +fn factions_are_read_in_record_key_order_whatever_the_file_order() { + let mut rows = fixture_rows(); + rows.sort_by(|a, b| b.key.cmp(&a.key)); + assert_eq!(rows.first().unwrap().key, "verb-speak"); + let names: Vec<_> = read(&rows) + .unwrap() + .factions + .into_iter() + .map(|faction| faction.name) + .collect(); + assert_eq!(names, ["Adrasteia", "Brannoch"]); +} + +#[test] +fn an_allegiance_that_is_not_a_map_is_malformed_not_empty() { + for not_a_map in [ + json!([]), + json!(["faction-adrasteia"]), + Value::Null, + json!(1), + ] { + let mut rows = fixture_rows(); + let shape = not_a_map.clone(); + edit(&mut rows, "faction-brannoch", |cells| cells[12] = shape); + assert_eq!( + read(&rows), + Err(CatalogReadError::MalformedRecord { + record: "faction-brannoch".into(), + field: 12 + }), + "{not_a_map}" + ); + } +} + +/// Appends cells to a faction as Aetheria's C# CultCache writes a `byte[]` +/// (MessagePack bin) and a `DateTime` (MessagePack timestamp, ext -1). +#[test] +fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() { + let mut rows = fixture_rows(); + let row = row_of(&mut rows, "faction-adrasteia"); + let rmpv::Value::Array(mut cells) = + rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap() + else { + panic!("not positional"); + }; + cells.push(rmpv::Value::Binary(vec![1, 0, 7])); + cells.push(rmpv::Value::Ext( + -1, + vec![0, 0, 0, 0, 0x65, 0x1f, 0x2a, 0x80], + )); + let mut payload = Vec::new(); + rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap(); + row.payload = payload; + + assert_eq!( + read(&rows).unwrap(), + read_aetheria_catalog(FIXTURE).unwrap() + ); +} From 677eb1b3a890b35a913a9cf76a94abeb8c4ed6be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 01:15:31 +0200 Subject: [PATCH 4/5] grammar-reader fix 1: the duplicate-faction test splices the second row into the snapshot bytes The store's writer keeps one row per record key, so the previous test never held two rows. Builds; the test crate is the only change. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria/tests.rs | 28 +++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index e022017..14c62df 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -421,13 +421,33 @@ fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() { } } +/// The store's own writer keeps one row per record key, so a second row is +/// spliced into the fixture's snapshot bytes the way a hand-merged file would +/// hold it. #[test] fn two_faction_rows_under_one_record_key_are_refused() { - let mut rows = fixture_rows(); - let twin = row_of(&mut rows, "faction-adrasteia").clone(); - rows.push(twin); + let bytes = std::fs::read(FIXTURE).unwrap(); + let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap() + else { + panic!("snapshot is not a tuple"); + }; + let rmpv::Value::Array(records) = &mut snapshot[2] else { + panic!("snapshot holds no records"); + }; + let twin = records + .iter() + .find(|record| record[0].as_str() == Some("faction-adrasteia")) + .unwrap() + .clone(); + records.push(twin); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.cc"); + let mut spliced = Vec::new(); + rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap(); + std::fs::write(&path, spliced).unwrap(); + assert_eq!( - read(&rows), + read_aetheria_catalog(&path), Err(CatalogReadError::DuplicateFaction { record: "faction-adrasteia".into() }) From 42e71d89d5b19699faeecac154d3461183c77043 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 01:29:31 +0200 Subject: [PATCH 5/5] grammar-reader fix 1: pin the record-key sort with rows spliced out of order The store re-sorts rows it writes, so the reversed-rows test never reached the reader unsorted. Both file-shape tests now splice raw snapshot rows. Builds; test-only change. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria/tests.rs | 39 +++++++++++++++---------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 14c62df..45f1702 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -421,11 +421,12 @@ fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() { } } -/// The store's own writer keeps one row per record key, so a second row is -/// spliced into the fixture's snapshot bytes the way a hand-merged file would -/// hold it. -#[test] -fn two_faction_rows_under_one_record_key_are_refused() { +/// The fixture's snapshot bytes with the record rows changed by `change`, for +/// what the store's own writer will not produce: it keeps one row per record +/// key and stores rows in key order. +fn spliced_catalog( + change: impl FnOnce(&mut Vec), +) -> (tempfile::TempDir, std::path::PathBuf) { let bytes = std::fs::read(FIXTURE).unwrap(); let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap() else { @@ -434,18 +435,25 @@ fn two_faction_rows_under_one_record_key_are_refused() { let rmpv::Value::Array(records) = &mut snapshot[2] else { panic!("snapshot holds no records"); }; - let twin = records - .iter() - .find(|record| record[0].as_str() == Some("faction-adrasteia")) - .unwrap() - .clone(); - records.push(twin); + change(records); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("catalog.cc"); let mut spliced = Vec::new(); rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap(); std::fs::write(&path, spliced).unwrap(); + (dir, path) +} +#[test] +fn two_faction_rows_under_one_record_key_are_refused() { + let (_dir, path) = spliced_catalog(|records| { + let twin = records + .iter() + .find(|record| record[0].as_str() == Some("faction-adrasteia")) + .unwrap() + .clone(); + records.push(twin); + }); assert_eq!( read_aetheria_catalog(&path), Err(CatalogReadError::DuplicateFaction { @@ -456,10 +464,11 @@ fn two_faction_rows_under_one_record_key_are_refused() { #[test] fn factions_are_read_in_record_key_order_whatever_the_file_order() { - let mut rows = fixture_rows(); - rows.sort_by(|a, b| b.key.cmp(&a.key)); - assert_eq!(rows.first().unwrap().key, "verb-speak"); - let names: Vec<_> = read(&rows) + let (_dir, path) = spliced_catalog(|records| { + records.sort_by(|a, b| b[0].as_str().cmp(&a[0].as_str())); + assert_eq!(records[0][0].as_str(), Some("verb-speak")); + }); + let names: Vec<_> = read_aetheria_catalog(&path) .unwrap() .factions .into_iter()