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() {