Merge pull request 'feat(sidecar): protocol 10 — five reward forwards (phase 13b)' (#10) from feat/phase-13b-rewards into edge

Reviewed-on: #10
This commit is contained in:
2026-09-24 13:01:37 +00:00
2 changed files with 80 additions and 3 deletions

View File

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

View File

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