diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 147c780..3611768 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -97,6 +97,16 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/world", post(world_spawn)) .route("/world/:run_id", get(world_owned)) .route("/world/:run_id/despawn", post(world_despawn)) + // The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b). Neither owned nor + // borrowed: an item put into somebody's hands, and a world save. Both are `done is + // done`, which is why they are not in the world family -- there is nothing to give + // back and no ledger row core would come back for. + // + // `GET /items` is the shard's own grant allowlist, so the website's dropdown offers + // what this shard will actually build rather than what a module guessed. + .route("/items", get(item_catalog)) + .route("/items/grant", post(item_grant)) + .route("/world/save", post(world_save)) // 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)) @@ -390,9 +400,23 @@ fn respond_event(result: Result) -> (StatusCode, Json) { .unwrap_or("request rejected"); let code = if reason.contains("disabled") { StatusCode::FORBIDDEN - } else if reason.contains("no lease is offered") || reason.contains("not counting") + } else if reason.contains("no lease is offered") + || reason.contains("not counting") + // Phase 12b. A grant against a run this shard has never been told to count + // is the same shape as an unknown lease key: the caller named something that + // does not exist here, which is a 404 and never a retry. It is deliberately + // NOT the same as a run whose ledger is open and empty -- that is a 200 with + // `granted: 0`, because "nobody came" is a result rather than a mistake. + || reason.contains("no participation ledger") { StatusCode::NOT_FOUND + } else if reason.contains("saves at most every") { + // A save refused because one just happened is the shard's rate limit, and it + // is TRANSIENT in a way nothing else on this plane is: the same request will + // succeed once the interval passes. 429 says exactly that, and keeps it out of + // the module's permanent-status set so a phase boundary is retried rather than + // abandoned. + StatusCode::TOO_MANY_REQUESTS } else { StatusCode::BAD_REQUEST }; @@ -652,8 +676,36 @@ async fn event_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json /// overridden".** A lease whose deadline has already fired stays listed, with `expired: true`, /// until teardown collects its verdict -- otherwise a reconcile in that window would report it gone /// and the website would write off a correctly-working backstop as an orphaned resource. -async fn lease_list(State(st): State) -> impl IntoResponse { - event_call(&st, "lease.list", Value::Null).await +/// **`?key=` and `?target=` narrow it to one row, and a targeted key needs them** (protocol 7 part +/// b). `Spawner.MaxCount` is one capability over thousands of spawners, so it has no single +/// "current" and the catalog walk cannot fill one in -- while the website's `read()` needs exactly +/// one value for exactly one target before it applies anything. Naming both answers that. +/// +/// The frame also carries `holds`: every lease this shard is actually holding, whatever key or +/// target it is on. A catalog walk can enumerate the KEYS but never the holds on a targeted one -- +/// there is no list of spawners to walk -- so without it a reconcile after an outage would have no +/// way to ask "what are you still holding?". +async fn lease_list(State(st): State, Query(q): Query) -> impl IntoResponse { + let mut body = serde_json::Map::new(); + if let Some(key) = q.key { + body.insert("key".to_string(), json!(key)); + } + if let Some(target) = q.target { + body.insert("target".to_string(), json!(target)); + } + let arg = if body.is_empty() { + Value::Null + } else { + Value::Object(body) + }; + event_call(&st, "lease.list", arg).await +} + +/// Narrowing for `GET /lease`. Both optional: absent means the whole catalog, as before. +#[derive(Debug, Deserialize)] +struct LeaseQuery { + key: Option, + target: Option, } /// Body: {"key":"...","value":"...","holdMs":,"untilMs":,"runId":,"idempotencyKey":}. @@ -773,6 +825,51 @@ async fn world_despawn( event_call(&st, "world.despawn", Value::Object(obj)).await } +// ---- the one-shots (protocol 7 part b) ---- + +/// What this shard is willing to grant, and the bounds it will grant within. +/// +/// A read, so the website's option source offers what this shard will actually build. The module +/// holds the same list, which is two copies of a short allowlist on purpose and exactly how the +/// lease bounds are already carried: the module's copy is what makes a bad value a refusal on a +/// form, and this one is what is true when the website is wrong. +async fn item_catalog(State(st): State) -> impl IntoResponse { + event_call(&st, "item.catalog", Value::Null).await +} + +/// Body: {"runId":"...","item":"gold","amount":N,"hue":,"name":,"where":,"idempotencyKey":}. +/// +/// **The recipients are not in the body, and that is the design.** The shard already holds the +/// run's participation ledger (protocol 6 part b), keyed by the same character serials the +/// website's `member_key` holds, so the grant names a run and the shard resolves who was there. +/// Sending a list would mean the same list crossing the wire twice with a window in which the two +/// disagree -- and it would have needed a core surface handing a module core's own participants. +/// +/// A run with no ledger open is a 404, not an empty success: "nobody came" and "you never told me +/// to count" are different facts, and only the first is a result a run should record. +/// +/// **Retryable, and protocol 6 is why.** `EVENTS.md` §G called a grant un-retryable because a lost +/// acknowledgement and a grant that never applied looked the same -- exactly the argument that made +/// `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey` closes that: a repeat is +/// answered by the original reply, so a retried grant cannot be one winner receiving two. +async fn item_grant(State(st): State, Json(body): Json) -> impl IntoResponse { + event_call(&st, "item.grant", body).await +} + +/// Body: {"idempotencyKey":}. Starts a world save, useful as a phase boundary. +/// +/// The reply says the save was STARTED and nothing more. What actually happened rides +/// `world.save.before` / `world.save.after`, which have been on the event stream since protocol 2 -- +/// so this route asserts nothing it cannot know, and a caller that needs the completion watches the +/// stream it is already connected to. +/// +/// **A save too soon after the last one is refused, not queued**, and the shard counts ServUO's own +/// autosave as the last one. A save stops the world; a queued one would land at a moment nobody +/// chose, in the middle of whatever the next step is doing. +async fn world_save(State(st): State, Json(body): Json) -> impl IntoResponse { + event_call(&st, "world.save", body).await +} + // ---- help-page queue handlers ---- /// The open help-page queue, correlated on reqId. Returns a pages.list. @@ -1401,6 +1498,68 @@ mod tests { ); } + /// A run this shard was never told to count is a 404; a run that WAS counted and had no + /// attendees is a 200. Protocol 7 part b. + #[test] + fn an_uncounted_run_is_a_404_and_an_empty_one_is_not() { + assert_eq!( + respond_event(Ok(json!({ + "kind": "oneshot.error", + "reason": "run 42 has no participation ledger open on this shard" + }))) + .0, + StatusCode::NOT_FOUND + ); + // The distinction the 404 exists to preserve. "Nobody came" is a RESULT -- an event + // nobody attended still happened -- and answering it as a failure would have the module + // retry a grant against a ledger that will be just as empty next time. + assert_eq!( + respond_event(Ok(json!({ + "kind": "item.grant.ok", + "runId": "42", + "granted": 0, + "missed": [] + }))) + .0, + StatusCode::OK + ); + } + + /// The save rate limit is the one refusal on this plane that the same request will get past + /// by waiting, so it is a 429 rather than the 400 every other refusal is. + #[test] + fn a_save_refused_for_coming_too_soon_is_a_429() { + assert_eq!( + respond_event(Ok(json!({ + "kind": "oneshot.error", + "reason": "this shard saves at most every 300 seconds, and the last save was 12 seconds ago" + }))) + .0, + StatusCode::TOO_MANY_REQUESTS + ); + // And an ordinary refusal on the same plane is still a 400, so the 429 is not swallowing + // the class it sits beside: a grant this shard does not offer will never succeed, however + // long the caller waits. + assert_eq!( + respond_event(Ok(json!({ + "kind": "oneshot.error", + "reason": "this shard does not grant 'castle'" + }))) + .0, + StatusCode::BAD_REQUEST + ); + // The event gate being off stays a 403 on this plane too -- it is an operator's deliberate + // refusal, not a bad request. + assert_eq!( + respond_event(Ok(json!({ + "kind": "oneshot.error", + "reason": "the event plane is disabled on this shard (Bridge.EventsEnabled)" + }))) + .0, + StatusCode::FORBIDDEN + ); + } + /// A lease taken, a tally answered: an ordinary success carries straight through. #[test] fn event_successes_are_200s() {