Merge pull request 'feat(sidecar): forward the asset manifest, the pixels and the body pass (Phase 3)' (#43) from feat/asset-bridge-p3 into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m52s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m52s
Reviewed-on: #43
This commit is contained in:
@@ -141,6 +141,17 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
// the files on that host changed since the last import", and a cached answer to that is
|
||||
// worse than no answer.
|
||||
.route("/assets/sources", get(assets_sources))
|
||||
// Stage 2 (phase 3): what the shard could serve, hashed but without the pixels, so the
|
||||
// website can ask only for what changed. Paged.
|
||||
.route("/assets/manifest", get(assets_manifest))
|
||||
// The pixels, for an explicit list of keys. POST rather than GET because the list is the
|
||||
// request -- a few hundred keys do not belong in a query string, and this is the one place
|
||||
// on this link where a read takes a body.
|
||||
.route("/assets/fetch", post(assets_fetch))
|
||||
// Slug -> body id. The one asset-plane call the shard answers ON ITS CORE THREAD, because
|
||||
// it resolves a class name by constructing the creature and reading its body; the sidecar
|
||||
// neither knows nor cares, which is the point of forwarding verbatim.
|
||||
.route("/assets/bodies", post(assets_bodies))
|
||||
// The cliloc table (Protocol 8, phase 2): UO's id -> display-string map, read out of the
|
||||
// shard's own client and paged. RPC for the same reason as the source gate, and one more:
|
||||
// it is five megabytes of somebody else's copyrighted strings, which this process has no
|
||||
@@ -1445,6 +1456,105 @@ struct ClilocQuery {
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Stage 2 of the import gate (docs/link/v8.md §6, phase 3): every asset the shard could serve,
|
||||
/// with a hash and a size and **no pixels**.
|
||||
///
|
||||
/// That separation is the whole difference between an Update and a re-download. The website holds
|
||||
/// the hashes from last time, diffs this against them and asks `/assets/fetch` only for the keys
|
||||
/// that moved — which, on the normal restart-that-changed-nothing, is none of them.
|
||||
///
|
||||
/// **Paged, and the caller drives the paging**, same envelope as `/cliloc`: echo the previous
|
||||
/// reply's `cursor` until one says `more: false`, and read `cut` to learn *why* a page was the
|
||||
/// last — only `end` means the manifest is complete. This family pages on the shard's wall clock
|
||||
/// rather than on bytes, because its rows are tiny and building them means decoding hundreds of
|
||||
/// sprites, so expect several pages of a few hundred rows each.
|
||||
///
|
||||
/// `?family=` selects which asset family; phase 3 serves `body` and the shard refuses anything
|
||||
/// else by name rather than substituting a default.
|
||||
async fn assets_manifest(
|
||||
State(st): State<AppState>,
|
||||
Query(q): Query<ManifestQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let mut cmd = json!({"kind": "assets.manifest", "reqId": req_id});
|
||||
|
||||
if let Some(family) = q.family.as_deref().filter(|s| !s.is_empty()) {
|
||||
cmd["family"] = json!(family);
|
||||
}
|
||||
|
||||
if let Some(cursor) = q.cursor.as_deref().filter(|s| !s.is_empty()) {
|
||||
cmd["cursor"] = json!(cursor);
|
||||
}
|
||||
|
||||
respond_assets(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ManifestQuery {
|
||||
family: Option<String>,
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// The bytes, for keys the caller names: `{"keys":[…],"catalog":<opt>,"cursor":<opt>}`.
|
||||
///
|
||||
/// Each row carries the sprite as base64 PNG. The shard encodes it, and that is deliberate rather
|
||||
/// than incidental: `System.Drawing` is already in its *decode* path (docs/link/v8.md §4.2), so
|
||||
/// PNG costs it no new dependency, while asking the website to encode would put an image encoder
|
||||
/// in Node and make the manifest's hash cover bytes nobody ever stores.
|
||||
///
|
||||
/// **`catalog` is the mid-import guard.** A manifest reply carries a `catalog` id derived from the
|
||||
/// client files themselves; passing it back here makes the shard refuse (422) if those files moved
|
||||
/// in between. Without it an operator who patched their client halfway through an import would get
|
||||
/// one asset set stitched out of two, with no error anywhere.
|
||||
///
|
||||
/// A key the shard cannot serve comes back as a **row** with a `status`, not as a failed request —
|
||||
/// a body this client has no art for is the expected answer for two thirds of the player bodies,
|
||||
/// and failing the whole page over one would make an import impossible on a stock client.
|
||||
async fn assets_fetch(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
assets_call(&st, "assets.fetch", body).await
|
||||
}
|
||||
|
||||
/// Slug → body id: `{"types":["GiantSpider", …]}` (docs/link/v8.md §8, phase 3).
|
||||
///
|
||||
/// The spawn atlas knows a creature by the class name in `Spawns/*.xml`; the client knows it by a
|
||||
/// body id; nothing in the ServUO tree declares the mapping as data. Only code running *inside*
|
||||
/// ServUO can answer it — construct the type, read `Body.BodyID`, delete it — which is why this is
|
||||
/// a request kind of its own rather than a step inside asset extraction: it runs on the shard's
|
||||
/// Core thread, while every decode on this plane runs off it.
|
||||
///
|
||||
/// **The shard caps the batch and refuses rather than truncates** a longer list, because every
|
||||
/// name in it costs a real constructor between two ticks of the world. Chunk the list; a 400 here
|
||||
/// names the cap.
|
||||
async fn assets_bodies(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
assets_call(&st, "assets.bodies", body).await
|
||||
}
|
||||
|
||||
/// `event_call`'s shape for the asset plane: take the caller's object verbatim, stamp `kind` and
|
||||
/// `reqId` on it, and map the reply through `respond_assets` rather than `respond_event`.
|
||||
///
|
||||
/// The two differ in exactly one way that matters, and it is the reason this is not `event_call`
|
||||
/// with a different string: `bridge.busy` is a **425** here, and it is the ordinary answer during
|
||||
/// an import rather than a rare collision. The shard serves one asset request at a time on purpose,
|
||||
/// so a caller that read busy as an error would abandon a healthy transfer.
|
||||
async fn assets_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json<Value>) {
|
||||
let mut obj = match body {
|
||||
Value::Object(m) => m,
|
||||
Value::Null => serde_json::Map::new(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "body must be a JSON object"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let req_id = st.rpc.next_req_id();
|
||||
obj.insert("kind".to_string(), json!(kind));
|
||||
obj.insert("reqId".to_string(), json!(req_id));
|
||||
|
||||
respond_assets(st.rpc.call(&st.shard, Value::Object(obj), &req_id).await)
|
||||
}
|
||||
|
||||
// ---- websocket ----
|
||||
|
||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
Reference in New Issue
Block a user