Merge ghostlight-verse/create-flow into main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f90d408466
5 changed files with 814 additions and 801 deletions
|
|
@ -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::<Vec<_>>(),
|
||||
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<String> = 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue