|
|
|
|
@@ -88,6 +88,25 @@ 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))
|
|
|
|
|
// 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))
|
|
|
|
|
@@ -381,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
|
|
|
|
|
};
|
|
|
|
|
@@ -643,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>}.
|
|
|
|
|
@@ -719,6 +780,96 @@ 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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.
|
|
|
|
|
@@ -1260,6 +1411,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]
|
|
|
|
|
@@ -1290,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() {
|
|
|
|
|
|