chore(release): cut edge over to main — protocols 3–12, the egg, and the first release (rust phase 18, D145) #14

Merged
whitlocktech merged 22 commits from edge into main 2026-09-26 05:45:09 +00:00
2 changed files with 91 additions and 2 deletions
Showing only changes of commit 9e2a83d7c9 - Show all commits

View File

@@ -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 /// passes through untouched like the rest of that body. The tally, the kit catalogue and the chat
/// memory all live in the plugin. /// 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 /// `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, /// 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. /// §16 the rewards, §17 the map; this constant is one of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 10; pub const PROTOCOL_VERSION: u32 = 11;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) { let args = match cli::parse(std::env::args().skip(1)) {

View File

@@ -128,6 +128,13 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/tally/close", post(tally_close)) .route("/tally/close", post(tally_close))
.route("/kits", get(kits)) .route("/kits", get(kits))
.route("/chat", post(chat)) .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)); .route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new() let app = Router::new()
@@ -558,6 +565,55 @@ async fn chat(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "chat.say", "a chat line").await 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<AppState>) -> 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<AppState>, Query(q): Query<MapChunkQuery>) -> 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<AppState>, Json(body): Json<Value>) -> 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<AppState>) -> 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 /// 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. /// the caller put there. `None` when the body is not an object.
fn stamp(body: Value, cmd: &str, req_id: &str) -> Option<Value> { fn stamp(body: Value, cmd: &str, req_id: &str) -> Option<Value> {
@@ -1041,6 +1097,31 @@ mod tests {
assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); 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. /// The same envelope rule as `perm_sync`, on the route that writes to a filesystem.
#[test] #[test]
fn a_configuration_write_cannot_choose_its_own_command() { fn a_configuration_write_cannot_choose_its_own_command() {