From ef4fdb6e2553cfd8afc94e7857759e4fd2701647 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 02:13:15 -0500 Subject: [PATCH 1/8] =?UTF-8?q?feat(sidecar):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20POST=20/kits/uses=20(PLAN=5FREDESIGNS=20=C2=A72.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens protocol 14 (D332) for the step editor: one thin forward, POST /kits/uses -> kits.uses ({kit, steamIds} in, counts out), for the kit weekend's warning. perm.sync's new notices (D333) pass through untouched like the rest of the body. PROTOCOL_VERSION 13 -> 14; one forward test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 14 +++++++++++--- sidecar/src/web.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 5f554e9..9059e01 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -175,11 +175,19 @@ use tracing_subscriber::EnvFilter; /// `POST /permissions/inventory`: the plugin's whole store, in pages it sizes to the game link's /// line cap. Forwarded like every other object; the pages are never kept here. /// +/// # Protocol 14 — the step editor (opened 2026-10-12) +/// +/// Opened by the module's PLAN_REDESIGNS.md §2 (D332), after protocol 13 shipped with RunicNPC. +/// One route, `POST /kits/uses`: how many of a list of players have used a kit up, or are waiting +/// out its cooldown — counts, never a list. And `perm.sync` gains `notices`, the one-time "you +/// earned a kit" messages (D333), with `noticesTold` in its report; both pass through untouched +/// like the rest of that body. The first-class optional plugins (§11) join this protocol too. +/// /// `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, -/// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes; this constant is one -/// of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 13; +/// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor; +/// this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 14; 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 bff518a..3338d73 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -130,6 +130,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/tally/snapshot", get(tally_snapshot)) .route("/tally/close", post(tally_close)) .route("/kits", get(kits)) + // Protocol 14 (the module's PLAN_REDESIGNS.md §2.6): how many of the given players have + // used a kit up or are on its cooldown, for the warning beside a kit weekend. The plugin + // reads Kits and answers counts; the list of players is the website's and is not kept. + .route("/kits/uses", post(kits_uses)) .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 @@ -579,6 +583,13 @@ async fn kits(State(st): State) -> Response { respond(st.rpc.call(&st.game, command, &req_id).await) } +/// How many of a list of players have used one kit up, and how many are on its cooldown (protocol +/// 14). The body is `{kit, steamIds}` and is forwarded as it is; the answer is counts. A POST +/// because the list rides in the body — it can be as long as a server's linked players. +async fn kits_uses(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "kits.uses", "a kit-use question").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 { @@ -1158,6 +1169,32 @@ mod tests { assert!(stamp(json!([0]), "perm.inventory", "r-8").is_none()); } + /// Protocol 14: a kit-use question keeps its list and cannot become another command, and a + /// sync's `notices` pass through it untouched like every other part of the body. + #[test] + fn a_protocol_14_kit_use_question_and_notices_pass_through() { + let body = json!({ "cmd": "perm.sync", "kit": "vip", "steamIds": ["76561190000000001", "76561190000000002"] }); + let stamped = stamp(body, "kits.uses", "r-12").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "kits.uses"); + assert_eq!(stamped["reqId"], "r-12"); + assert_eq!(stamped["kit"], "vip"); + assert_eq!(stamped["steamIds"][1], "76561190000000002"); + assert!(stamp(json!(["vip"]), "kits.uses", "r-13").is_none()); + + let body = json!({ + "setId": "s-2", + "notices": [{ "key": "run:4:step:9", "steamId": "76561190000000001", "message": "You earned kit vip — type /kit vip", "delivery": "popup" }], + }); + let stamped = stamp(body, "perm.sync", "r-14").expect("an object is stamped"); + + assert_eq!( + stamped["notices"][0]["message"], + "You earned kit vip — type /kit vip" + ); + assert_eq!(stamped["notices"][0]["delivery"], "popup"); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test] From e720c21c091afd5e92838deb17e07a475687fee3 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 06:53:51 -0500 Subject: [PATCH 2/8] =?UTF-8?q?feat(sidecar):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20the=20Economics=20forwards=20(PLAN=5FREDESIGNS=20=C2=A711.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /economics/pay, /economics/balances and /economics/adjust, thin forwards like /kits/uses: each body is stamped with its own command and passed to the plugin, which remembers a payment's key. Nothing is kept here. The tally's `balance` and the `economics.wiped` event ride the feed like any frame. Still protocol 14 (D332); one forward test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 5 +++++ sidecar/src/web.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 9059e01..39107cf 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -183,6 +183,11 @@ use tracing_subscriber::EnvFilter; /// earned a kit" messages (D333), with `noticesTold` in its report; both pass through untouched /// like the rest of that body. The first-class optional plugins (§11) join this protocol too. /// +/// Economics (§20.3, the module's PLAN_REDESIGNS.md §11.2) adds three forwards: +/// `POST /economics/pay` (coins from an event step, kept once paid), `POST /economics/balances` +/// and `POST /economics/adjust` (staff). The plugin remembers a payment's key; this process keeps +/// nothing. The tally's `balance` and the `economics.wiped` event ride the feed like any frame. +/// /// `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, /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 3338d73..51a76ec 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -134,6 +134,11 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // used a kit up or are on its cooldown, for the warning beside a kit weekend. The plugin // reads Kits and answers counts; the list of players is the website's and is not kept. .route("/kits/uses", post(kits_uses)) + // Protocol 14, Economics (§20.3): thin forwards. The plugin remembers a payment's key so a + // retry never pays twice; nothing about a payment or a balance is kept here. + .route("/economics/pay", post(economics_pay)) + .route("/economics/balances", post(economics_balances)) + .route("/economics/adjust", post(economics_adjust)) .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 @@ -590,6 +595,24 @@ async fn kits_uses(State(st): State, Json(body): Json) -> Respo forward_object(&st, body, "kits.uses", "a kit-use question").await } +/// Coins to a list of players from an event step (protocol 14). The body is `{key, amount, +/// steamIds, message?, delivery?}`; the answer is `economics.paid` with each player's balance +/// before and after, or `economics.error` with a reason. +async fn economics_pay(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "economics.pay", "a payment").await +} + +/// The balances of a list of players, with the server's starting balance (protocol 14). +async fn economics_balances(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "economics.balances", "a balance question").await +} + +/// Staff deposit, withdraw or set one player's balance (protocol 14). The body is `{key, steamId, +/// op, amount}`; the answer is `economics.adjusted` with the balance before and after. +async fn economics_adjust(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "economics.adjust", "a balance adjustment").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 { @@ -1195,6 +1218,36 @@ mod tests { assert_eq!(stamped["notices"][0]["delivery"], "popup"); } + /// Protocol 14, Economics: each body keeps its fields and is stamped with its own command, so + /// a payment cannot be sent as an adjustment by naming one in its body. + #[test] + fn protocol_14_economics_bodies_pass_through() { + let body = json!({ "cmd": "economics.adjust", "key": "7:4", "amount": 500.0, "steamIds": ["76561190000000001"], "message": "You earned {amount} coins", "delivery": "popup" }); + let stamped = stamp(body, "economics.pay", "r-31").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "economics.pay"); + assert_eq!(stamped["reqId"], "r-31"); + assert_eq!(stamped["key"], "7:4"); + assert_eq!(stamped["amount"], 500.0); + assert_eq!(stamped["message"], "You earned {amount} coins"); + + let stamped = stamp( + json!({ "steamIds": ["76561190000000001"] }), + "economics.balances", + "r-32", + ) + .expect("an object is stamped"); + assert_eq!(stamped["cmd"], "economics.balances"); + assert_eq!(stamped["steamIds"][0], "76561190000000001"); + + let body = json!({ "key": "adj-1", "steamId": "76561190000000001", "op": "withdraw", "amount": 340.5 }); + let stamped = stamp(body, "economics.adjust", "r-33").expect("an object is stamped"); + assert_eq!(stamped["cmd"], "economics.adjust"); + assert_eq!(stamped["op"], "withdraw"); + assert_eq!(stamped["amount"], 340.5); + assert!(stamp(json!([1]), "economics.pay", "r-34").is_none()); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test] From a95ecb8e25f1652b36eaa338beba8cc464bd2119 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 14:35:54 -0500 Subject: [PATCH 3/8] =?UTF-8?q?feat(sidecar):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20the=20Backpacks=20forward=20(PLAN=5FREDESIGNS=20=C2=A711.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /backpacks/read, a thin forward like /kits/uses: the body is stamped `backpacks.read` whatever it names, so the route can only ever read. Nothing is kept here. The bigger backpack an event lends is a permission in an ordinary perm.sync (D337) and needs nothing new. Still protocol 14 (D332); one test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 4 ++++ sidecar/src/web.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 39107cf..0b4ee34 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -188,6 +188,10 @@ use tracing_subscriber::EnvFilter; /// and `POST /economics/adjust` (staff). The plugin remembers a payment's key; this process keeps /// nothing. The tally's `balance` and the `economics.wiped` event ride the feed like any frame. /// +/// Backpacks (§20.4, the module's PLAN_REDESIGNS.md §11.3) adds one: `POST /backpacks/read`, +/// one player's backpack, read live. The plugin never writes a backpack, and a bigger one an event +/// lends is a `backpacks.size.N` permission in an ordinary `perm.sync`, so it needs nothing here. +/// /// `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, /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 51a76ec..ad0872b 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -139,6 +139,8 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/economics/pay", post(economics_pay)) .route("/economics/balances", post(economics_balances)) .route("/economics/adjust", post(economics_adjust)) + // Protocol 14, Backpacks (§20.4): one player's backpack, read live and never kept here. + .route("/backpacks/read", post(backpacks_read)) .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 @@ -613,6 +615,13 @@ async fn economics_adjust(State(st): State, Json(body): Json) - forward_object(&st, body, "economics.adjust", "a balance adjustment").await } +/// One player's backpack (protocol 14). The body is `{steamId}`; the answer is +/// `backpacks.contents` (`exists`, and with a backpack its `capacity`, `pageSize` and `items`), or +/// `backpacks.error` with a reason. The plugin only reads, and nothing is kept here. +async fn backpacks_read(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "backpacks.read", "a backpack read").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 { @@ -1248,6 +1257,19 @@ mod tests { assert!(stamp(json!([1]), "economics.pay", "r-34").is_none()); } + /// Protocol 14, Backpacks: a read is stamped `backpacks.read` whatever its body names, so the + /// route can only ever read. + #[test] + fn protocol_14_backpacks_read_is_stamped() { + let body = json!({ "cmd": "backpacks.write", "steamId": "76561198000000001" }); + let stamped = stamp(body, "backpacks.read", "r-40").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "backpacks.read"); + assert_eq!(stamped["reqId"], "r-40"); + assert_eq!(stamped["steamId"], "76561198000000001"); + assert!(stamp(json!("76561198000000001"), "backpacks.read", "r-41").is_none()); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test] From 4ad5cb0545193e7098049f332df35df73bee06de Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 00:39:00 -0500 Subject: [PATCH 4/8] =?UTF-8?q?feat(sidecar):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20the=20world=20event=20call=20forward=20(PLAN=5FREDESIGNS=20?= =?UTF-8?q?=C2=A714.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /world/call, a thin forward like /world/place: the body is stamped `world.call` whatever it names, so the route cannot place, revert or zone anything. What a call starts is kept, so nothing is remembered here; the plugin keeps the call's key. The `world.event` frames (24 types, told apart by `event`) ride the feed like any other event, and hello's `console` like any other field. Still protocol 14 (D332); one new test, 71 pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 6 ++++++ sidecar/src/web.rs | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 0b4ee34..b2850a2 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -192,6 +192,12 @@ use tracing_subscriber::EnvFilter; /// one player's backpack, read live. The plugin never writes a backpack, and a bigger one an event /// lends is a `backpacks.size.N` permission in an ordinary `perm.sync`, so it needs nothing here. /// +/// Rust's own world events (§20.5, the module's PLAN_REDESIGNS.md §14.3.1) add one forward, +/// `POST /world/call`: run one of the game's own world events now, for a run. It is kept, so the +/// plugin remembers only the call's key and its run, and this process nothing. The `world.event` +/// frames (24 of them, told apart by `event`) ride the feed like any other event, and hello's +/// `console` like any other field. +/// /// `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, /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index ad0872b..299d690 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -123,6 +123,8 @@ 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 14 (§20.5): one of the game's own world events, now. Kept, never reverted. + .route("/world/call", post(world_call)) // 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. @@ -554,6 +556,13 @@ async fn world_revert(State(st): State, Json(body): Json) -> Re forward_object(&st, body, "world.revert", "a revert").await } +/// Run one of the game's own world events for a run (protocol 14): a patrol helicopter, a cargo +/// ship, an airdrop and the rest. `world.called` (`called`, and `repeat` for a key already seen) +/// or `world.error` with a reason. What it starts is kept, so nothing is remembered here. +async fn world_call(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "world.call", "a world event call").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 @@ -1270,6 +1279,22 @@ mod tests { assert!(stamp(json!("76561198000000001"), "backpacks.read", "r-41").is_none()); } + /// Protocol 14, world events: a call is stamped `world.call` whatever its body names, so the + /// route cannot place, revert or zone anything, and its params pass through untouched. + #[test] + fn protocol_14_world_call_is_stamped() { + let body = json!({ "cmd": "world.revert", "reqId": "theirs", "runId": "9", "key": "9:2", + "event": "airdrop", "count": 3, "x": -412.0, "z": 1180.5 }); + let stamped = stamp(body, "world.call", "r-50").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "world.call"); + assert_eq!(stamped["reqId"], "r-50"); + assert_eq!(stamped["event"], "airdrop"); + assert_eq!(stamped["count"], 3); + assert_eq!(stamped["x"], -412.0); + assert!(stamp(json!("airdrop"), "world.call", "r-51").is_none()); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test] From 1ab0cab80ae31c5494a4f4f10e7fcd7683699b8b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 06:45:27 -0500 Subject: [PATCH 5/8] =?UTF-8?q?feat(sidecar):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20the=20Dangerous=20Treasures=20start=20forward=20(PLAN=5FREDE?= =?UTF-8?q?SIGNS=20=C2=A714.3.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /dt/start, stamped dt.start whatever its body names, forwarded to the plugin and answered with its dt.opened or dt.error. The plugin remembers the key; DT despawns its own chest; nothing is kept here. dt.started, dt.won and dt.ended ride the feed like any other event (docs rust-link/PROTOCOL.md §20.8). 72 tests (one new), fmt and clippy clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 5 +++++ sidecar/src/web.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index b2850a2..df08f77 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -198,6 +198,11 @@ use tracing_subscriber::EnvFilter; /// frames (24 of them, told apart by `event`) ride the feed like any other event, and hello's /// `console` like any other field. /// +/// Dangerous Treasures (§20.8, the module's PLAN_REDESIGNS.md §14.3.4) adds one forward, +/// `POST /dt/start`: open one treasure event for a run's step. The plugin remembers the key so a +/// retry never opens a second; DT despawns its own chest. `dt.started`, `dt.won` and `dt.ended` +/// ride the feed like any other event. +/// /// `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, /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 299d690..a2447c7 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -125,6 +125,9 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/world/revert", post(world_revert)) // Protocol 14 (§20.5): one of the game's own world events, now. Kept, never reverted. .route("/world/call", post(world_call)) + // Protocol 14 (§20.8): open one Dangerous Treasures event. The plugin remembers the key; + // DT picks the spot and despawns its own chest, so nothing here is kept either. + .route("/dt/start", post(dt_start)) // 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. @@ -563,6 +566,13 @@ async fn world_call(State(st): State, Json(body): Json) -> Resp forward_object(&st, body, "world.call", "a world event call").await } +/// Open one Dangerous Treasures event for a run's step (protocol 14). `dt.opened` (where it +/// opened, and `repeat` for a key already seen) or `dt.error` with a reason, `dt-not-ready` among +/// them. DT despawns its own chest, so nothing is remembered here. +async fn dt_start(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "dt.start", "a Dangerous Treasures start").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 @@ -1295,6 +1305,20 @@ mod tests { assert!(stamp(json!("airdrop"), "world.call", "r-51").is_none()); } + /// Protocol 14, Dangerous Treasures: a start is stamped `dt.start` whatever its body names, so + /// the route can open a treasure event and nothing else, and its key and level pass through. + #[test] + fn protocol_14_dt_start_is_stamped() { + let body = json!({ "cmd": "world.call", "reqId": "theirs", "key": "12:3", "level": 2 }); + let stamped = stamp(body, "dt.start", "r-60").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "dt.start"); + assert_eq!(stamped["reqId"], "r-60"); + assert_eq!(stamped["key"], "12:3"); + assert_eq!(stamped["level"], 2); + assert!(stamp(json!(2), "dt.start", "r-61").is_none()); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test] From f96878441da64300e24ff39a1943c0e08cbcbf61 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 12:26:47 -0500 Subject: [PATCH 6/8] =?UTF-8?q?feat(sidecar):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20the=20guarded=20crate=20placement=20forward=20(PLAN=5FREDESI?= =?UTF-8?q?GNS=20=C2=A714.3.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /gc/place stamps the body gc.place and forwards it; the plugin keeps the key and the crate is kept when the run ends, so nothing is kept here. Spec: docs rust-link/PROTOCOL.md §20.9. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 5 +++++ sidecar/src/web.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index df08f77..65efcf7 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -203,6 +203,11 @@ use tracing_subscriber::EnvFilter; /// retry never opens a second; DT despawns its own chest. `dt.started`, `dt.won` and `dt.ended` /// ride the feed like any other event. /// +/// Guarded Crate (§20.9, the module's PLAN_REDESIGNS.md §14.3.5) adds one forward, +/// `POST /gc/place`: place one guarded crate for a run's step. The plugin remembers the key so a +/// retry never places a second; the crate is kept when the run ends (D373). `gc.started`, +/// `gc.ended` and `gc.failed` ride the feed like any other event. +/// /// `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, /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index a2447c7..7305883 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -128,6 +128,9 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // Protocol 14 (§20.8): open one Dangerous Treasures event. The plugin remembers the key; // DT picks the spot and despawns its own chest, so nothing here is kept either. .route("/dt/start", post(dt_start)) + // Protocol 14 (§20.9): place one guarded crate. The plugin remembers the key; the crate is + // kept when the run ends (Guarded Crate cannot end one alone), so nothing is kept here. + .route("/gc/place", post(gc_place)) // 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. @@ -573,6 +576,13 @@ async fn dt_start(State(st): State, Json(body): Json) -> Respon forward_object(&st, body, "dt.start", "a Dangerous Treasures start").await } +/// Place one guarded crate for a run's step (protocol 14). `gc.placed` (where it landed, and +/// `repeat` for a key already seen) or `gc.error` with a reason, `npcspawn-missing` among them. +/// Guarded Crate ends its own event, so nothing is remembered here. +async fn gc_place(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "gc.place", "a guarded crate placement").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 @@ -1319,6 +1329,23 @@ mod tests { assert!(stamp(json!(2), "dt.start", "r-61").is_none()); } + /// Protocol 14, Guarded Crate: a placement is stamped `gc.place` whatever its body names, so + /// the route can place a guarded crate and nothing else, and its key, event and location pass + /// through. + #[test] + fn protocol_14_gc_place_is_stamped() { + let body = json!({ "cmd": "dt.start", "reqId": "theirs", "key": "12:4", "event": "Elite", "monument": "powerplant_1", "offsetX": 10.0 }); + let stamped = stamp(body, "gc.place", "r-62").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "gc.place"); + assert_eq!(stamped["reqId"], "r-62"); + assert_eq!(stamped["key"], "12:4"); + assert_eq!(stamped["event"], "Elite"); + assert_eq!(stamped["monument"], "powerplant_1"); + assert_eq!(stamped["offsetX"], 10.0); + assert!(stamp(json!("Elite"), "gc.place", "r-63").is_none()); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test] From 6bf8a3624e713ad6ac5ba50c5935d401aea68fcd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 02:32:02 -0500 Subject: [PATCH 7/8] =?UTF-8?q?feat(sidecar):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20the=20console=20command=20run=20forward=20(PLAN=5FREDESIGNS?= =?UTF-8?q?=20=C2=A716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /commands/run`, stamped `command.run` the way `world.call`, `dt.start` and `gc.place` are (PROTOCOL.md §20.11). The plugin keeps the key, fills {steamid} and {name} and decides who is online; the sidecar moves the line and knows nothing of what a command does (D384, no allowlist). Joins protocol 14, still unreleased on edge. Stamp test: the body's key, commands and recipients pass through byte for byte, placeholders and quotes included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 5 +++++ sidecar/src/web.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 65efcf7..668ac3e 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -208,6 +208,11 @@ use tracing_subscriber::EnvFilter; /// retry never places a second; the crate is kept when the run ends (D373). `gc.started`, /// `gc.ended` and `gc.failed` ride the feed like any other event. /// +/// Console commands as event steps (§20.11, the module's PLAN_REDESIGNS.md §16) add one forward, +/// `POST /commands/run`: the commands a step's author typed, run once or once per online +/// recipient. The plugin remembers the key so nothing runs twice; this process keeps nothing and +/// knows nothing of what a command does. +/// /// `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, /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 7305883..0d95b06 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -131,6 +131,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // Protocol 14 (§20.9): place one guarded crate. The plugin remembers the key; the crate is // kept when the run ends (Guarded Crate cannot end one alone), so nothing is kept here. .route("/gc/place", post(gc_place)) + // Protocol 14 (§20.11): console commands a step's author typed. The plugin keeps the key + // so nothing runs twice, fills the placeholders and decides who is online; this process + // moves the line and knows nothing of what any command does (no allowlist, D384). + .route("/commands/run", post(commands_run)) // 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. @@ -583,6 +587,16 @@ async fn gc_place(State(st): State, Json(body): Json) -> Respon forward_object(&st, body, "gc.place", "a guarded crate placement").await } +/// Run the console commands a step's author typed (protocol 14): once, or once per online +/// recipient. `command.ran` (how many ran, the console's replies, who was skipped as offline, and +/// `repeat` for a key already seen) or `command.error` with a reason, `unknown-command` among +/// them. A step's `teardown` rides its run and the plugin keeps it with the key; `teardownOf` +/// asks for the teardown kept under another key (D385). The plugin remembers the key, so nothing +/// is remembered here. +async fn commands_run(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "command.run", "a console command run").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 @@ -1346,6 +1360,43 @@ mod tests { assert!(stamp(json!("Elite"), "gc.place", "r-63").is_none()); } + /// Protocol 14, console commands: a run is stamped `command.run` whatever its body names, so + /// the route runs commands and nothing else, and its key, commands and recipients pass through + /// byte for byte; the placeholders are the plugin's to fill. + #[test] + fn protocol_14_command_run_is_stamped() { + let body = json!({ "cmd": "gc.place", "reqId": "theirs", "key": "12:5", + "commands": ["sr add {steamid} 100", "say \"{name} won\""], + "recipients": [{ "steamId": "76561198000000001", "name": "Some \"One\"" }] }); + let stamped = stamp(body, "command.run", "r-64").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "command.run"); + assert_eq!(stamped["reqId"], "r-64"); + assert_eq!(stamped["key"], "12:5"); + assert_eq!(stamped["commands"][0], "sr add {steamid} 100"); + assert_eq!(stamped["commands"][1], "say \"{name} won\""); + assert_eq!(stamped["recipients"][0]["name"], "Some \"One\""); + assert!(stamp(json!(["rbe"]), "command.run", "r-65").is_none()); + + // D385: a step's teardown rides its run, and a teardown asked for by key alone names the + // run it is for; both are the plugin's to read. + let run = stamp( + json!({ "key": "12:6", "commands": ["rbe 1"], "teardown": ["rbe stop"] }), + "command.run", + "r-66", + ) + .expect("an object is stamped"); + assert_eq!(run["teardown"][0], "rbe stop"); + let teardown = stamp( + json!({ "key": "12:6:teardown", "teardownOf": "12:6" }), + "command.run", + "r-67", + ) + .expect("an object is stamped"); + assert_eq!(teardown["cmd"], "command.run"); + assert_eq!(teardown["teardownOf"], "12:6"); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test] From b8e860d85e4b2febce43d2dee1f2d3e41a2c156d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 15:18:51 -0500 Subject: [PATCH 8/8] =?UTF-8?q?feat(sidecar):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20the=20raid=20base=20spawn=20and=20spot=20check=20forwards=20?= =?UTF-8?q?(PLAN=5FREDESIGNS=20=C2=A711.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /raid/spawn stamps the body raid.spawn and POST /raid/check stamps it raid.check, and each forwards it. The plugin keeps the key and the run's base in its world registry, so world.revert takes it back like a zone; nothing is kept here. The five raid.* events ride the feed like any other. Spec: docs rust-link/PROTOCOL.md §20.12. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 7 +++++++ sidecar/src/web.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 668ac3e..cf9b386 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -213,6 +213,13 @@ use tracing_subscriber::EnvFilter; /// recipient. The plugin remembers the key so nothing runs twice; this process keeps nothing and /// knows nothing of what a command does. /// +/// RaidableBases (§20.12, the module's PLAN_REDESIGNS.md §11.4) adds two forwards: `POST +/// /raid/spawn`, one base for a run's step, and `POST /raid/check`, a spot's checks with nothing +/// spawned. The plugin keeps the key and the run's base in its world registry, so `world.revert` +/// takes it back like a zone and `world.expired` says when the plugin ended it first. `raid.started`, +/// `raid.completed`, `raid.ended`, `raid.purchased` and `raid.locked` ride the feed like any other +/// event. +/// /// `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, /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 0d95b06..24e5bc6 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -135,6 +135,11 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // so nothing runs twice, fills the placeholders and decides who is online; this process // moves the line and knows nothing of what any command does (no allowlist, D384). .route("/commands/run", post(commands_run)) + // Protocol 14 (§20.12): one RaidableBases base for a run, and a spot's checks. The plugin + // keeps the key and the run's base in its world registry, so `world.revert` takes it back + // like a zone; nothing is kept here. + .route("/raid/spawn", post(raid_spawn)) + .route("/raid/check", post(raid_check)) // 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. @@ -597,6 +602,19 @@ async fn commands_run(State(st): State, Json(body): Json) -> Re forward_object(&st, body, "command.run", "a console command run").await } +/// Spawn one RaidableBases base for a run's step (protocol 14). `raid.queued` (its id, when the +/// raids helper made it the run's, and `repeat` for a key already seen) or `raid.error` with a +/// reason. The plugin's world registry holds the run's base; nothing is remembered here. +async fn raid_spawn(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "raid.spawn", "a raid base spawn").await +} + +/// Check a spot for a raid base without spawning one (protocol 14): `raid.checked`, with the +/// refusal's reason and the warnings, or `raid.error` when the spot cannot be checked at all. +async fn raid_check(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "raid.check", "a raid base spot check").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 @@ -1397,6 +1415,33 @@ mod tests { assert_eq!(teardown["teardownOf"], "12:6"); } + /// Protocol 14, RaidableBases: a spawn is stamped `raid.spawn` and a check `raid.check` + /// whatever their bodies name, so each route does its one thing; the plugin's own names for + /// difficulties, profiles and bases (D386), the spot and the settings pass through untouched. + #[test] + fn protocol_14_raid_spawn_and_check_are_stamped() { + let body = json!({ "cmd": "world.revert", "reqId": "theirs", "key": "12:7", "runId": "12", + "difficulty": "Wobbly", "profile": "Wobbly Bases", "base": "wobbly1", + "x": 650.0, "z": 2037.0, "heightAdjust": 5.0, + "pvp": false, "npcs": true, "npcCount": 0, "despawnMinutes": 90, "lock": true }); + let spawn = stamp(body.clone(), "raid.spawn", "r-68").expect("an object is stamped"); + + assert_eq!(spawn["cmd"], "raid.spawn"); + assert_eq!(spawn["reqId"], "r-68"); + assert_eq!(spawn["key"], "12:7"); + assert_eq!(spawn["difficulty"], "Wobbly"); + assert_eq!(spawn["base"], "wobbly1"); + assert_eq!(spawn["heightAdjust"], 5.0); + assert_eq!(spawn["npcCount"], 0); + assert_eq!(spawn["pvp"], false); + assert!(stamp(json!("Wobbly"), "raid.spawn", "r-69").is_none()); + + let check = stamp(body, "raid.check", "r-70").expect("an object is stamped"); + assert_eq!(check["cmd"], "raid.check"); + assert_eq!(check["x"], 650.0); + assert_eq!(check["profile"], "Wobbly Bases"); + } + /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// and the plugin's business, so it must arrive byte for byte as the website composed it. #[test]