From a1a6177921eed7ddbb324dbaaf9916657aa6d6ee Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 00:42:54 -0500 Subject: [PATCH] =?UTF-8?q?feat(sidecar):=20protocol=209=20=E2=80=94=20fiv?= =?UTF-8?q?e=20world=20forwards=20(phase=2013a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /world/monuments, GET /world/owned, POST /world/zone, POST /world/place and POST /world/revert, each a correlated round trip with cmd and reqId stamped over the caller's. The allowlist, bounds, monument vocabulary and the registry of what each run owns live in the plugin (the module's PLAN.md §28); this process moves lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 13 ++++++--- sidecar/src/web.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 813a9bc..baf49f2 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -138,10 +138,17 @@ use tracing_subscriber::EnvFilter; /// website. This process moves lines between them and learns nothing about either, which is why /// the whole protocol is three thin forwards here. /// +/// # Protocol 9 — the world verbs +/// +/// `GET /world/monuments`, `GET /world/owned`, `POST /world/zone`, `POST /world/place` and +/// `POST /world/revert`: what an event makes in the world — a zone, crates, NPCs — and gives back +/// (the module's PLAN.md §28). Five more thin forwards. The allowlist, the bounds, the monument +/// vocabulary and the registry of what each run owns all live in the plugin. +/// /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the -/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases; this constant is one -/// of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 8; +/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs; +/// this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 9; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index f29c9cf..477190c 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -112,6 +112,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // allowlist, the ceiling and the timer; the website holds the ledger. This moves lines. .route("/lease", get(lease_list).post(lease_apply)) .route("/lease/release", post(lease_release)) + // Protocol 9 (§28): what an event makes in the world and gives back. Five more thin + // forwards. The allowlist, the bounds, the monument vocabulary and the registry of what + // each run owns are all the plugin's; nothing here knows a crate from a zone. + .route("/world/monuments", get(world_monuments)) + .route("/world/owned", get(world_owned)) + .route("/world/zone", post(world_zone)) + .route("/world/place", post(world_place)) + .route("/world/revert", post(world_revert)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -453,6 +461,53 @@ async fn lease_release(State(st): State, Json(body): Json) -> R forward_object(&st, body, "lease.release", "a lease release").await } +/// This wipe's monuments and the plugin's placeable allowlist, for the authoring form. Live, like +/// every catalogue here: a map changes at every wipe, and a cached list would offer monuments that +/// no longer exist. +async fn world_monuments(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "world.monuments", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// What `GET /world/owned` may narrow to: one run, or every run when absent. +#[derive(Debug, Deserialize)] +struct WorldOwnedQuery { + #[serde(rename = "runId")] + run_id: Option, +} + +/// What the world still holds of what events made. The plugin LOOKS for each thing — a restart +/// is not proof a crate is gone — so this is the website's reconcile answer, and it is never +/// cached. +async fn world_owned(State(st): State, Query(q): Query) -> Response { + let req_id = st.rpc.next_req_id(); + let mut command = json!({ "cmd": "world.owned", "reqId": req_id }); + + if let Some(run_id) = q.run_id { + command["runId"] = Value::String(run_id); + } + + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Open a zone a run owns. `world.ok` or `world.error` with a reason. +async fn world_zone(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "world.zone", "a zone").await +} + +/// Place crates or NPCs a run owns. A repeated idempotency key is answered with the first call's +/// ids by the plugin; this process does not know one call from another. +async fn world_place(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "world.place", "a placement").await +} + +/// Give back what a run owns. **Something already gone is a `200`**, listed as `gone`, because +/// reverting a thing a player looted is a success, not a failure. +async fn world_revert(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "world.revert", "a revert").await +} + /// Put `cmd` and `reqId` on a caller's object, **after** it is taken, so they overwrite anything /// the caller put there. `None` when the body is not an object. fn stamp(body: Value, cmd: &str, req_id: &str) -> Option { @@ -906,6 +961,17 @@ mod tests { assert!(stamp(json!("decay.scale"), "lease.release", "r-1").is_none()); } + #[test] + fn a_world_command_cannot_choose_its_own_command() { + let body = json!({ "cmd": "world.revert", "reqId": "theirs", "runId": "7", "prefab": "crate.elite" }); + let stamped = stamp(body, "world.place", "r-1").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "world.place"); + assert_eq!(stamped["reqId"], "r-1"); + assert_eq!(stamped["runId"], "7"); + assert_eq!(stamped["prefab"], "crate.elite"); + } + /// The same envelope rule as `perm_sync`, on the route that writes to a filesystem. #[test] fn a_configuration_write_cannot_choose_its_own_command() {