feat(web): the borrowed planes and the one-shots on the wire (Phase 12b) #39
@@ -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<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
.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<AppState>) -> 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<AppState>, Query(q): Query<LeaseQuery>) -> 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<String>,
|
||||
target: Option<String>,
|
||||
}
|
||||
|
||||
/// Body: {"key":"...","value":"...","holdMs":<ms>,"untilMs":<opt>,"runId":<opt>,"idempotencyKey":<opt>}.
|
||||
@@ -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<AppState>) -> impl IntoResponse {
|
||||
event_call(&st, "item.catalog", Value::Null).await
|
||||
}
|
||||
|
||||
/// Body: {"runId":"...","item":"gold","amount":N,"hue":<opt>,"name":<opt>,"where":<opt>,"idempotencyKey":<opt>}.
|
||||
///
|
||||
/// **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<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
event_call(&st, "item.grant", body).await
|
||||
}
|
||||
|
||||
/// Body: {"idempotencyKey":<opt>}. 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<AppState>, Json(body): Json<Value>) -> 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() {
|
||||
|
||||
Reference in New Issue
Block a user