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:
commit
9afbdc879e
8 changed files with 894 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -668,6 +668,7 @@ dependencies = [
|
|||
"ghostlight-persona-projection",
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
"rmpv",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
297
crates/ghostlight/src/aetheria.rs
Normal file
297
crates/ghostlight/src/aetheria.rs
Normal 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;
|
||||
526
crates/ghostlight/src/aetheria/tests.rs
Normal file
526
crates/ghostlight/src/aetheria/tests.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
BIN
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc
vendored
Normal file
BIN
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc
vendored
Normal file
Binary file not shown.
16
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt
vendored
Normal file
16
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt
vendored
Normal 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.
|
||||
Loading…
Reference in a new issue