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 new file mode 100644 index 0000000..4db8eaa --- /dev/null +++ b/crates/ghostlight/src/aetheria.rs @@ -0,0 +1,297 @@ +//! 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 rmpv::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 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")] + 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 => { + 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); + } + _ => {} + } + } + 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 { + 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(), + field: 0, + }), + } + } + + fn malformed(&self, field: u8) -> CatalogReadError { + CatalogReadError::MalformedRecord { + record: self.record.to_owned(), + field, + } + } + + 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)) + .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::Nil => Ok(String::new()), + Value::String(text) => text + .as_str() + .map(str::to_owned) + .ok_or_else(|| self.malformed(field)), + _ => 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 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))?; + 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: own, + 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..45f1702 --- /dev/null +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -0,0 +1,526 @@ +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"); +} + +#[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}" + ); + } +} + +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:?}" + ); + } +} + +/// 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 { + panic!("snapshot is not a tuple"); + }; + let rmpv::Value::Array(records) = &mut snapshot[2] else { + panic!("snapshot holds no records"); + }; + 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 { + record: "faction-adrasteia".into() + }) + ); +} + +#[test] +fn factions_are_read_in_record_key_order_whatever_the_file_order() { + 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() + .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() + ); +} 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 0000000..f408315 Binary files /dev/null and b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc differ 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.