Merge ghostlight-verse/grammar-reader: Ghostlight reads Aetheria's catalog file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 01:52:06 +02:00
commit 9afbdc879e
8 changed files with 894 additions and 0 deletions

1
Cargo.lock generated
View file

@ -668,6 +668,7 @@ dependencies = [
"ghostlight-persona-projection",
"reqwest",
"rmp-serde",
"rmpv",
"serde",
"serde_bytes",
"serde_json",

View file

@ -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

View file

@ -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<AetheriaFaction>,
}
/// 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<ImportKey, f32>,
}
/// 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<Path>) -> Result<AetheriaCatalog, CatalogReadError> {
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<Value>,
}
impl<'a> Cells<'a> {
fn of(record: &'a str, payload: &[u8]) -> Result<Self, CatalogReadError> {
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<i64, CatalogReadError> {
self.cell(field)?
.as_i64()
.ok_or_else(|| self.malformed(field))
}
fn text(&self, field: u8) -> Result<String, CatalogReadError> {
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<String, CatalogReadError> {
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<Value>, 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<GrammarRole> = 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<AetheriaFaction, CatalogReadError> {
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;

View file

@ -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<CultCacheEnvelope> {
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<Value>)) {
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<AetheriaCatalog, CatalogReadError> {
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<Value>)| {
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<CultCacheEnvelope> {
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<rmpv::Value>),
) -> (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()
);
}

View file

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

View file

@ -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,

Binary file not shown.

View file

@ -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=<CultLib 45c2f40 clone> -p:CultMathRoot=<CultLib 252e226c clone>
--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.