diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 3dd927a..6572502 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -153,10 +153,18 @@ use tracing_subscriber::EnvFilter; /// passes through untouched like the rest of that body. The tally, the kit catalogue and the chat /// memory all live in the plugin. /// +/// # Protocol 11 — the map +/// +/// `GET /map`, `GET /map/chunk`, `POST /map/render` and `GET /map/live`: the picture of this +/// wipe's map, in slices, and everything that moves on it (the module's PLAN.md §30). Four more +/// thin forwards. **Nothing here is stored**: the picture passes through to the website, which +/// keeps it, and positions are asked for while somebody is looking and never touch the database +/// (D111). Which layer a viewer may see is decided on the website, never here. +/// /// `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, §15 the world verbs, -/// §16 the rewards; this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 10; +/// §16 the rewards, §17 the map; this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 11; 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 acd8c9d..12b027c 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -128,6 +128,13 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/tally/close", post(tally_close)) .route("/kits", get(kits)) .route("/chat", post(chat)) + // Protocol 11 (§30): the map. The picture passes through in slices and is never kept + // here, and positions are asked for while somebody is looking and never filed (D111). The + // plugin decides what exists and what is stale; this moves lines, like everything above. + .route("/map", get(map_info)) + .route("/map/chunk", get(map_chunk)) + .route("/map/render", post(map_render)) + .route("/map/live", get(map_live)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -558,6 +565,55 @@ async fn chat(State(st): State, Json(body): Json) -> Response { forward_object(&st, body, "chat.say", "a chat line").await } +/// What this map is, where its picture comes from, and its monuments (protocol 11, stage one). +/// Live, and never cached here: a wipe changes the answer, and the module compares its key and +/// hash against what it holds to decide whether to fetch at all. +async fn map_info(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "map.info", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// What `GET /map/chunk` reads: which picture, and which slice of it. +#[derive(Debug, Deserialize)] +struct MapChunkQuery { + #[serde(rename = "mapKey")] + map_key: String, + sha256: String, + n: u32, +} + +/// One slice of the picture, base64 (stage two). The plugin refuses `stale` when the key or hash +/// has moved since the caller asked `/map`, so a fetch that straddles a wipe cannot splice two +/// maps — which is also why the two are forwarded as the caller sent them and never filled in. +async fn map_chunk(State(st): State, Query(q): Query) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ + "cmd": "map.fetch", + "reqId": req_id, + "mapKey": q.map_key, + "sha256": q.sha256, + "chunk": q.n, + }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Ask the game to draw its own map when there is no cached picture (D109). **Answered at once +/// and done on a later frame**: the render stalls the game for seconds, longer than this +/// sidecar's reply budget on any large map, so the module polls `/map` for the result instead of +/// waiting here. +async fn map_render(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "map.render", "a render").await +} + +/// Everything that moves on the map, every layer at once. Which layers a viewer may see is the +/// website's decision (§8.5), so nothing here filters, and nothing here keeps the answer. +async fn map_live(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "map.live", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).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 { @@ -1041,6 +1097,31 @@ mod tests { assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); } + /// Protocol 11's one opaque forward is stamped like the rest: a render cannot become a world + /// placement by naming one. + #[test] + fn a_render_cannot_choose_its_own_command() { + let body = json!({ "cmd": "world.place", "reqId": "theirs", "actor": "admin" }); + let stamped = stamp(body, "map.render", "r-5").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "map.render"); + assert_eq!(stamped["reqId"], "r-5"); + assert_eq!(stamped["actor"], "admin"); + } + + /// A map slice must fit the game link's line cap on the way BACK, or the plugin's reply is + /// discarded by this process's reader and the caller sees a `504` for a picture that exists. + /// The plugin slices at 512 KiB; base64 grows that by a third, and the frame around it is + /// small. + #[test] + fn a_map_slice_fits_the_game_links_line_cap() { + let slice: usize = 512 * 1024; + let base64 = slice.div_ceil(3) * 4; + let envelope = 1024; + + assert!(base64 + envelope <= crate::game::MAX_INBOUND_LINE_BYTES); + } + /// 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() {