Merge pull request 'feat(sidecar): protocol 9 — five world forwards (phase 13a)' (#9) from feat/phase-13a-world into edge

Reviewed-on: #9
This commit is contained in:
2026-09-24 10:09:13 +00:00
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() {