WorldForge Studio

02 — Worlds and resources

The typed REST surface: one client object, a resource module per route group, and a raw escape hatch for everything else.

The client

import { createClient } from '@blueforge-studio/worldforge-sdk';

const client = createClient({ baseUrl, apiKey });
ResourceRoutes it covers
client.worldsGET/POST /worlds, GET/PATCH/DELETE /worlds/:id
client.atlas/atlas/nodes, /atlas/edges
client.regions/worlds/:id/regions incl. reveal / hide
client.wiki/wiki/pages CRUD + restore
client.packs/packs CRUD, duplicate, assets incl. multipart upload
client.proposals/proposals list/commit/reject/undo, per-world inbox, policies, auto-accept pause/resume
client.printShops/print-shops (+ near/radius_km filter), /print-orders
client.collabmemberships, feed, presence (a snapshot read — the live view is SSE, see 04)
client.rawevery other route — same auth, errors, query building

Every method takes a trailing opts?: RequestOptions — { query, body, signal, headers, parse } — and returns a promise of the (type-only) response. IDs are encodeURIComponent-escaped into the path for you.

Worlds

// Create — schema_version is the world JSON schema ladder (see /offline).
const { id, version } = await client.worlds.create({
  name: 'My Campaign',
  schema_version: 1,          // positive integer; the server's tests all send 1
  json: worldDocument,        // typed `{} | null`, not `unknown` — pass a real object
});

const list = await client.worlds.list();
const detail = await client.worlds.get(id); // includes `json`

// PATCH requires EXACTLY ONE mode flag (else 400 commit_required):
await client.worlds.patch(id, { json: next, confirm: true });      // write now
await client.worlds.patch(id, { json: next, propose: true });      // file a proposal

WorldPatchBody also allows name, proposal_id, diff_summary, changeset_id and base_version (optimistic-concurrency version; a mismatch is a version_conflict error carrying currentVersion). patch resolves to unknown — the response shape depends on the mode. A world is removed with await client.worlds.delete(id).

confirm: true needs the world:commit scope (owner only); propose: true needs world:write and is open to GMs — the proposal lands in the owner's review inbox (client.proposals.inbox(worldId)).

Resource reference

Signatures as exported; opts is the trailing RequestOptions.

ResourceMethods
worldslist(), create(body), get(id), patch(id, body), delete(id)
atlaslistNodes(worldId), getNode(id), createNode(body), patchNode(id, body), deleteNode(id), listEdges(worldId), createEdge(body), deleteEdge(id) — worldId goes in the worldId query parameter
regionslist(worldId), create(worldId, body), patch(worldId, regionId, body), remove(worldId, regionId), reveal(worldId, regionId, body?), hide(worldId, regionId, body?)
wikilist(worldId) (extra filters via opts.query), get(id), create({ worldId, title, body?, kind?, audience? }), patch(id, body), remove(id), restore(id)
packslist(), get(packId) (with assets?), create(body), patch(packId, body), remove(packId), duplicate(packId), listAssets(packId), uploadAsset(packId, file: Blob | FormData) — a bare Blob is sent as the multipart field file
proposalslist(query?), commit(id, body?), reject(id, body?), undo(id, body?), inbox(worldId), listPolicies(worldId), createPolicy(worldId, body), patchPolicy(worldId, policyId, body), deletePolicy(worldId, policyId), pauseAutoAccept(worldId), resumeAutoAccept(worldId)
printShopslist(query?), get(id), createOrder(body), listOrders(), orderQrRaw(id) (the QR image as a Response)
collablistMembers(worldId), addMember(worldId, { userId, role? }), removeMember(worldId, userId), feed(worldId, query?), presence(worldId)
rawrequest(method, path), get, post, patch, delete, response(method, path) — all take opts (no put)

Most write bodies are Record<string, unknown>: the SDK does not pin their shape, so check the route reference in docs/api/ for the fields.

Exported types

From the root entrypoint: WorldForgeClient, ClientOptions, RequestOptions, QueryParams, TransportOptions, WorldForgeApiErrorBody, the auth types listed in 01, and the data types WorldSummary, WorldDetail, WorldCreateBody, WorldCreateResponse, WorldPatchBody, AtlasNode, AtlasEdge, WikiPageSummary, WikiPage, PackSummary, PackAsset, Proposal, ProposalStatus ('pending' | 'committed' | 'rejected' | 'expired' | 'stale'), ProposalPolicy, PrintShop, PrintOrder, Region, Membership, FeedItem. Most are open shapes ([key: string]: unknown) because new fields ship additively. RegionsResource and PrintShopsResource are also exported as classes; the other resource classes are not.

Transport (the fetch wrapper every resource uses: get/post/patch/delete, request, raw, url) and buildQuery(query) are exported for building your own resource modules. buildQuery skips undefined/null, repeats the key for arrays, and returns '' or '?a=1&b=2'.

Errors and opt-in validation

Every non-2xx throws WorldForgeApiError (status, code, reason, requestId, body). Responses are type-only by default — no runtime parsing unless you ask:

const world = await client.worlds.get(id, {
  parse: { parse: (v) => MyWorldShape.parse(v) },   // e.g. WorldSchema from /offline
});

This keeps the hot path free of zod-version skew and makes validation an explicit opt-in when a consumer's schema must win over the server's.

The raw escape hatch

Typed methods cover the core surface; everything else is one call away:

// e.g. a route the SDK hasn't wrapped yet
const items = await client.raw.get<InventoryItem[]>(`/worlds/${id}/inventory`);

// non-JSON responses (QR codes, PDFs, files) → the Response itself
const res = await client.raw.response('GET', `/print-orders/${orderId}/qr`);

Query parameters, JSON bodies, headers and AbortSignal work the same as on typed methods ({ query: { … }, body: { … }, signal, headers }). raw.response returns the Response untouched (still throwing on non-2xx); raw.get/post/... return parsed JSON, or undefined for a 204 / empty body.

Pagination and filtering

There is no pagination helper — list methods return whole arrays. Routes take their filters as query parameters — pass them inline (list(worldId, { query }) on wiki, a query first argument on printShops.list, proposals.list and collab.feed):

await client.wiki.list(worldId, { query: { includeDeleted: 'true' } });
await client.printShops.list({ near: '55.67,12.56', radius_km: 10 });
await client.atlas.listNodes(worldId);