feat(sidecar): protocol 12 — POST /titles (phase 17) #12

Merged
whitlocktech merged 1 commits from feat/phase-17-integrations into edge 2026-09-25 23:31:26 +00:00
3 changed files with 31 additions and 2 deletions

View File

@@ -57,6 +57,7 @@ still authenticate). Every response carries `X-RustLink-Version`.
| `GET /feed?since=&limit=` | store | **Oldest first, from a cursor.** For a consumer that must not miss a row. Omitting `since` asks where the end is |
| `GET /status` | plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
| `GET /ws` | broadcast | The live feed. Sends `ws.hello` on connect |
| every later route | plugin (RPC) | One thin forward per command, from `POST /link/confirm` (protocol 3) to `POST /titles` (protocol 12). Each is listed beside its protocol in `src/web.rs`, and its body in `docs/rust-link/PROTOCOL.md` |
The split is the point: the store-backed reads answer while the game is off, which is what lets the
website render a server list during a wipe or a restart. `/status` is the one route that fails when

View File

@@ -163,8 +163,8 @@ use tracing_subscriber::EnvFilter;
///
/// `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; this constant is one of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 11;
/// §16 the rewards, §17 the map, §18 the optional mods; this constant is one of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 12;
fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) {

View File

@@ -135,6 +135,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/map/chunk", get(map_chunk))
.route("/map/render", post(map_render))
.route("/map/live", get(map_live))
// Protocol 12 (§33): the chat titles a player has earned. A whole set, replaced each time,
// held by the plugin in memory and read by BetterChat on the chat path. Nothing here knows
// what a title is.
.route("/titles", post(titles))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new()
@@ -565,6 +569,12 @@ async fn chat(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "chat.say", "a chat line").await
}
/// Replace the chat titles this server's plugin holds (protocol 12). The set is whole, so a retry
/// is harmless, and the plugin answers whether BetterChat is there to show them.
async fn titles(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "titles.set", "a title set").await
}
/// What this map is, where its picture comes from, and its monuments (protocol 11, stage one).
/// Live, and never cached here: a wipe changes the answer, and the module compares its key and
/// hash against what it holds to decide whether to fetch at all.
@@ -1097,6 +1107,24 @@ mod tests {
assert!(stamp(json!(["9"]), "tally.close", "r-4").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]
fn a_protocol_12_title_set_passes_through_untouched() {
let body = json!({
"cmd": "chat.say",
"setId": "s-1",
"titles": [{ "steamId": "76561198000000001", "text": "[#ff8800]Top Killer[/#]" }],
});
let stamped = stamp(body, "titles.set", "r-5").expect("an object is stamped");
assert_eq!(stamped["cmd"], "titles.set");
assert_eq!(stamped["reqId"], "r-5");
assert_eq!(stamped["setId"], "s-1");
assert_eq!(stamped["titles"][0]["text"], "[#ff8800]Top Killer[/#]");
assert!(stamp(json!("titles"), "titles.set", "r-6").is_none());
}
/// Protocol 11's one opaque forward is stamped like the rest: a render cannot become a world
/// placement by naming one.
#[test]