feat(sidecar): protocol 14 — the Economics forwards (PLAN_REDESIGNS §11.2) #25

Merged
whitlocktech merged 1 commits from feat/protocol-14-economics into edge 2026-10-12 07:40:40 -05:00
2 changed files with 58 additions and 0 deletions

View File

@@ -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;

View File

@@ -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<AppState>, Json(body): Json<Value>) -> 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<AppState>, Json(body): Json<Value>) -> 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<AppState>, Json(body): Json<Value>) -> 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<AppState>, Json(body): Json<Value>) -> 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<AppState>, Json(body): Json<Value>) -> 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]