diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 7c7ce7c..f772a10 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -91,7 +91,7 @@ use tracing_subscriber::EnvFilter; /// the dumb-forwarder property again. /// /// **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 // itself, on its own thread, once the service actually begins. The runtime is built by whichever diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index fa609a2..147c780 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -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, Json(body): Json) -> 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, Path(run_id): Path) -> 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, + Path(run_id): Path, + Json(body): Json, +) -> 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]