diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 45f1702..c9333ec 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -524,3 +524,66 @@ fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() { read_aetheria_catalog(FIXTURE).unwrap() ); } + +/// A faction record re-encoded with `change` applied to its decoded cells, for +/// shapes `serde_json` cannot spell: a non-string map key, raw invalid bytes. +fn with_cells(key: &str, change: impl FnOnce(&mut Vec)) -> Vec { + let mut rows = fixture_rows(); + let row = row_of(&mut rows, key); + let rmpv::Value::Array(mut cells) = + rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap() + else { + panic!("not positional"); + }; + change(&mut cells); + let mut payload = Vec::new(); + rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap(); + row.payload = payload; + rows +} + +#[test] +fn an_allegiance_key_that_is_not_a_string_is_malformed_by_key_and_field() { + for key in [ + rmpv::Value::from(5), + rmpv::Value::Nil, + rmpv::Value::Binary(b"faction-adrasteia".to_vec()), + ] { + let rows = with_cells("faction-brannoch", |cells| { + cells[12] = rmpv::Value::Map(vec![(key, rmpv::Value::F64(0.5))]); + }); + assert_eq!( + read(&rows), + Err(CatalogReadError::MalformedRecord { + record: "faction-brannoch".into(), + field: 12 + }) + ); + } +} + +#[test] +fn text_that_is_not_utf8_is_malformed_by_key_and_field() { + // The marker is overwritten in the encoded bytes with an invalid sequence of + // the same length, since a decoded string cannot hold one. + const MARKER: &str = "ZZZZ"; + for field in [1usize, 3] { + let mut rows = with_cells("faction-brannoch", |cells| { + cells[field] = rmpv::Value::from(MARKER); + }); + let payload = &mut row_of(&mut rows, "faction-brannoch").payload; + let at = payload + .windows(MARKER.len()) + .position(|window| window == MARKER.as_bytes()) + .unwrap(); + payload[at..at + MARKER.len()].copy_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]); + assert_eq!( + read(&rows), + Err(CatalogReadError::MalformedRecord { + record: "faction-brannoch".into(), + field: field as u8 + }), + "field {field}" + ); + } +} diff --git a/crates/ghostlight/src/grammar.rs b/crates/ghostlight/src/grammar.rs index b827461..a11cd69 100644 --- a/crates/ghostlight/src/grammar.rs +++ b/crates/ghostlight/src/grammar.rs @@ -8,7 +8,7 @@ //! digest its verbs do not have. use crate::SubjectKind; -use crate::patch::{AffordanceKindName, EntityKind, RefKind, RoleSpec, kernel_speak_entry}; +use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; @@ -63,10 +63,20 @@ impl GrammarVerb { /// Whether a declared role list is this verb's signature: same names, same /// referent kinds, same order. pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool { - self.roles.len() == roles.len() - && self.roles.iter().zip(roles).all(|(want, have)| { - want.role == have.role.0 && want.referent.ref_kind() == have.kind + self.role_specs() == roles + } + + /// This verb's signature as the kernel declares it: the one lowering of a + /// grammar role, so declaring a verb and admitting a declaration cannot + /// disagree. + pub(crate) fn role_specs(&self) -> Vec { + self.roles + .iter() + .map(|role| RoleSpec { + role: Role(role.role.clone()), + kind: role.referent.ref_kind(), }) + .collect() } } @@ -165,6 +175,11 @@ impl GrammarBinding { } } + /// Every verb, in kind order. + pub(crate) fn verbs(&self) -> impl Iterator { + self.verbs.iter().map(|(kind, verb)| (kind.as_str(), verb)) + } + pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> { self.verbs.get(kind) } diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 727733e..f373b42 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -144,8 +144,8 @@ pub use table::{ }; pub use vault::{VaultError, VaultEvidenceSource}; pub use verse::{ - CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, - ReplayInferencePort, RunField, RunId, RunStart, + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord, + RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, VerseGenesisError, }; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index be84e8f..76f76da 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -3,9 +3,10 @@ use super::{ ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation, DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey, KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId, - Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, + EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, WorldKernel, WorldPatch, WorldPhase, WorldScaleIntentRef, WorldSnapshot, journal, patch::kernel_speak_grant, prepare_creation, + verse::{CreateVerseWorldIntent, VerseGenesisError}, }; use crate::VerifiedPrincipalEvidence; use chrono::Utc; @@ -18,6 +19,21 @@ use tokio::task::JoinHandle; /// other. A world that wants more declares them through the patch lane. const GENESIS_PLACE: &str = "commons"; +/// The commons every genesis declares, the one room its first subjects share. +pub(crate) fn commons_declaration() -> Declaration { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(GENESIS_PLACE), + label: "The Commons".into(), + kind: EntityKind::Place, + container: None, + }) +} + +/// Where a genesis subject stands. +pub(crate) fn commons_ref() -> Ref { + Ref::Draft(DraftHandle::new(GENESIS_PLACE)) +} + const REQUEST_CAPACITY: usize = 32; #[derive(Clone, Debug)] @@ -120,7 +136,7 @@ impl WorldMailbox { kind, controller, affordances: kernel_speak_grant(), - position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + position: Some(commons_ref()), import_key: None, }) }; @@ -130,12 +146,7 @@ impl WorldMailbox { // jurisdiction's population target. The owner subject lands in // `Uncovered`, visible and reducing nothing. let mut declarations = vec![ - Declaration::Entity(EntityDeclaration { - handle: DraftHandle::new(GENESIS_PLACE), - label: "The Commons".into(), - kind: EntityKind::Place, - container: None, - }), + commons_declaration(), declare( "first-person", input.human_subject_label, @@ -219,6 +230,20 @@ impl WorldMailbox { .map_err(mailbox_outcome) } + /// Creates the canonical Aetheria verse world through the same + /// authenticated creation `create` uses. + pub async fn create_verse( + &self, + intent: CreateVerseWorldIntent, + principal: &VerifiedPrincipalEvidence, + ) -> Result { + let principal_id = PrincipalId::new(principal.account_subject_hash()); + let input = intent.lower(principal_id.clone())?; + Ok(self + .create_authenticated(input, AuthenticatedCaller::verified_principal(principal_id)) + .await?) + } + /// A wall-clock read at the boundary a request crosses into the mailbox is /// ingress, not reduce: the reducer stays deterministic over its journal, /// and this is the one place a caller's own claim of continued authority diff --git a/crates/ghostlight/src/verse/genesis.rs b/crates/ghostlight/src/verse/genesis.rs new file mode 100644 index 0000000..9641467 --- /dev/null +++ b/crates/ghostlight/src/verse/genesis.rs @@ -0,0 +1,143 @@ +//! The canonical Aetheria verse world's genesis. +//! +//! One intent lowers to one `CreateWorld`: a commons, every grammar verb +//! declared as an affordance, and each Aetheria faction as an institution +//! imported by its record key. The world is bound to the catalog's grammar, so +//! the kernel refuses any affordance the grammar does not carry. +//! +//! Phase A verbs record the act and change no component: an outcome band with +//! no effects, and the act carried as the utterance that makes an entry +//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling +//! `verb-effects-ghostlight`), and those arrive with phase B. + +use crate::aetheria::AetheriaCatalog; +use crate::mailbox::{MailboxError, commons_declaration, commons_ref}; +use crate::patch::{ + AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef, + FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration, + kernel_speak_entry, kernel_speak_grant, +}; +use crate::{ + CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind, + WorldPatch, WorldScaleIntentRef, +}; +use std::collections::BTreeMap; + +const COMMIT_LEN: usize = 40; + +/// What a caller states to create the canonical verse world. Everything else a +/// world needs (the commons, the subjects, the affordances, their grants and +/// the provenance of what is known) is derived from the catalog. +#[derive(Clone, Debug)] +pub struct CreateVerseWorldIntent { + pub id: CommandId, + pub title: String, + pub brief: String, + pub catalog: AetheriaCatalog, + /// The Aetheria commit the catalog was read at: 40 lower-case hex digits. + /// It is the provenance of every fact the world holds about a faction. + pub aetheria_commit: String, + pub lens_weights: LensWeights, + pub targets: BTreeMap, +} + +#[derive(Debug, thiserror::Error)] +pub enum VerseGenesisError { + #[error("aetheria_commit is not 40 lower-case hex digits")] + NotACommit, + #[error(transparent)] + Mailbox(#[from] MailboxError), +} + +impl CreateVerseWorldIntent { + /// The one lowering. The kernel supplies the `speak` entry at genesis and + /// holds it to the grammar, so it is granted here and never declared. + pub(crate) fn lower(self, owner: PrincipalId) -> Result { + let commit = &self.aetheria_commit; + if commit.len() != COMMIT_LEN + || !commit + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(VerseGenesisError::NotACommit); + } + + let speak = kernel_speak_entry().kind; + let mut grants = kernel_speak_grant(); + let mut declarations = vec![commons_declaration()]; + for (kind, verb) in self.catalog.grammar.verbs() { + if kind == speak.0 { + continue; + } + let handle = DraftHandle::new(format!("verb-{kind}")); + grants.insert(Ref::Draft(handle.clone())); + declarations.push(Declaration::Affordance(AffordanceDeclaration { + handle, + kind: AffordanceKindName(kind.to_owned()), + roles: verb.role_specs(), + preconditions: vec![Precondition::CanBroadcast { + via: AudienceSpec::Colocated, + }], + effect_slots: Vec::new(), + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: Vec::new(), + }], + // The kernel refuses an entry that can neither change a + // component nor speak, and a speaking entry needs an audience, + // so a phase A verb is its own utterance to the room: the act + // is recorded as speech and nothing else changes. + carries_speech: true, + })); + } + + let mut evidence = Vec::new(); + for (position, faction) in self.catalog.factions.iter().enumerate() { + declarations.push(Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(format!("faction-{position}")), + label: faction.name.trim().to_owned(), + kind: SubjectKind::Institution, + controller: NewController::OperationalAgent, + affordances: grants.clone(), + position: Some(commons_ref()), + import_key: Some(faction.key.clone()), + })); + // A faction with no description has nothing to state. + if let Some(statement) = Statement::new(faction.description.trim()) { + let receipt = EvidenceRef::new(format!( + "aetheria:{commit}:{}/{}", + faction.key.schema(), + faction.key.record() + )); + evidence.push(receipt.clone()); + declarations.push(Declaration::Fact(FactDeclaration { + handle: DraftHandle::new(format!("fact-{position}")), + label: faction.name.trim().to_owned(), + statement, + standing: FactStandingRef::Canonical { evidence: receipt }, + })); + } + } + + Ok(CreateWorld { + id: self.id, + owner, + title: self.title, + brief: self.brief, + patch: WorldPatch { + declarations, + operations: Vec::new(), + evidence, + }, + scale_intent: WorldScaleIntentRef { + targets: self.targets, + jurisdictions: BTreeMap::new(), + }, + lens_weights: self.lens_weights, + grammar: Some(self.catalog.grammar), + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/verse/genesis/tests.rs b/crates/ghostlight/src/verse/genesis/tests.rs new file mode 100644 index 0000000..42b36da --- /dev/null +++ b/crates/ghostlight/src/verse/genesis/tests.rs @@ -0,0 +1,476 @@ +use super::*; +use crate::aetheria::read_aetheria_catalog; +use crate::patch::{FactStanding, Mismatch}; +use crate::tests::{auth_principal, owner, stock_weights}; +use crate::{ + CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb, + KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel, + WorldMailbox, WorldPhase, +}; +use chrono::{Duration, Utc}; +use std::collections::BTreeSet; + +/// Aetheria master when the fixture catalog was written. +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); + +fn catalog() -> AetheriaCatalog { + read_aetheria_catalog(FIXTURE).unwrap() +} + +fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent { + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Verse".into(), + brief: "Aetheria.".into(), + catalog, + aetheria_commit: commit.into(), + lens_weights: stock_weights(), + targets: BTreeMap::new(), + } +} + +/// A world created through the kernel from the lowering, for what the +/// snapshot hides: the facts' evidence. +fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result) { + let dir = tempfile::tempdir().unwrap(); + let world = intent.lower(owner()).unwrap(); + let result = WorldKernel::create(dir.path().join("world.cc"), world, &auth_principal(owner())) + .map(|(kernel, _)| kernel); + (dir, result) +} + +struct Verse { + _dir: tempfile::TempDir, + _task: tokio::task::JoinHandle<()>, + mailbox: WorldMailbox, + principal: VerifiedPrincipalEvidence, + receipt: crate::CreationReceipt, +} + +async fn verse(intent: CreateVerseWorldIntent) -> Verse { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let receipt = mailbox.create_verse(intent, &principal).await.unwrap(); + Verse { + _dir: dir, + _task: task, + mailbox, + principal, + receipt, + } +} + +#[tokio::test] +async fn verse_genesis_from_the_aetheria_fixture() { + let catalog = catalog(); + let verse = verse(intent_of(catalog.clone(), COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + + // One institution per faction, keyed by its record, named by its Name. + let mut factions: Vec<_> = snapshot + .subjects + .iter() + .map(|subject| { + ( + subject.import_key.clone().unwrap(), + subject.label.clone(), + subject.kind, + subject.controller_mode, + ) + }) + .collect(); + factions.sort_by(|a, b| a.0.cmp(&b.0)); + let expected: Vec<_> = catalog + .factions + .iter() + .map(|faction| { + ( + faction.key.clone(), + faction.name.clone(), + SubjectKind::Institution, + Some(ControllerMode::OperationalAgent), + ) + }) + .collect(); + assert_eq!(factions.len(), 2); + assert_eq!(factions, expected); + + // One fact per faction, carrying its description. + let statements: BTreeSet<_> = snapshot + .facts + .iter() + .map(|fact| fact.statement.as_str().to_owned()) + .collect(); + assert_eq!( + statements, + catalog + .factions + .iter() + .map(|faction| faction.description.clone()) + .collect::>() + ); + + // The world is bound to the catalog's grammar. + assert_eq!(snapshot.grammar, Some(catalog.grammar.identity())); + assert_eq!(verse.receipt.world_id, snapshot.world_id); + + // Every faction stands in the one commons. + let commons: Vec<_> = snapshot + .places + .iter() + .filter(|place| place.label == "The Commons") + .collect(); + assert_eq!(commons.len(), 1); + assert!( + snapshot + .subjects + .iter() + .all(|subject| subject.position == Some(commons[0].id)) + ); +} + +#[test] +fn each_fact_cites_its_faction_record_and_the_commit_it_was_read_at() { + for commit in [COMMIT, OTHER_COMMIT] { + let (_dir, kernel) = kernel_of(intent_of(catalog(), commit)); + let kernel = kernel.unwrap(); + let evidence: BTreeSet = kernel + .state + .facts + .values() + .map(|fact| match &fact.standing { + FactStanding::Canonical { evidence } => evidence.text().to_owned(), + other => panic!("a verse fact is canonical, not {other:?}"), + }) + .collect(); + assert_eq!( + evidence, + catalog() + .factions + .iter() + .map(|faction| format!( + "aetheria:{commit}:aetheria.faction/{}", + faction.key.record() + )) + .collect::>() + ); + } +} + +#[test] +fn a_commit_is_forty_lower_case_hex_digits() { + for bad in [ + "", + &COMMIT[..39], + &format!("{COMMIT}0"), + &COMMIT.to_uppercase(), + &format!("{}g", &COMMIT[..39]), + ] { + assert!( + matches!( + intent_of(catalog(), bad).lower(owner()), + Err(VerseGenesisError::NotACommit) + ), + "{bad:?}" + ); + } + assert!(intent_of(catalog(), COMMIT).lower(owner()).is_ok()); + assert!(!VerseGenesisError::NotACommit.to_string().contains(COMMIT)); +} + +#[test] +fn verse_genesis_refuses_a_duplicate_faction_record() { + let mut twice = catalog(); + let first = twice.factions[0].clone(); + twice.factions.push(first); + // The lowering does not decide: it declares all three. + let world = intent_of(twice.clone(), COMMIT).lower(owner()).unwrap(); + let subjects = world + .patch + .declarations + .iter() + .filter(|declaration| matches!(declaration, Declaration::Subject(_))) + .count(); + assert_eq!(subjects, 3); + + let (_dir, kernel) = kernel_of(intent_of(twice, COMMIT)); + let Err(KernelError::PatchRejected(set)) = kernel else { + panic!("a second faction under one record key was admitted"); + }; + assert!( + set.iter() + .any(|mismatch| matches!(mismatch, Mismatch::DuplicateImportKey { .. })), + "{set:?}" + ); +} + +#[tokio::test] +async fn every_granted_affordance_is_a_grammar_verb() { + let catalog = catalog(); + let verse = verse(intent_of(catalog.clone(), COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + + let mut kinds: Vec<_> = snapshot + .affordances + .iter() + .map(|affordance| affordance.entry.kind.0.clone()) + .collect(); + kinds.sort(); + // Each verb once: the kernel's speak is the grammar's speak. + assert_eq!( + kinds, + catalog + .grammar + .verbs() + .map(|(kind, _)| kind.to_owned()) + .collect::>() + ); + assert!(kinds.contains(&"speak".to_owned())); + + // Each has its render path from the grammar, and phase A changes nothing. + for affordance in &snapshot.affordances { + let verb = catalog.grammar.verb(&affordance.entry.kind.0).unwrap(); + assert_eq!(affordance.render_path, Some(verb.render_path)); + assert!(affordance.entry.effect_slots.is_empty()); + assert_eq!(affordance.entry.outcome_bands.len(), 1); + assert!(affordance.entry.outcome_bands[0].effects.is_empty()); + } + + // Every faction is granted every verb. + let all: BTreeSet<_> = snapshot.affordances.iter().map(|a| a.id).collect(); + for subject in &snapshot.subjects { + assert_eq!(subject.affordances, all); + } +} + +#[tokio::test] +async fn factions_get_opportunities() { + let verse = verse(intent_of(catalog(), COMMIT)).await; + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let revision = verse.mailbox.snapshot().await.unwrap().revision; + verse + .mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: verse.receipt.world_id, + expected_revision: revision, + body, + }, + &verse.principal, + ) + .await + .unwrap(); + } + let snapshot = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.phase, WorldPhase::Active); + assert_eq!(snapshot.opportunities.len(), catalog().factions.len()); + let scopes: BTreeSet<_> = snapshot.opportunities.iter().map(|o| o.scope).collect(); + assert_eq!(scopes.len(), snapshot.opportunities.len()); + for opportunity in &snapshot.opportunities { + assert_eq!(opportunity.controller_mode, ControllerMode::OperationalAgent); + assert_eq!(opportunity.affordance_ids.len(), snapshot.affordances.len()); + } +} + +#[test] +fn a_faction_without_a_description_holds_no_fact() { + let mut bare = catalog(); + bare.factions[0].description = " ".into(); + bare.factions[1].description = " Second. ".into(); + let (_dir, kernel) = kernel_of(intent_of(bare, COMMIT)); + let facts: Vec<_> = kernel + .unwrap() + .state + .facts + .values() + .map(|fact| fact.statement.as_str().to_owned()) + .collect(); + assert_eq!(facts, vec!["Second.".to_owned()]); +} + +#[test] +fn a_grammar_the_kernel_speak_cannot_live_in_refuses_genesis() { + let mut mute = catalog(); + mute.grammar = GrammarBinding::new( + "aetheria.verse_grammar", + 1, + [( + "haul".to_owned(), + GrammarVerb { + render_path: RenderPath::ShipAction, + roles: vec![GrammarRole { + role: "cargo".into(), + referent: GrammarReferentKind::Cargo, + }], + }, + )], + ) + .unwrap(); + let (_dir, kernel) = kernel_of(intent_of(mute, COMMIT)); + let Err(KernelError::PatchRejected(set)) = kernel else { + panic!("a world whose grammar has no speak was admitted"); + }; + assert!( + set.iter() + .any(|mismatch| matches!(mismatch, Mismatch::AffordanceKindNotInGrammar { .. })), + "{set:?}" + ); +} + +#[tokio::test] +async fn the_dungeon_ingress_still_creates_an_unbound_world() { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("dungeon-owner", Utc::now() + Duration::hours(1)); + mailbox + .create( + crate::CreateWorldIntent { + id: CommandId::new(), + title: "Dungeon".into(), + brief: String::new(), + human_subject_label: "Player".into(), + narrative_persona_label: None, + operational_agent_label: None, + targets: BTreeMap::new(), + jurisdictions: Vec::new(), + lens_weights: stock_weights(), + }, + &principal, + ) + .await + .unwrap(); + let snapshot = mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.grammar, None); + assert!( + snapshot + .places + .iter() + .any(|place| place.label == "The Commons") + ); +} + +#[tokio::test] +async fn a_faction_name_is_trimmed_into_its_institution_label() { + let mut padded = catalog(); + let expected = padded.factions[0].name.trim().to_owned(); + padded.factions[0].name = format!(" {expected}\t "); + let key = padded.factions[0].key.clone(); + let verse = verse(intent_of(padded, COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + let subject = snapshot + .subjects + .iter() + .find(|subject| subject.import_key.as_ref() == Some(&key)) + .expect("the padded faction was created"); + assert_eq!(subject.label, expected); +} + +#[tokio::test] +async fn genesis_twice_with_one_intent_is_one_creation() { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let intent = intent_of(catalog(), COMMIT); + let first = mailbox.create_verse(intent.clone(), &principal).await.unwrap(); + let before = mailbox.snapshot().await.unwrap(); + let again = mailbox.create_verse(intent, &principal).await.unwrap(); + let after = mailbox.snapshot().await.unwrap(); + assert_eq!(again, first); + assert_eq!(after.revision, before.revision); + assert_eq!(after.state_digest, before.state_digest); + assert_eq!(after.subjects.len(), before.subjects.len()); +} + +#[tokio::test] +async fn genesis_under_another_command_id_finds_the_world_occupied() { + let verse = verse(intent_of(catalog(), COMMIT)).await; + let before = verse.mailbox.snapshot().await.unwrap(); + let refused = verse + .mailbox + .create_verse(intent_of(catalog(), COMMIT), &verse.principal) + .await; + assert!( + matches!( + refused, + Err(VerseGenesisError::Mailbox(MailboxError::Kernel( + KernelError::CreationTargetOccupied + ))) + ), + "{refused:?}" + ); + let after = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(after.revision, before.revision); + assert_eq!(after.state_digest, before.state_digest); +} + +/// Evidence for the open question `ship-acts-are-not-speech`, pinning what the +/// phase A lowering does today: a ship verb is only admissible as an utterance, +/// and committing it asserts a claim fact the room hears. +#[test] +fn phase_a_ship_verbs_are_spoken_acts_in_the_room() { + use crate::tests::{command, opportunity_for, spoken, submit_owner}; + use crate::{ + AuthenticatedCaller, CallerId, DecisionInvocation, Role, RoleBinding, SubmitReceipt, + Target, + }; + + let (_scratch, kernel) = kernel_of(intent_of(catalog(), COMMIT)); + let mut kernel = kernel.unwrap(); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let snapshot = kernel.snapshot().unwrap(); + submit_owner(&mut kernel, &snapshot, body); + } + let active = kernel.snapshot().unwrap(); + let attack = active + .affordances + .iter() + .find(|a| a.entry.kind.0 == "attack") + .unwrap() + .id; + let [first, second] = [&active.subjects[0], &active.subjects[1]]; + let opportunity = opportunity_for(&active, first.id); + let caller = CallerId::Controller(opportunity.controller_id); + let invocation = |speech: Option<&str>| DecisionInvocation { + affordance: attack, + bindings: vec![RoleBinding { + role: Role("target".into()), + target: Target::Subject(second.id), + }], + proposed: Vec::new(), + speech: speech.map(|text| crate::patch::Statement::new(text).unwrap()), + display: None, + }; + let try_it = |kernel: &mut WorldKernel, speech: Option<&str>| { + kernel.submit( + command( + &active, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + opportunity: opportunity.clone(), + invocation: invocation(speech), + }, + ), + &AuthenticatedCaller::fixture(caller.clone()), + ) + }; + + // Without an utterance the act is refused: the verb has no other way in. + assert!(try_it(&mut kernel, None).is_err()); + // With one, the act commits and its words are a claim fact. + let receipt = try_it(&mut kernel, Some("We strike.")).expect("the attack commits"); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let event = kernel.state.events.last().unwrap(); + assert_eq!(spoken(&kernel.state, event), Some("We strike.")); +} diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index ba0cd08..2600127 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -1,9 +1,12 @@ //! The Aetheria verse run's own organs. The verse binary and its run record //! live above this module; here sit the parts of a run that belong to the -//! library: the record of every provider call and the spend it implies. +//! library: the record of every provider call and the spend it implies, and the +//! genesis of the canonical verse world. mod calls; +mod genesis; +pub use genesis::{CreateVerseWorldIntent, VerseGenesisError}; pub use calls::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart,