feat(web): the world verbs on the wire (protocol 7, Phase 12a)
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 9s
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 9s
`POST /world` places, `GET /world/:runId` says what a run still owns, and `POST /world/:runId/despawn` gives it back. One route family for five author-facing verbs, because each of them ends in "an object exists and this run owns it" -- the differences between a boss's multipliers, an oracle's lines and a gate's destination are fields on one command, not five commands. `PROTOCOL_VERSION` -> 7. The overlay's `overlay.toml` is bumped in the same window; 12b amends 7 in place rather than bumping again, so an overlay and a sidecar both declaring 7 are interchangeable only within one side of that merge -- tolerable for the same single reason 6 was, and no other: nothing is released from `edge`. `world.owned` is a GET, unlike `participation.snapshot`: it carries no idempotency key and the shard answers it in one pass. A run the shard has no rows for answers with an EMPTY hand rather than a 404, and the distinction is load-bearing for reconcile -- "owns nothing" and "never heard of it" are the same fact once the registry is the only record of ownership, and they stay the same fact across a restart, because the registry is written by the same world save as the objects it describes. Two tests pin what the world verbs depend on from `respond_event`, rather than trusting that its reason-sniffing keeps covering a kind it predates: a ceiling refusal is a 400 (permanent -- retrying "you asked for 80 and this shard places 30" gets the same answer forever), the event gate being off is still a 403, and an empty owned list is a 200. Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -88,6 +88,15 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
post(participation_snapshot),
|
||||
)
|
||||
.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.
|
||||
.route("/pages", get(pages_list))
|
||||
.route("/pages/:id/respond", post(page_respond))
|
||||
@@ -719,6 +728,51 @@ async fn participation_close(
|
||||
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 ----
|
||||
|
||||
/// 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
|
||||
/// bad request. The catalog is short and a typo in a step is the likely cause of both.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user