Merge pull request 'feat(web): the world verbs on the wire (protocol 7, Phase 12a)' (#38) from feature/events-p12a-world-verbs into edge

Reviewed-on: #38
This commit is contained in:
2026-09-07 06:58:22 +00:00
2 changed files with 112 additions and 1 deletions

View File

@@ -91,7 +91,7 @@ use tracing_subscriber::EnvFilter;
/// the dumb-forwarder property again. /// the dumb-forwarder property again.
/// ///
/// **No store migration.** Nothing gains a column; the new kind is persisted whole like every other. /// **No store migration.** Nothing gains a column; the new kind is persisted whole like every other.
pub const PROTOCOL_VERSION: u32 = 6; pub const PROTOCOL_VERSION: u32 = 7;
// Not `#[tokio::main]`: on Windows the SCM dispatcher takes over this thread and starts the runtime // Not `#[tokio::main]`: on Windows the SCM dispatcher takes over this thread and starts the runtime
// itself, on its own thread, once the service actually begins. The runtime is built by whichever // itself, on its own thread, once the service actually begins. The runtime is built by whichever

View File

@@ -88,6 +88,15 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
post(participation_snapshot), post(participation_snapshot),
) )
.route("/participation/:run_id/close", post(participation_close)) .route("/participation/:run_id/close", post(participation_close))
// The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a). Five things an event author
// can place -- creatures, an enhanced "boss", an oracle NPC, a temporary gate,
// decoration -- and ONE command family, because each of them ends in "an object exists
// and this run owns it". POST places, GET says what the run still owns, POST .../despawn
// gives it back. Ownership is held on the shard, so despawn cannot be pointed at a serial
// the run did not create.
.route("/world", post(world_spawn))
.route("/world/:run_id", get(world_owned))
.route("/world/:run_id/despawn", post(world_despawn))
// Help-page (support) queue: snapshot the open queue, respond to / close a page. // Help-page (support) queue: snapshot the open queue, respond to / close a page.
.route("/pages", get(pages_list)) .route("/pages", get(pages_list))
.route("/pages/:id/respond", post(page_respond)) .route("/pages/:id/respond", post(page_respond))
@@ -719,6 +728,51 @@ async fn participation_close(
event_call(&st, "participation.close", Value::Object(obj)).await event_call(&st, "participation.close", Value::Object(obj)).await
} }
/// Body: {"runId":"...","what":"creature|boss|npc|gate|decor","map":"...","x":N,"y":N,...}.
///
/// One route for five author-facing verbs. The `what` discriminator is a wire detail: the
/// differences between them -- a boss's multipliers, an oracle's lines, a gate's destination and
/// `holdMs` -- are fields on one command rather than five commands, so there is one ledger shape,
/// one teardown path and one reconcile instead of five near-identical ones in three repos.
///
/// The shard registers every serial it places against the run and PERSISTS that registry beside
/// the world save, which is what makes `world_despawn` below safe: a spawned creature survives a
/// restart, so an in-memory registry would leave the website holding serials the shard would not
/// vouch for.
async fn world_spawn(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
event_call(&st, "world.spawn", body).await
}
/// What the run still owns, and the answer the website's `reconcile()` is built on.
///
/// A GET, unlike `participation_snapshot`: it carries no idempotency key and the shard answers it
/// in one pass, pruning rows whose object the world has already lost as it walks. Anything not
/// listed is gone -- which is the shape core wants, because it takes a row out of its ledger only
/// on an explicit reply and this is that reply.
async fn world_owned(State(st): State<AppState>, Path(run_id): Path<String>) -> impl IntoResponse {
event_call(&st, "world.owned", json!({ "runId": run_id })).await
}
/// Body: {"serials":[...]} -- or no serials at all, which means everything the run owns and is the
/// call teardown actually makes.
///
/// Three answers, and the split is why the shard keeps a registry at all. `removed` was found and
/// deleted; `gone` was owned but already absent, which is what happens when a player kills an event
/// creature and is a SUCCESS; `refused` was never this run's to delete, and is the only answer here
/// that means somebody asked for something they should not have.
async fn world_despawn(
State(st): State<AppState>,
Path(run_id): Path<String>,
Json(body): Json<Value>,
) -> impl IntoResponse {
let mut obj = match body {
Value::Object(m) => m,
_ => serde_json::Map::new(),
};
obj.insert("runId".to_string(), json!(run_id));
event_call(&st, "world.despawn", Value::Object(obj)).await
}
// ---- help-page queue handlers ---- // ---- help-page queue handlers ----
/// The open help-page queue, correlated on reqId. Returns a pages.list. /// The open help-page queue, correlated on reqId. Returns a pages.list.
@@ -1260,6 +1314,63 @@ mod tests {
); );
} }
/// Protocol 7's world verbs go through the same responder, and this pins the two mappings
/// they depend on rather than trusting that the reason-sniffing above keeps covering a kind
/// it was written before.
///
/// A CEILING refusal is a 400 on purpose. It is permanent -- retrying "you asked for 80
/// creatures and this shard places 30" gets the same answer forever -- and it is the module's
/// `PERMANENT_STATUSES` that has to see it as such, so classifying it as anything retryable
/// would put a run in a loop against a limit that will never move.
#[test]
fn a_world_refusal_is_a_400_and_the_gate_is_still_a_403() {
assert_eq!(
respond_event(Ok(json!({
"kind": "world.error",
"action": "spawn",
"reason": "this shard places 1 to 30 of 'creature' at a time, and 80 was asked for"
})))
.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
respond_event(Ok(json!({
"kind": "world.error",
"action": "spawn",
"reason": "the event plane is disabled on this shard (Bridge.EventsEnabled)"
})))
.0,
StatusCode::FORBIDDEN
);
}
/// A run the shard has no registry rows for answers with an EMPTY hand, not a 404, and the
/// distinction is load-bearing for reconcile.
///
/// "This run owns nothing" and "I have never heard of this run" are the same fact once the
/// registry is the only record of ownership, and they stay the same fact across a restart:
/// the registry is written by `EventSink.WorldSave`, so it and the objects it describes are
/// saved and lost together. A 404 here would make the website treat a run that legitimately
/// owns nothing as a shard it could not reach.
#[test]
fn a_run_owning_nothing_is_an_empty_list_not_a_404() {
let (status, body) = respond_event(Ok(json!({
"kind": "world.owned.ok",
"runId": "77",
"owned": [],
"pruned": 0
})));
assert_eq!(status, StatusCode::OK);
assert_eq!(
body.0
.get("owned")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(0)
);
}
/// An unknown lease key and an unknown run are not-founds; anything else the shard refuses is a /// An unknown lease key and an unknown run are not-founds; anything else the shard refuses is a
/// bad request. The catalog is short and a typo in a step is the likely cause of both. /// bad request. The catalog is short and a typo in a step is the likely cause of both.
#[test] #[test]