diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index baf49f2..3dd927a 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -145,10 +145,18 @@ use tracing_subscriber::EnvFilter; /// (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. /// +/// # Protocol 10 — the rewards +/// +/// `POST /tally/open`, `GET /tally/snapshot`, `POST /tally/close`, `GET /kits` and `POST /chat`: +/// who took part in a run, the kits a reward can name, and one line in the server's chat (the +/// module's PLAN.md §29). Five more thin forwards. `perm.sync` also gains a `credits` field, which +/// passes through untouched like the rest of that body. The tally, the kit catalogue and the chat +/// memory 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, §15 the world verbs; -/// this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 9; +/// 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; 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 477190c..acd8c9d 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -120,6 +120,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/world/zone", post(world_zone)) .route("/world/place", post(world_place)) .route("/world/revert", post(world_revert)) + // Protocol 10 (§29): who took part, what they may redeem, and a line in chat. The tally + // is counted and held by the plugin, the kit catalogue is Kits' own, and whether a chat + // line was already said is the plugin's memory. These move lines, like everything above. + .route("/tally/open", post(tally_open)) + .route("/tally/snapshot", get(tally_snapshot)) + .route("/tally/close", post(tally_close)) + .route("/kits", get(kits)) + .route("/chat", post(chat)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -508,6 +516,48 @@ async fn world_revert(State(st): State, Json(body): Json) -> Re forward_object(&st, body, "world.revert", "a revert").await } +/// Start counting who takes part in a run. `tally.ok` or `tally.error` with a reason. +async fn tally_open(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "tally.open", "a tally").await +} + +/// What `GET /tally/snapshot` reads: one run's tally. +#[derive(Debug, Deserialize)] +struct TallySnapshotQuery { + #[serde(rename = "runId")] + run_id: String, +} + +/// Who has taken part in a run so far, with the plugin's score for each. Live and never cached: +/// a reward is granted from this answer, and a stale one would leave out whoever joined last. +async fn tally_snapshot( + State(st): State, + Query(q): Query, +) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "tally.snapshot", "reqId": req_id, "runId": q.run_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Stop counting and forget the tally. **A tally already gone is a `200`**, like a world revert. +async fn tally_close(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "tally.close", "a tally close").await +} + +/// The kits this server's Kits plugin has, with what each is gated by. Live, for the authoring +/// form, and refused by the plugin with a reason when Kits is not loaded. +async fn kits(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "kits.list", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Say one line in the server's chat. A repeated key is answered `ok` by the plugin and not said +/// again, so a retried step never repeats itself. +async fn chat(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "chat.say", "a chat line").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 { @@ -972,6 +1022,25 @@ mod tests { assert_eq!(stamped["prefab"], "crate.elite"); } + /// Protocol 10's forwards are stamped the same way: a chat line cannot become a permission + /// sync by naming one. + #[test] + fn a_protocol_10_command_cannot_choose_its_own_command() { + let body = json!({ "cmd": "perm.sync", "reqId": "theirs", "key": "k-1", "message": "hi" }); + let stamped = stamp(body, "chat.say", "r-2").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "chat.say"); + assert_eq!(stamped["reqId"], "r-2"); + assert_eq!(stamped["message"], "hi"); + + let body = json!({ "cmd": "tally.close", "runId": "9", "score": "both" }); + let stamped = stamp(body, "tally.open", "r-3").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "tally.open"); + assert_eq!(stamped["score"], "both"); + assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); + } + /// 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() {