diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index 4249b93..f9fff20 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -51,18 +51,10 @@ pub(crate) struct EveRouteHint { const PLAY_FAULT_LINE: &str = "The storyteller lost the thread and your turn ended. Write again to start a new turn."; -/// The `world_create.v4` payload, in one place, so the button that captures it -/// and the descriptor that advertises it cannot drift. -const CREATE_BINDINGS: [&str; 8] = [ - "title", - "brief", - "subject_label", - "narrative_persona_label", - "operational_agent_label", - "targets", - "jurisdictions", - "lens_weights", -]; +/// What the Begin button captures from the owner: the three fields the form +/// asks for, and all `world_begin.v1` carries. Every other choice a world is +/// born with is the route's (`runtime::begin_intent`), not the surface's. +const BEGIN_BINDINGS: [&str; 3] = ["title", "brief", "subject_label"]; const SEED_BINDINGS: [&str; 2] = ["vault_scope", "brief"]; @@ -246,99 +238,43 @@ pub(crate) fn authenticated_surface( match snapshot { None => { - // Dungeon policy, editable before submission; the library holds no - // default weights. - let default_lens_weights = - serde_json::to_string(&crate::runtime::uniform_lens_weights()) - .context("the default lens weights encode")?; children.extend([ json!({ - "id":"world.create.title", + "id":"world.begin.title", "kind":"control.input.text", "props":{"label":"World title","placeholder":"A name for this world"}, "stateBindings":[local_draft("title", "string")], "children":[] }), - // Cut 10 (PA.f149): `CREATE_BINDINGS` has always advertised - // `brief` — `world_create.v4`'s `CreatePayload.brief` is - // required, not `#[serde(default)]` — but no control ever - // captured it. Driving `world.create` with the real - // vendored lowering surfaced this: nothing the lowering - // could ever submit through this form would satisfy - // `world_create.v4`, hand-built test payloads notwithstanding. json!({ - "id":"world.create.brief", - "kind":"control.input.textarea", - // PA.f164: `brief` is documented "required, may be empty" - // (`CreatePayload::brief`), the same deliberate-empty - // shape as `targets`/`jurisdictions` below — an authored - // `value` so a form left untouched here still submits. - "props":{"label":"Brief","rows":2,"value":"","placeholder":"One sentence of what this world is for"}, - "stateBindings":[local_draft("brief", "string")], - "children":[] - }), - json!({ - "id":"world.create.subject", + "id":"world.begin.subject", "kind":"control.input.text", "props":{"label":"Your name","placeholder":"The first person in the world"}, "stateBindings":[local_draft("subject_label", "string")], "children":[] }), + // `brief` is "required, may be empty" (`CreatePayload::brief`): + // an authored `value`, because the lowering captures only a + // `value`, so a form left untouched here still submits. json!({ - "id":"world.create.narrative_persona", - "kind":"control.input.text", - "props":{"label":"Narrative persona (optional)","placeholder":"A person who lives the story in prose"}, - "stateBindings":[local_draft("narrative_persona_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.operational_agent", - "kind":"control.input.text", - "props":{"label":"Operational agent (optional)","placeholder":"An institution or operator-shaped mind"}, - "stateBindings":[local_draft("operational_agent_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.targets", + "id":"world.begin.brief", "kind":"control.input.textarea", - // PA.f164: an authored `value`, not only a `placeholder` — - // `findAuthoredBindingValue` (the vendored lowering) only - // ever captures a control's `value`, never its - // `placeholder`, so a form submitted without editing this - // field would otherwise be refused as missing. `{}` is a - // legitimate `targets` (a world with no scale target is a - // deliberate choice, `CreatePayload`'s own doc comment). - "props":{"label":"Scale target","rows":2,"value":"{}","placeholder":"{\"person\": 12, \"institution\": 3}"}, - "stateBindings":[local_draft("targets", "string")], - "children":[] - }), - json!({ - "id":"world.create.jurisdictions", - "kind":"control.input.textarea", - // PA.f164: same gap, `[]` is a legitimate empty root list. - "props":{"label":"Jurisdiction roots","rows":3,"value":"[]","placeholder":"[{\"handle\":\"low_sere\",\"label\":\"The Low Sere\",\"permille\":700}]"}, - "stateBindings":[local_draft("jurisdictions", "string")], - "children":[] - }), - json!({ - "id":"world.create.lens_weights", - "kind":"control.input.textarea", - "props":{"label":"Lens weights","rows":2,"value":default_lens_weights,"placeholder":default_lens_weights}, - "stateBindings":[local_draft("lens_weights", "string")], + "props":{"label":"Brief (optional)","rows":2,"value":"","placeholder":"One sentence of what this world is for"}, + "stateBindings":[local_draft("brief", "string")], "children":[] }), command_button( - "world.create", - "Create world", - "world.create", + "world.begin", + "Begin", + "world.begin", json!({}), - &CREATE_BINDINGS, + &BEGIN_BINDINGS, ), ]); commands.push(command_descriptor( - "world.create", - "ghostlight.world_create.v4", - &CREATE_BINDINGS, + "world.begin", + "ghostlight.world_begin.v1", + &BEGIN_BINDINGS, "WorldMailbox", )); } @@ -610,7 +546,7 @@ pub(crate) fn authenticated_surface( json!({ "id":"world.play.text", "kind":"control.input.textarea", - // PA.f214: an authored `value`, as `world.create.brief` above: + // PA.f214: an authored `value`, as `world.begin.brief` above: // the lowering only captures a `value`, so a box left // untouched must still submit `text: ""`, which continues // an existing Running turn (the running hint tells players @@ -710,7 +646,7 @@ pub(crate) fn operation_schema(operation: &str) -> Option<&'static str> { "heimdall.auth.begin" => "heimdall.auth_begin_command.v1", "heimdall.auth.complete" => "heimdall.auth_complete_command.v1", "app.auth.logout" => "ghostlight.app_logout.v2", - "world.create" => "ghostlight.world_create.v4", + "world.begin" => "ghostlight.world_begin.v1", "world.approve" => "ghostlight.world_approve.v0", "world.activate" => "ghostlight.world_activate.v0", "world.advance_time" => "ghostlight.world_advance_time.v0", @@ -922,37 +858,100 @@ mod tests { assert_eq!(surface["version"], 0); } - #[test] - fn empty_authenticated_surface_has_create_without_session_zero() { - let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); - let encoded = serde_json::to_string(&surface).unwrap(); - assert!(encoded.contains("world.create")); - assert!(encoded.contains("narrative_persona_label")); - assert!(encoded.contains("operational_agent_label")); - assert!(!encoded.contains("session_zero")); - assert!(!encoded.contains("campaign")); - assert_eq!(surface["version"], 0); - - // The lens control's default is Dungeon's uniform policy, every stock - // lens named, and it decodes as the weights the payload will carry. - fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node.as_object()? - .values() - .flat_map(|value| match value { - Value::Array(items) => items.iter().collect::>(), - other => vec![other], - }) - .find_map(|child| find(child, id)) + fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { + if node["id"] == id { + return Some(node); } - let control = find(&surface, "world.create.lens_weights").expect("a lens weights control"); - let default = control["props"]["value"].as_str().expect("a default value"); - let decoded: ghostlight::LensWeights = serde_json::from_str(default).unwrap(); - assert_eq!(decoded, crate::runtime::uniform_lens_weights()); - assert_eq!(decoded.iter().count(), ghostlight::Lens::ALL.len()); - assert!(decoded.iter().all(|(_, weight)| weight == 1)); + node["children"].as_array()?.iter().find_map(|child| find(child, id)) + } + + /// R2 at the surface: the empty world asks for a title, a name and an + /// optional brief, and offers one command, Begin. The button carries no + /// authored choice of its own: what a world is born with beyond those + /// three fields is the route's, and the descriptor captures only what the + /// form asks. + #[test] + fn the_empty_world_surface_asks_for_a_title_a_name_and_offers_one_begin() { + let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); + assert_eq!(surface["version"], 0); + let encoded = serde_json::to_string(&surface).unwrap(); + for retired in [ + "narrative_persona", + "operational_agent", + "world.create", + "lens_weights", + "jurisdictions", + "session_zero", + "campaign", + ] { + assert!( + !encoded.contains(retired), + "the empty-world surface still carries {retired}" + ); + } + let root = &surface["surface"]["root"]; + for id in ["world.begin.title", "world.begin.subject", "world.begin.brief"] { + assert!(find(root, id).is_some(), "{id}"); + } + for id in [ + "world.begin.narrative_persona", + "world.begin.operational_agent", + "world.begin.targets", + "world.begin.jurisdictions", + "world.begin.lens_weights", + ] { + assert!(find(root, id).is_none(), "{id}"); + } + assert_eq!(find(root, "world.begin.brief").unwrap()["props"]["value"], ""); + + // Beside the sign-out every signed-in surface carries, Begin is the + // only command. + let commands: Vec<&Value> = surface["commands"] + .as_array() + .unwrap() + .iter() + .filter(|descriptor| descriptor["command"] != "app.auth.logout") + .collect(); + assert_eq!(commands.len(), 1, "{commands:?}"); + assert_eq!(commands[0]["command"], "world.begin"); + assert_eq!(commands[0]["payloadSchema"], "ghostlight.world_begin.v1"); + assert_eq!(commands[0]["authority"], "WorldMailbox"); + assert_eq!(commands[0]["captureBindings"], json!(["title", "brief", "subject_label"])); + + let button = find(root, "world.begin").expect("a Begin button"); + assert_eq!(button["props"]["label"], "Begin"); + assert_eq!( + button["props"]["action"], + json!({"command":"world.begin"}), + "the Begin button authors nothing beyond its command" + ); + } + + /// Each control feeds the field it is named for: typing into a control + /// puts the text under the binding that control declares, and the Begin + /// button captures exactly the bindings the three controls declare. A + /// control bound to another control's field would submit no name (or + /// overwrite the title) in a real client. + #[test] + fn each_begin_control_is_bound_to_its_own_field_and_the_button_captures_them_all() { + let surface = authenticated_surface("sha256:owner", None, None, false).unwrap(); + let root = &surface["surface"]["root"]; + let bound = |id: &str| -> String { + let bindings = find(root, id).unwrap()["stateBindings"].as_array().unwrap().clone(); + assert_eq!(bindings.len(), 1, "{id}"); + bindings[0]["bindingName"].as_str().unwrap().to_owned() + }; + assert_eq!(bound("world.begin.title"), "title"); + assert_eq!(bound("world.begin.subject"), "subject_label"); + assert_eq!(bound("world.begin.brief"), "brief"); + let mut captured: Vec = find(root, "world.begin").unwrap()["props"]["captureBindings"] + .as_array() + .unwrap() + .iter() + .map(|name| name.as_str().unwrap().to_owned()) + .collect(); + captured.sort(); + assert_eq!(captured, ["brief", "subject_label", "title"]); } /// `world.play`'s own operation id resolves to its schema through diff --git a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json index 3907bbc..5798d2d 100644 --- a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json +++ b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json @@ -5,23 +5,20 @@ "intent": { "clientId": "eve.bridge.test", "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:34.345Z", + "issuedAt": "2026-10-10T00:38:56.201Z", "operation": { - "idempotencyKey": "00e4b100-7626-4c62-8f45-5c2cfd5b5528", - "operationId": "world.create", + "idempotencyKey": "25d921aa-f228-425c-92e3-08c5dd9af508", + "operationId": "world.begin", "routeHint": { "sourceVersion": 0, "transport": "https-json" }, - "schemaId": "ghostlight.world_create.v4" + "schemaId": "ghostlight.world_begin.v1" }, "payload": { "bindings": { "brief": "A world captured for the committed Eve client bridge fixture.", - "jurisdictions": "[]", - "lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}", "subject_label": "The Fixture Operator", - "targets": "{}", "title": "Bridge Fixture World" } }, @@ -30,59 +27,15 @@ "schema": "gamecult.eve.command_invocation.v1", "surfaceId": "ghostlight.play" }, - "label": "world.create" + "label": "world.begin" }, { "intent": { "clientId": "eve.bridge.test", "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:35.759Z", + "issuedAt": "2026-10-10T00:38:57.625Z", "operation": { - "idempotencyKey": "10b5cf50-43c3-4735-b54c-3cec0c1e632b", - "operationId": "world.approve", - "routeHint": { - "sourceVersion": 1, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_approve.v0" - }, - "payload": {}, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.approve" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:37.240Z", - "operation": { - "idempotencyKey": "d89dea99-cfeb-4992-9d5d-33b3f7a1cce2", - "operationId": "world.activate", - "routeHint": { - "sourceVersion": 2, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_activate.v0" - }, - "payload": {}, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.activate" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:38.611Z", - "operation": { - "idempotencyKey": "d654e813-be7d-4818-9872-9aecd86c4e39", + "idempotencyKey": "fc391d0d-d107-4a66-b5c5-b754ffa814fa", "operationId": "world.play", "routeHint": { "sourceVersion": 3, diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 74fd519..ba0392f 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -14,7 +14,7 @@ use crate::{ use ghostlight::{ CONSUMER_BODY_LIMIT, CommandBody, CommandId, ConnectorBinding, ConsumerPort, ConsumerRegistry, ControllerError, ControllerModels, ControllerPort, ControllerRunner, ControllerWorkCustody, - CreateJurisdictionIntent, CreateWorldIntent, DEFAULT_LOCAL_MODEL_PREFIX, + CreateWorldIntent, DEFAULT_LOCAL_MODEL_PREFIX, DEFAULT_SDK_MODEL_PREFIX, KernelError, Lens, LensWeights, LocalBinding, MailboxError, PersonaLane, PrincipalCommandIntent, PrincipalId, SdkBinding, SeedOutcome, SeedPort, SubjectKind, SubmitReceipt, TickMinutes, @@ -39,6 +39,7 @@ use cultnet_rs::{ decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, }; use serde::Deserialize; +use sha2::{Digest, Sha256}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -107,36 +108,19 @@ struct ProductionAdmission { write_lease: ProcessWriteLeaseGuard, } +/// What the owner states to begin a world: three fields, nothing else. Every +/// other choice a world is born with (no scale targets, no jurisdiction roots, +/// no persona or agent label, uniform lens weights) is decided in +/// `begin_intent`, so a client cannot author them and the surface does not +/// carry them. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -struct CreatePayload { +struct BeginPayload { title: String, /// The world's premise in the owner's words, projected to every lane as - /// guidance. Required, may be empty; a payload that omits it is refused, - /// which is what `world_create.v4` means. + /// guidance. Required, may be empty. brief: String, subject_label: String, - #[serde(default)] - narrative_persona_label: Option, - #[serde(default)] - operational_agent_label: Option, - /// World-wide target of goal-bearing subjects per kind. Required, and may - /// be empty: a world with no target is a deliberate choice, not a default - /// that arrives because nobody said anything. A payload that omits it is - /// refused, which is what `world_create.v4` means. - #[serde(deserialize_with = "deserialize_json_capture")] - targets: BTreeMap, - /// The jurisdiction roots, declared by genesis beside the commons because - /// `resolve_patch` only resolves roots the same patch declares. A duplicate - /// handle and a permille sum over 1000 are refused by the resolver, not - /// pre-checked here: a pre-check would be a second reducer. - #[serde(deserialize_with = "deserialize_json_capture")] - jurisdictions: Vec, - /// Required, and must draw: a payload that omits it is refused, which is - /// what `world_create.v4` means. An unknown lens name is refused by - /// deserialization before any handler runs. - #[serde(deserialize_with = "deserialize_json_capture")] - lens_weights: LensWeights, } /// Cut 10 (PA.f149): the vendored Eve browser lowering's own @@ -216,35 +200,6 @@ fn unwrap_bindings(payload: &Value) -> Result { Ok(Value::Object(merged)) } -/// Cut 10 (PA.f149): every one of these three fields is edited in a plain -/// `control.input.textarea` — the vendored Eve browser lowering has no -/// JSON-typed control — so a real client always captures the field as the -/// JS string the textarea holds (Dungeon's own `eve::local_draft` calls -/// these fields' `valueKind` `"string"` for exactly this reason), never a -/// parsed object or array. A hand-built payload (existing tests) may still -/// supply the already-parsed value directly. This accepts either: a JSON -/// string is parsed as JSON text; anything else is deserialized as the typed -/// value directly. -fn deserialize_json_capture<'de, D, T>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, - T: serde::de::DeserializeOwned, -{ - match Value::deserialize(deserializer)? { - Value::String(text) => serde_json::from_str(&text).map_err(serde::de::Error::custom), - other => serde_json::from_value(other).map_err(serde::de::Error::custom), - } -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CreateJurisdiction { - /// The draft handle genesis declares the root under, and the intent's key. - handle: String, - label: String, - permille: u32, -} - #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct SeedPayload { @@ -261,16 +216,6 @@ struct SeedPayload { brief: Option, } -/// The shape a jurisdiction handle may take. Checked at ingress because the -/// handle becomes a draft-index key the model reads back in mismatches, and a -/// handle carrying whitespace or case would make that text unusable. -fn is_handle_shape(value: &str) -> bool { - let mut chars = value.chars(); - value.len() <= 48 - && chars.next().is_some_and(|first| first.is_ascii_lowercase()) - && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') -} - /// `world.play`'s own payload. Cut 10 (PA.f151) deleted the client-supplied /// `answers` field: the vendored Eve browser lowering has no authored, /// non-editable binding value (`hidden` is not a prop any renderer reads), so @@ -1242,6 +1187,8 @@ enum RuntimeCommandError { Mailbox(#[from] MailboxError), #[error(transparent)] Kernel(#[from] KernelError), + #[error("the begin stopped at its {step} step: {cause}")] + BeginStopped { step: &'static str, cause: String }, } /// The one door an answer's own `answerToken` is judged by (PA.f179): admits @@ -1257,6 +1204,147 @@ fn answer_token_admits(given: Option<&str>, expected: Option<&str>) -> bool { matches!((given, expected), (Some(given), Some(expected)) if given == expected) } +/// What Begin does after the create, and nothing else decides it: the kernel +/// commands it submits in order, each as its own command. The kernel judges +/// each one; the sequence stops at the first refusal. Where a seed vault is +/// configured the sequence ends at the approved Draft, because seeding is the +/// owner's Draft-only lane and Begin must not close it by activating; the +/// card then offers the seed controls and Activate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BeginStep { + Approve, + Activate, +} + +impl BeginStep { + fn name(self) -> &'static str { + match self { + Self::Approve => "approve", + Self::Activate => "activate", + } + } + + fn reached(self) -> &'static str { + match self { + Self::Approve => "approved", + Self::Activate => "activated", + } + } + + fn body(self) -> CommandBody { + match self { + Self::Approve => CommandBody::ApproveDraft, + Self::Activate => CommandBody::ActivateWorld, + } + } +} + +fn begin_plan(seed_available: bool) -> &'static [BeginStep] { + if seed_available { + &[BeginStep::Approve] + } else { + &[BeginStep::Approve, BeginStep::Activate] + } +} + +/// The world a Begin creates: the owner's three fields and the defaults this +/// route owns (no scale targets, no jurisdiction roots, no persona or agent +/// label, Dungeon's uniform lens policy). +fn begin_intent(id: CommandId, payload: BeginPayload) -> CreateWorldIntent { + CreateWorldIntent { + id, + title: payload.title, + brief: payload.brief, + human_subject_label: payload.subject_label, + narrative_persona_label: None, + operational_agent_label: None, + targets: BTreeMap::new(), + jurisdictions: Vec::new(), + lens_weights: uniform_lens_weights(), + } +} + +/// The id of one of the kernel commands a Begin submits after its create: +/// derived from the Begin's own key and the step's name, so a replayed Begin +/// replays each of its commands through the kernel's own idempotency ledger +/// instead of submitting new ones. +fn begin_step_id(begin_key: &str, step: BeginStep) -> Result { + let key = uuid::Uuid::parse_str(begin_key).map_err(|_| KernelError::InvalidCommandId)?; + let digest = Sha256::new() + .chain_update(b"ghostlight.world.begin.v1") + .chain_update(key.as_bytes()) + .chain_update(step.name().as_bytes()) + .finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + CommandId::parse_uuid(&uuid::Uuid::from_bytes(bytes).to_string()) +} + +/// `world.begin`, the one way a world is made: the owner's one click. The +/// surface sequences, the kernel decides: create and the steps of +/// `begin_plan` reach it as separate commands, each admitted or refused on +/// its own, and the sequence stops at the first refusal. What the kernel +/// committed before it stays committed, and the card then offers the next +/// step's own button. When the owner is not the world's only required +/// approver the kernel refuses the activate step. +async fn begin_world( + state: &AppState, + verified_principal: &VerifiedPrincipalEvidence, + begin_key: &str, + command_id: CommandId, + payload: BeginPayload, +) -> Result { + let created = state + .world + .create(begin_intent(command_id, payload), verified_principal) + .await + .map_err(|error| stopped_at("create", map_mailbox(error)))?; + let mut reached = "created"; + for &step in begin_plan(state.seed_vault_root.is_some()) { + let snapshot = current_world(state) + .await + .ok() + .flatten() + .ok_or(RuntimeCommandError::BeginStopped { + step: step.name(), + cause: "the world could not be read".into(), + })?; + state + .world + .submit_principal( + PrincipalCommandIntent { + id: begin_step_id(begin_key, step).map_err(|error| stopped_at(step.name(), error.into()))?, + world_id: created.world_id, + expected_revision: snapshot.revision, + body: step.body(), + }, + verified_principal, + ) + .await + .map_err(|error| stopped_at(step.name(), map_mailbox(error)))?; + reached = step.reached(); + } + Ok(json!({ + "kind":"begun", + "reached":reached, + "worldId":serde_json::to_value(created.world_id).unwrap_or(Value::Null), + "commitDigest":created.commit_digest + })) +} + +/// A refusal at one step of a Begin: its own variant so the receipt names the +/// step, except an unknown outcome, which stays one (the caller must not read +/// it as a refusal). +fn stopped_at(step: &'static str, error: RuntimeCommandError) -> RuntimeCommandError { + match error { + RuntimeCommandError::OutcomeUnknown(detail) => RuntimeCommandError::OutcomeUnknown(format!("{step}: {detail}")), + other => RuntimeCommandError::BeginStopped { + step, + cause: other.to_string(), + }, + } +} + async fn execute_world( state: &AppState, verified_principal: &VerifiedPrincipalEvidence, @@ -1269,52 +1357,17 @@ async fn execute_world( .as_deref() .unwrap_or(""), )?; - if invocation.operation.operation_id == "world.create" { - let payload: CreatePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - if let Some(root) = payload - .jurisdictions - .iter() - .find(|root| !is_handle_shape(&root.handle)) - { - return Err(RuntimeCommandError::Payload(format!( - "jurisdiction handle {} is not a draft handle", - root.handle - ))); - } - let receipt = state - .world - .create( - CreateWorldIntent { - id: command_id, - title: payload.title, - brief: payload.brief, - human_subject_label: payload.subject_label, - narrative_persona_label: payload.narrative_persona_label.filter(|label| !label.trim().is_empty()), - operational_agent_label: payload.operational_agent_label.filter(|label| !label.trim().is_empty()), - targets: payload.targets, - jurisdictions: payload - .jurisdictions - .into_iter() - .map(|root| CreateJurisdictionIntent { - handle: root.handle, - label: root.label, - permille: root.permille, - }) - .collect(), - lens_weights: payload.lens_weights, - }, - verified_principal, - ) - .await - .map_err(map_mailbox)?; - return Ok(json!({ - "kind":"created", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "worldId":serde_json::to_value(receipt.world_id).unwrap_or(Value::Null), - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - })); + if invocation.operation.operation_id == "world.begin" { + // The detail of a refused payload is not echoed: this route's fields + // are what the owner typed. + let payload: BeginPayload = unwrap_bindings(&invocation.payload) + .ok() + .and_then(|flat| serde_json::from_value(flat).ok()) + .ok_or_else(|| { + RuntimeCommandError::Payload("the begin payload does not satisfy world_begin.v1".into()) + })?; + let key = invocation.operation.idempotency_key.as_deref().unwrap_or(""); + return begin_world(state, verified_principal, key, command_id, payload).await; } if invocation.operation.operation_id == "world.play" { // Cut 10 (PA.f152): owner-gated, like `world.advance_time` and @@ -2292,7 +2345,7 @@ mod tests { GameCultRuntimePresenceHealthRecord, RuntimePresenceAuthenticationContext, authenticate_runtime_presence_claim, }; - use ghostlight::{InferenceFault, InferencePurpose}; + use ghostlight::{CreateJurisdictionIntent, CreationReceipt, InferenceFault, InferencePurpose}; use tower::ServiceExt; /// A fake clock so the retry-until-deadline policy is provable without @@ -3138,10 +3191,10 @@ mod tests { /// report, not a skip to swallow quietly. #[tokio::test] #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn world_create_seed_activate_and_play_round_trip_through_the_real_client() { + async fn world_begin_and_play_round_trip_through_the_real_client() { if !eve_client_bridge_is_available() { panic!( - "world_create_seed_activate_and_play_round_trip_through_the_real_client was run \ + "world_begin_and_play_round_trip_through_the_real_client was run \ (via --ignored) but the real Eve client bridge is not available: `node` is \ missing, or the vendored lowering's built `dist/index.js`, its `jsdom` \ devDependency, or the sibling `eve-contracts` package's own `ajv` dependency does \ @@ -3155,42 +3208,27 @@ mod tests { let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let create_intents = client_intents( + let begin_intents = client_intents( &empty_surface, &provider, json!([ {"set": { - "world.create.title": "Bridge World", - "world.create.brief": "A world built by the real client bridge.", - "world.create.subject": "The Bridge Operator", - "world.create.targets": "{}", - "world.create.jurisdictions": "[]", - "world.create.lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}" + "world.begin.title": "Bridge World", + "world.begin.brief": "A world built by the real client bridge.", + "world.begin.subject": "The Bridge Operator" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ); - assert_eq!(create_intents.len(), 1); - let created = post(&fixture.state, &fixture.cookie, create_intents.into_iter().next().unwrap()).await; - assert_eq!(created["receipt"]["state"], "accepted", "world.create via the real client: {created}"); - - let draft_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let approve_intents = client_intents(&draft_surface, &provider, json!([{"click": "world.approve"}])); - assert_eq!(approve_intents.len(), 1); - let approved = post(&fixture.state, &fixture.cookie, approve_intents.into_iter().next().unwrap()).await; - assert_eq!(approved["receipt"]["state"], "accepted", "world.approve via the real client: {approved}"); - - // No fixture sets `AppState.seed_vault_root`, so the Draft surface - // offers no seeding to click at all (PA.f213). - let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + assert_eq!(begin_intents.len(), 1); + let begun = post(&fixture.state, &fixture.cookie, begin_intents.into_iter().next().unwrap()).await; + assert_eq!(begun["receipt"]["state"], "accepted", "world.begin via the real client: {begun}"); + assert_eq!(begun["receipt"]["reached"], "activated", "{begun}"); + let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; assert!( - !serde_json::to_string(&approved_surface).unwrap().contains("world.seed"), - "a server with no vault offered seeding to the real client" + serde_json::to_string(&active_surface).unwrap().contains("world.play.card"), + "one Begin through the real client did not reach the play card" ); - let activate_intents = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])); - assert_eq!(activate_intents.len(), 1); - let activated = post(&fixture.state, &fixture.cookie, activate_intents.into_iter().next().unwrap()).await; - assert_eq!(activated["receipt"]["state"], "accepted", "world.activate via the real client: {activated}"); // A typed Play on a fresh Active world opens a turn. The first table's // inference port blocks until released, so the turn stays `Running` @@ -3344,7 +3382,7 @@ mod tests { /// PA.f162's own gate proof: replays the committed fixture's intents, /// in order, straight through `api_router` — no `node`, no bridge, no /// vendored `dist/index.js` anywhere in this call path. This is what - /// `world_create_seed_activate_and_play_round_trip_through_the_real_client` + /// `world_begin_and_play_round_trip_through_the_real_client` /// (which drives the real bridge, and so needs `node`) proved once, at /// capture time; this test proves it again on every run, on the door /// Idunn's own `cargo test --locked -p ghostlight-dungeon --bin @@ -3355,26 +3393,17 @@ mod tests { let fixture = fixture().await; let mut steps = doc.steps.into_iter(); - let create = steps.next().unwrap(); - assert_eq!(create.label, "world.create"); - let created = post(&fixture.state, &fixture.cookie, create.intent).await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); - - let approve = steps.next().unwrap(); - assert_eq!(approve.label, "world.approve"); - let approved = post(&fixture.state, &fixture.cookie, approve.intent).await; - assert_eq!(approved["receipt"]["state"], "accepted", "{approved}"); - - let activate = steps.next().unwrap(); - assert_eq!(activate.label, "world.activate"); - let activated = post(&fixture.state, &fixture.cookie, activate.intent).await; - assert_eq!(activated["receipt"]["state"], "accepted", "{activated}"); + let begin = steps.next().unwrap(); + assert_eq!(begin.label, "world.begin"); + let begun = post(&fixture.state, &fixture.cookie, begin.intent).await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + assert_eq!(begun["receipt"]["reached"], "activated", "{begun}"); let play = steps.next().unwrap(); assert_eq!(play.label, "world.play"); let played = post(&fixture.state, &fixture.cookie, play.intent).await; assert_eq!(played["receipt"]["state"], "accepted", "{played}"); - assert!(steps.next().is_none(), "the fixture must carry exactly these four steps"); + assert!(steps.next().is_none(), "the fixture must carry exactly these two steps"); let table = fixture.state.play.clone().unwrap(); let observed = tokio::time::timeout(Duration::from_secs(5), async move { @@ -3826,7 +3855,7 @@ mod tests { /// client's past behavior pretending to still be the client's current /// one. Runs only when `node` and the built lowering are actually /// available (`eve_client_bridge_is_available`). `#[ignore]`d for the - /// same reason `world_create_seed_activate_and_play_round_trip_through_the_real_client` + /// same reason `world_begin_and_play_round_trip_through_the_real_client` /// is (Soul's owed item 3, above): a normal `cargo test` run must /// distinguish "not exercised" from "passed" in its own summary line /// without `--nocapture`, and only `--ignored` opts in. @@ -3868,39 +3897,22 @@ mod tests { let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let create_intent = client_intents( + let begin_intent = client_intents( &empty_surface, &provider, json!([ {"set": { - "world.create.title": "Bridge Fixture World", - "world.create.brief": "A world captured for the committed Eve client bridge fixture.", - "world.create.subject": "The Fixture Operator", - "world.create.targets": "{}", - "world.create.jurisdictions": "[]", - "world.create.lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}" + "world.begin.title": "Bridge Fixture World", + "world.begin.brief": "A world captured for the committed Eve client bridge fixture.", + "world.begin.subject": "The Fixture Operator" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ) .into_iter() .next() .unwrap(); - post(&fixture.state, &fixture.cookie, create_intent.clone()).await; - - let draft_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let approve_intent = client_intents(&draft_surface, &provider, json!([{"click": "world.approve"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, approve_intent.clone()).await; - - let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let activate_intent = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, activate_intent.clone()).await; + post(&fixture.state, &fixture.cookie, begin_intent.clone()).await; let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; let play_intent = client_intents( @@ -3916,7 +3928,7 @@ mod tests { .unwrap(); post(&fixture.state, &fixture.cookie, play_intent.clone()).await; - let regenerated = [create_intent, approve_intent, activate_intent, play_intent]; + let regenerated = [begin_intent, play_intent]; let committed = bridge_fixture().steps; assert_eq!(regenerated.len(), committed.len(), "step count drifted"); for (regenerated_intent, committed_step) in regenerated.into_iter().zip(committed) { @@ -4052,10 +4064,10 @@ mod tests { /// bridge-dependent tests (Soul's owed item 3, above). #[tokio::test] #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted() { + async fn a_begin_form_filled_only_by_its_labelled_fields_is_still_accepted() { if !eve_client_bridge_is_available() { panic!( - "a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted was run \ + "a_begin_form_filled_only_by_its_labelled_fields_is_still_accepted was run \ (via --ignored) but the real Eve client bridge is not available: `node` is missing, \ or the vendored lowering's built `dist/index.js`, its `jsdom` devDependency, or the \ sibling `eve-contracts` package's own `ajv` dependency does not resolve in this \ @@ -4074,10 +4086,10 @@ mod tests { &provider, json!([ {"set": { - "world.create.title": "Untouched Fields World", - "world.create.subject": "The Owner" + "world.begin.title": "Untouched Fields World", + "world.begin.subject": "The Owner" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ); assert_eq!(create_intents.len(), 1); @@ -4087,8 +4099,9 @@ mod tests { // rather than omitting the fields outright. let bindings = &intent["payload"]["bindings"]; assert_eq!(bindings["brief"], ""); - assert_eq!(bindings["targets"], "{}"); - assert_eq!(bindings["jurisdictions"], "[]"); + // The button authors nothing beside `bindings`: the route owns every + // other choice a world is born with. + assert_eq!(intent["payload"].as_object().unwrap().keys().collect::>(), ["bindings"]); let created = post(&fixture.state, &fixture.cookie, intent).await; assert_eq!( @@ -4113,26 +4126,7 @@ mod tests { #[tokio::test] async fn a_payload_with_an_unrecognized_sibling_field_beside_bindings_is_refused() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Sibling Field World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Sibling Field World").await; let denied = post( &fixture.state, @@ -4168,26 +4162,7 @@ mod tests { #[tokio::test] async fn a_payload_naming_the_same_field_in_bindings_and_as_a_sibling_is_refused() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Colliding Field World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Colliding Field World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -4225,27 +4200,7 @@ mod tests { #[tokio::test] async fn http_eve_journey_uses_one_world_owner() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Cutover World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - assert_eq!(created["receipt"]["sourceVersion"], 1); + draft_world(&fixture.state, &fixture.cookie, "Cutover World").await; let approved = post( &fixture.state, @@ -4329,26 +4284,7 @@ mod tests { #[tokio::test] async fn world_play_reaches_the_play_table_and_is_accepted() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Play Routing World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Play Routing World").await; let played = post( &fixture.state, @@ -4396,26 +4332,7 @@ mod tests { #[tokio::test] async fn a_play_commit_does_not_poison_the_next_world_commands_compare_and_swap() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"CAS World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "CAS World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -4482,74 +4399,9 @@ mod tests { ); } - /// The create/approve/activate boilerplate every scripted-question - /// harness below needs, factored out so `play_world_with_a_scripted_question` - /// and `play_world_with_two_scripted_questions` (Cut 13) do not each - /// repeat it. Returns the fixture and the world's `sourceVersion` after - /// activation. - /// A cleared optional label means none. The form's persona and agent boxes - /// have no authored value, so a player who typed a name and deleted it - /// sends "" (or spaces); the route reads that as no persona and no agent, - /// not as a world with an empty-named one the kernel then refuses. - /// - /// Mutation: pass `payload.narrative_persona_label` and - /// `payload.operational_agent_label` through `create` unfiltered. - #[tokio::test] - async fn cleared_optional_labels_create_a_world_with_no_persona_and_no_agent() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Cleared Labels", - "brief":"", - "subject_label":"Operator", - "narrative_persona_label":"", - "operational_agent_label":" ", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); - let snapshot = fixture.state.world.snapshot().await.unwrap(); - assert_eq!( - snapshot.subjects.len(), - 1, - "a cleared label declared a persona or agent: {} subjects", - snapshot.subjects.len() - ); - } - async fn activated_fixture(title: &str) -> (Fixture, u64) { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":title, - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, title).await; let approved = post( &fixture.state, &fixture.cookie, @@ -5272,26 +5124,7 @@ mod tests { #[tokio::test] async fn a_play_row_only_change_wakes_the_revisions_channel() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Wake World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Wake World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -5398,26 +5231,7 @@ mod tests { #[tokio::test] async fn an_admission_refusal_reaches_the_caller_as_denied_not_accepted() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Admission World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Admission World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -5463,26 +5277,7 @@ mod tests { #[tokio::test] async fn world_play_is_owner_gated_on_the_surface_and_the_route() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Owner-Gated World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "Owner-Gated World").await; let approved = post( &fixture.state, &fixture.cookie, @@ -5544,26 +5339,7 @@ mod tests { async fn world_play_without_a_play_table_is_denied() { let mut fixture = fixture().await; fixture.state.play = None; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"No Play World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "No Play World").await; let played = post( &fixture.state, @@ -5600,26 +5376,7 @@ mod tests { #[tokio::test] async fn world_play_without_an_idempotency_key_is_refused_not_defaulted() { let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"No Key World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); + draft_world(&fixture.state, &fixture.cookie, "No Key World").await; let mut keyless: EveCommandInvocation = serde_json::from_value(invocation( "world.play", @@ -5645,24 +5402,453 @@ mod tests { ); } + // ---- World.begin ---------------------------------------------------- + + /// The payload the real client builds from the served Begin button, with + /// the three fields typed. Every input comes from the surface this process + /// served, never from a copy in the test. + fn begin_payload_from(surface: &Value, title: &str, brief: &str, subject: &str) -> Value { + untouched_intent( + surface, + "world.begin", + &[("title", json!(title)), ("brief", json!(brief)), ("subject_label", json!(subject))], + )["payload"] + .clone() + } + + /// What a client sends when the player types into the controls named by + /// id: each text lands under the binding that control declares on the + /// served surface. A control bound to the wrong field shows up here. + fn begin_payload_typed_into_controls(surface: &Value, typed: &[(&str, &str)]) -> Value { + fn control<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { + if node["id"] == id { + return Some(node); + } + node["children"].as_array().into_iter().flatten().find_map(|child| control(child, id)) + } + let by_binding: Vec<(String, Value)> = typed + .iter() + .map(|(id, text)| { + let node = control(&surface["surface"]["root"], id).unwrap_or_else(|| panic!("no control {id}")); + (node["stateBindings"][0]["bindingName"].as_str().unwrap().to_owned(), json!(text)) + }) + .collect(); + let pairs: Vec<(&str, Value)> = by_binding.iter().map(|(name, value)| (name.as_str(), value.clone())).collect(); + untouched_intent(surface, "world.begin", &pairs)["payload"].clone() + } + + fn begin_invocation(payload: Value, key: &str) -> Value { + invocation("world.begin", "ghostlight.world_begin.v1", 0, payload, key) + } + + async fn served_surface(fixture: &Fixture) -> Value { + get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await + } + + async fn owner_principal(fixture: &Fixture) -> VerifiedPrincipalEvidence { + fixture + .state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .unwrap() + } + + fn typed_begin(title: &str, brief: &str, subject: &str) -> BeginPayload { + BeginPayload { title: title.into(), brief: brief.into(), subject_label: subject.into() } + } + + /// R1 and R2: one Begin reaches an Active world, and the kernel received + /// three separate commands, each admitted on its own. The ledger is the + /// witness: each step's own id replays as already applied at its own + /// revision, so none of them was merged into another or skipped. + /// + /// Mutations: submit no approve (the activate is then refused); give the + /// approve and activate commands the create's id (the kernel's ledger + /// answers the replay of their own ids with a conflict, not a receipt). + #[tokio::test] + async fn begin_commits_create_approve_and_activate_as_three_separate_commands() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Begun World", "A premise.", "The Owner"); + let begun = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + assert_eq!(begun["receipt"]["kind"], "begun"); + assert_eq!(begun["receipt"]["reached"], "activated"); + + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.phase, WorldPhase::Active); + assert_eq!(world.revision, 2, "genesis, approval and activation are three commits"); + + let principal = owner_principal(&fixture).await; + for (step, revision) in [(BeginStep::Approve, 1), (BeginStep::Activate, 2)] { + let replay = fixture + .state + .world + .submit_principal( + PrincipalCommandIntent { + id: begin_step_id(&key, step).unwrap(), + world_id: world.world_id, + expected_revision: world.revision, + body: step.body(), + }, + &principal, + ) + .await + .unwrap(); + match replay { + SubmitReceipt::AlreadyApplied(receipt) => assert_eq!(receipt.resulting_revision, revision, "{}", step.name()), + SubmitReceipt::Applied(_) => panic!("the {} step was not already in the ledger", step.name()), + } + } + // The create is the command under the Begin's own key. + let create_replay = fixture + .state + .world + .create( + begin_intent(CommandId::parse_uuid(&key).unwrap(), typed_begin("Begun World", "A premise.", "The Owner")), + &principal, + ) + .await + .unwrap(); + assert_eq!(create_replay.commit_digest, begun["receipt"]["commitDigest"].as_str().unwrap()); + } + + /// The commands a Begin submits after its create have ids that are a + /// function of the Begin's whole key and the step's name: two keys that + /// differ only late in the string, or one key's two steps, never share an + /// id. + /// + /// Mutations: hash only a prefix of the key; hash no step name. + #[test] + fn begin_step_ids_depend_on_the_whole_key_and_the_step() { + let key = "123e4567-e89b-12d3-a456-426614174000"; + let late = "123e4567-e89b-12d3-a456-426614174001"; + assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(late, BeginStep::Approve).unwrap()); + assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(key, BeginStep::Activate).unwrap()); + assert_eq!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(key, BeginStep::Approve).unwrap()); + assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), CommandId::parse_uuid(key).unwrap()); + } + + /// A refusal at a step names that step, and an undecided outcome is never + /// read as a refusal: the owner must not be told to retry a Begin that may + /// have committed. + /// + /// Mutation: map every error, the unknown outcome included, to + /// `BeginStopped`. + #[test] + fn a_stopped_step_names_itself_and_an_unknown_outcome_stays_unknown() { + match stopped_at("approve", RuntimeCommandError::OutcomeUnknown("cmd".into())) { + RuntimeCommandError::OutcomeUnknown(detail) => assert_eq!(detail, "approve: cmd"), + other => panic!("an unknown outcome was read as {other}"), + } + let refused = stopped_at("activate", RuntimeCommandError::Payload("no".into())).to_string(); + assert!(refused.starts_with("the begin stopped at its activate step"), "{refused}"); + } + + /// A replayed Begin (the same key) replays its commands through the + /// kernel's ledger and commits nothing new. + #[tokio::test] + async fn a_replayed_begin_commits_nothing_new() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Replayed World", "", "The Owner"); + let first = post(&fixture.state, &fixture.cookie, begin_invocation(payload.clone(), &key)).await; + assert_eq!(first["receipt"]["state"], "accepted", "{first}"); + let second = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; + assert_eq!(second["receipt"]["state"], "accepted", "{second}"); + assert_eq!(second["receipt"]["reached"], "activated"); + assert_eq!(first["receipt"]["commitDigest"], second["receipt"]["commitDigest"]); + assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().revision, 2); + } + + /// R3: the sequence stops at the first refusal, and the world stays in the + /// phase the kernel committed. Here the kernel refuses the approve (the + /// draft was already approved by hand), so the Begin is denied naming that + /// step, the world stays an approved Draft, and the card offers the next + /// step's own button, which continues to an Active world. + /// + /// Mutations: carry on to the activate after a refused approve; report a + /// refused step as accepted. + #[tokio::test] + async fn begin_stops_at_the_first_refusal_and_the_card_offers_the_next_step() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Stopped World", "", "The Owner"); + let principal = owner_principal(&fixture).await; + let draft = fixture + .state + .world + .create( + begin_intent(CommandId::parse_uuid(&key).unwrap(), typed_begin("Stopped World", "", "The Owner")), + &principal, + ) + .await + .unwrap(); + fixture + .state + .world + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: draft.world_id, + expected_revision: 0, + body: CommandBody::ApproveDraft, + }, + &principal, + ) + .await + .unwrap(); + + let refused = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; + assert_eq!(refused["receipt"]["state"], "denied", "{refused}"); + let message = refused["receipt"]["message"].as_str().unwrap_or_default(); + assert!( + message.starts_with("the begin stopped at its approve step"), + "the refusal did not name its step: {message}" + ); + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.phase, WorldPhase::Draft, "a refused step must leave the draft as the kernel committed it"); + assert_eq!(world.revision, 1); + + let mut buttons = Vec::new(); + surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); + assert!(buttons.contains(&"world.activate".to_owned()), "{buttons:?}"); + assert!(!buttons.contains(&"world.begin".to_owned()), "a world exists; Begin must not be offered"); + + let activated = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.activate", + "ghostlight.world_activate.v0", + world.revision + 1, + json!({}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(activated["receipt"]["state"], "accepted", "{activated}"); + assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().phase, WorldPhase::Active); + } + + /// A Draft the kernel holds unapproved (a refused Begin can leave one) + /// offers Approve, then Activate, and no Begin. + #[tokio::test] + async fn an_unapproved_draft_offers_approve_and_no_begin() { + let fixture = fixture().await; + draft_world(&fixture.state, &fixture.cookie, "Plain Draft").await; + let mut buttons = Vec::new(); + surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); + assert!(buttons.contains(&"world.approve".to_owned()), "{buttons:?}"); + assert!(!buttons.contains(&"world.begin".to_owned()), "{buttons:?}"); + } + + /// Each control the player types into feeds the field it is named for: + /// the world that results carries the typed title, brief and name. + /// + /// Mutations: bind the "Your name" control to the title field; bind the + /// title control to the name field; hand the world's first person a label + /// other than the typed one. + #[tokio::test] + async fn the_text_typed_into_each_control_reaches_its_own_field() { + let fixture = fixture().await; + let payload = begin_payload_typed_into_controls( + &served_surface(&fixture).await, + &[ + ("world.begin.title", "Typed Title"), + ("world.begin.subject", "Typed Name"), + ("world.begin.brief", "Typed Brief"), + ], + ); + let begun = post( + &fixture.state, + &fixture.cookie, + begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.title, "Typed Title"); + assert_eq!(world.brief, "Typed Brief"); + assert_eq!(world.subjects.len(), 1); + assert_eq!(world.subjects[0].label, "Typed Name"); + } + + /// No refusal of a Begin echoes anything the owner typed or sent, and each + /// names the step it stopped at or the route's own fixed line. A blank + /// name or title is refused whatever whitespace it is made of, and no + /// refused Begin leaves a world. + /// + /// Mutations: put the payload in the unreadable-payload line; stop naming + /// the create step; keep a blank name by padding it with a letter. + #[tokio::test] + async fn a_refused_begin_never_echoes_what_was_sent_and_leaves_no_world() { + let fixture = fixture().await; + let canary = "CANARY-7f3a91-ZZ"; + let surface = served_surface(&fixture).await; + + let mut unreadable = begin_payload_from(&surface, "A World", canary, canary); + unreadable[canary] = json!(canary); + let mut missing_name = begin_payload_from(&surface, canary, canary, canary); + missing_name["bindings"].as_object_mut().unwrap().remove("subject_label"); + let mut authored_default = begin_payload_from(&surface, "A World", canary, canary); + authored_default["targets"] = json!(canary); + + for (what, payload, create_refusal) in [ + ("empty title", begin_payload_from(&surface, "", canary, canary), true), + ("blank title", begin_payload_from(&surface, " ", canary, canary), true), + ("empty name", begin_payload_from(&surface, canary, canary, ""), true), + ("blank name", begin_payload_from(&surface, canary, canary, " "), true), + ("tab name", begin_payload_from(&surface, canary, canary, "\t\n"), true), + ("unreadable payload", unreadable, false), + ("missing name", missing_name, false), + ("an authored default", authored_default, false), + ] { + let refused = post( + &fixture.state, + &fixture.cookie, + begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(refused["receipt"]["state"], "denied", "{what}: {refused}"); + assert!( + !refused.to_string().contains(canary), + "{what}: the refusal echoed an input value: {refused}" + ); + if create_refusal { + let message = refused["receipt"]["message"].as_str().unwrap_or_default(); + assert!( + message.starts_with("the begin stopped at its create step"), + "{what}: the refusal did not name the create step: {message}" + ); + } + assert!( + current_world(&fixture.state).await.unwrap().is_none(), + "{what}: a refused Begin left a world" + ); + } + } + + /// Begin is the one way a world is made: `world.create` is not an + /// operation this server advertises, so naming it, under either payload + /// schema, is refused before any handler runs and makes no world. + /// + /// Mutation: advertise `world.create` again in `operation_schema`. + #[tokio::test] + async fn world_create_is_not_a_route() { + let fixture = fixture().await; + for schema in ["ghostlight.world_create.v4", "ghostlight.world_begin.v1"] { + let payload = json!({"title":"Bypass","brief":"","subject_label":"Operator"}); + let refused = post( + &fixture.state, + &fixture.cookie, + invocation("world.create", schema, 0, payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(refused["receipt"]["state"], "denied", "{schema}: {refused}"); + assert!( + refused["receipt"]["message"].as_str().unwrap_or_default().contains("not advertised"), + "{schema}: {refused}" + ); + assert!(current_world(&fixture.state).await.unwrap().is_none()); + } + let served = served_surface(&fixture).await; + assert!( + served["commands"].as_array().unwrap().iter().all(|descriptor| descriptor["command"] != "world.create"), + "the surface advertises world.create" + ); + } + + /// The route owns the defaults. Another client's Begin states the three + /// fields and nothing else: it cannot author scale targets, roots, labels + /// or lens weights (each is refused as an unknown field, and no world is + /// made), and the world a three-field Begin makes has the route's + /// defaults: one person, uniform lens weights, no scale deficit. + /// + /// Mutations: drop `deny_unknown_fields` from `BeginPayload`; give one + /// lens weight 0 or a weight other than 1 in `uniform_lens_weights`; + /// declare a persona in `begin_intent`. + #[tokio::test] + async fn begin_owns_the_defaults_and_refuses_a_client_that_authors_them() { + let fixture = fixture().await; + let surface = served_surface(&fixture).await; + for (field, value) in [ + ("targets", json!("{}")), + ("jurisdictions", json!("[]")), + ("lens_weights", json!("{}")), + ("narrative_persona_label", json!("Persona")), + ("operational_agent_label", json!("Agent")), + ] { + let mut payload = begin_payload_from(&surface, "Authored", "", "Operator"); + payload[field] = value; + let refused = post( + &fixture.state, + &fixture.cookie, + begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(refused["receipt"]["state"], "denied", "{field}: {refused}"); + assert!(current_world(&fixture.state).await.unwrap().is_none(), "{field}: made a world"); + } + + let begun = post( + &fixture.state, + &fixture.cookie, + begin_invocation( + begin_payload_from(&surface, "Defaults", "", "Operator"), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.subjects.len(), 1, "a default declared a persona or an agent"); + assert!(world.scale_deficit.is_empty(), "a default declared a scale target"); + assert_eq!(world.lens_weights.iter().count(), ghostlight::Lens::ALL.len()); + assert!(world.lens_weights.iter().all(|(_, weight)| weight == 1), "the lens weights are not uniform"); + } + + /// Where a seed vault is configured, Begin stops at the approved Draft: + /// seeding is the Draft-only lane, so the owner then finds the seed + /// controls and Activate on the card, and no Begin. Without a vault the + /// same click reaches an Active world. + /// + /// Mutations: ignore the vault in `begin_plan`; leave the approve out of + /// the vault plan. + #[tokio::test] + async fn begin_stops_at_the_approved_draft_where_a_vault_is_configured() { + let fixture = fixture().await; + let mut configured = fixture.state.clone(); + configured.seed_vault_root = Some(PathBuf::from("/vault-for-begin")); + let payload = begin_payload_from(&served_surface(&fixture).await, "Seeded World", "", "The Owner"); + let begun = post(&configured, &fixture.cookie, begin_invocation(payload, &uuid::Uuid::new_v4().to_string())).await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + assert_eq!(begun["receipt"]["reached"], "approved"); + + let world = current_world(&configured).await.unwrap().unwrap(); + assert_eq!(world.phase, WorldPhase::Draft); + assert_eq!(world.draft_approvals, world.required_approvers, "the Draft is approved"); + + let served = get(&configured, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + let mut buttons = Vec::new(); + surface_buttons(&served["surface"]["root"], &mut buttons); + for wanted in ["world.seed", "world.activate"] { + assert!(buttons.contains(&wanted.to_owned()), "{wanted} missing: {buttons:?}"); + } + assert!(!buttons.contains(&"world.begin".to_owned()), "{buttons:?}"); + assert_eq!(begin_plan(true), [BeginStep::Approve]); + assert_eq!(begin_plan(false), [BeginStep::Approve, BeginStep::Activate]); + } + #[tokio::test] async fn exact_stale_retry_returns_journal_receipt_without_app_cache() { let fixture = fixture().await; let id = uuid::Uuid::new_v4().to_string(); - let command = invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Retry World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &id, - ); + let payload = begin_payload_from(&served_surface(&fixture).await, "Retry World", "", "Operator"); + let command = begin_invocation(payload, &id); let first = post(&fixture.state, &fixture.cookie, command.clone()).await; let second = post(&fixture.state, &fixture.cookie, command).await; assert_eq!( @@ -5675,7 +5861,7 @@ mod tests { .unwrap() .unwrap() .revision, - 0 + 2 ); } @@ -5689,47 +5875,11 @@ mod tests { async fn a_principal_command_resubmitted_under_its_own_key_returns_applied_then_already_applied() { let fixture = fixture().await; + let payload = begin_payload_from(&served_surface(&fixture).await, "Retry Receipt World", "", "Operator"); post( &fixture.state, &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Retry Receipt World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - post( - &fixture.state, - &fixture.cookie, - invocation( - "world.activate", - "ghostlight.world_activate.v0", - 2, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), + begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), ) .await; let id = uuid::Uuid::new_v4().to_string(); @@ -5819,7 +5969,36 @@ mod tests { ); } - /// A Draft world from `world.create`'s own intent: the owner's Human + /// A Draft world made through the kernel's own mailbox, as `begin_world` + /// makes one before it approves: tests that need a Draft (or the walk + /// from one) start here, since `world.begin` is the only route that + /// creates a world and it does not stop at a Draft. + async fn draft_world(state: &AppState, cookie: &str, title: &str) -> CreationReceipt { + let principal = state + .sessions + .lock() + .await + .account_for_cookie(cookie, Utc::now()) + .unwrap() + .expect("the fixture cookie names a live session"); + state + .world + .create( + begin_intent( + CommandId::new(), + BeginPayload { + title: title.into(), + brief: String::new(), + subject_label: "Operator".into(), + }, + ), + &principal, + ) + .await + .unwrap() + } + + /// A Draft world from the kernel's own create intent: the owner's Human /// subject beside a narrative persona and an operational agent, with the /// given scale intent and jurisdictions, so a test can look at the Draft /// world the seed lane actually runs against. @@ -6020,124 +6199,6 @@ mod tests { // ---- The seed command ------------------------------------------------ - /// Spec test 1. The previous create schema is not kept alive beside - /// `world_create.v4`: a complete payload announcing it dies at validation before any handler runs, - /// and a payload that announces v4 but omits the lens weights or the brief - /// is a payload error rather than a defaulted intent. None creates a world. - #[tokio::test] - async fn a_stale_create_payload_is_refused() { - let fixture = fixture().await; - let stale = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v3", - 0, - json!({"title":"Stale World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[]}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(stale["receipt"]["state"], "denied"); - assert!(current_world(&fixture.state).await.unwrap().is_none()); - - let unweighted = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({"title":"Unweighted World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[]}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(unweighted["receipt"]["state"], "denied"); - // Refused as a payload, before genesis: a defaulted empty set would - // also be denied, by the resolver, and must not pass for this. - let message = unweighted["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("missing field `lens_weights`"), - "the omission was not refused at the payload: {message}" - ); - assert!( - current_world(&fixture.state).await.unwrap().is_none(), - "a v4 payload with no lens weights created a world anyway" - ); - - let partial = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({"title":"Half World","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(partial["receipt"]["state"], "denied"); - assert!( - current_world(&fixture.state).await.unwrap().is_none(), - "a v4 payload with no brief created a world anyway" - ); - } - - /// A lens name the library does not know, beside one it does, is refused - /// by deserialization before any handler runs. No world is created. - #[tokio::test] - async fn a_create_payload_naming_an_unknown_lens_is_refused() { - let fixture = fixture().await; - let result = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({"title":"Tribunal World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":1,"tribunal":1}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(result["receipt"]["state"], "denied"); - let message = result["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("tribunal"), - "the unknown lens was not refused by name: {message}" - ); - assert!(current_world(&fixture.state).await.unwrap().is_none()); - } - - /// Every lens named, every weight zero: the payload decodes, and the - /// library's genesis refuses it with `LensWeightsNeverDraw`. No world. - #[tokio::test] - async fn a_create_payload_whose_lenses_never_draw_is_refused() { - let fixture = fixture().await; - let result = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({"title":"Still World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":0,"charter":0,"ledger":0,"hearth":0,"tangle":0,"veil":0,"ember":0,"numen":0}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(result["receipt"]["state"], "denied"); - let message = result["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("LensWeightsNeverDraw"), - "the refusal did not name the draw rule: {message}" - ); - assert!(current_world(&fixture.state).await.unwrap().is_none()); - } - /// Every `control.button` in a surface, at whatever depth. fn surface_buttons(node: &Value, into: &mut Vec) { if node["kind"] == "control.button" @@ -6536,16 +6597,14 @@ mod tests { async fn every_bound_command_admits_the_payload_an_untouched_form_submits() { let fixture = fixture().await; let mut exercised = Vec::new(); - let steps: [(&str, Vec<(&str, Value)>); 5] = [ + let steps: [(&str, Vec<(&str, Value)>); 3] = [ ( - "world.create", + "world.begin", vec![ ("title", json!("Untouched Form World")), ("subject_label", json!("The Operator")), ], ), - ("world.approve", vec![]), - ("world.activate", vec![]), ("world.advance_time", vec![("minutes", json!(1))]), ("world.play", vec![]), ]; diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 0df4607..bfd4cee 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -624,9 +624,11 @@ lane still uses. The real-model gate that exists today is the Idunn recipe's runs `controllers::tests::real_local_model_cognition_modes_commit_speech` against a real model; exercising a whole play turn against one is not gated by this contract. -`world.create`'s current payload is `world_create.v4`: title, brief, targets, -jurisdiction roots and lens weights are required, and Dungeon's create surface -offers uniform weights as its editable default. `world.seed` runs one +`world.begin` is the one route that makes a world. Its payload is +`world_begin.v1`: title, brief and subject_label, nothing else; the route +decides everything else a world is born with (no targets, no roots, no persona +or agent label, uniform lens weights) and `begin_plan` decides how far Begin +goes. `world.seed` runs one `SeedRunner` session per invocation against a `VaultEvidenceSource`. Focused tests must prove: diff --git a/docs/architecture/ghostlight-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md index a296f15..3b84e29 100644 --- a/docs/architecture/ghostlight-eve-native-interface.md +++ b/docs/architecture/ghostlight-eve-native-interface.md @@ -48,7 +48,7 @@ and world phase (`anonymous_surface`, `authenticated_surface` in |---|---|---| | Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` | | Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) | -| Authenticated, no world | the create form: title, brief, your name, optional Narrative persona and Operational agent labels, scale target, jurisdiction roots, lens weights | `world.create` (`ghostlight.world_create.v4`) | +| Authenticated, no world | title, your name, optional brief, and one Begin button | `world.begin` (`ghostlight.world_begin.v1`: title, brief, subject_label): create, then approve and activate as separate kernel commands, stopping at the first refusal; where a seed vault is configured it stops at the approved Draft so the owner can seed before Activate | | Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) | | Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) |