feat(sidecar): protocol 9 — five world forwards (phase 13a)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m58s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-24 00:42:54 -05:00
parent 788bed89a0
commit a1a6177921
2 changed files with 76 additions and 3 deletions

View File

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

View File

@@ -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<AppState>, Json(body): Json<Value>) -> 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<AppState>) -> 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<String>,
}
/// 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<AppState>, Query(q): Query<WorldOwnedQuery>) -> 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<AppState>, Json(body): Json<Value>) -> 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<AppState>, Json(body): Json<Value>) -> 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<AppState>, Json(body): Json<Value>) -> 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<Value> {
@@ -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() {