// --------------------------------------------------------------------------- // Map import — resolve a `world.tilemap.map` AT-URI into a render-ready // `MapExport` (+ atlas URL) for the engine's `buildScene`. // // This is the atproto adapter that the pure `render/` engine deliberately does // NOT contain: it knows about DIDs, PDS resolution, XRPC and blobs, so the // engine itself stays dependency-free (pixi.js only) and portable. It is plain // `fetch` + JSON — no `@atproto/api`, no pixi, no DOM beyond `fetch` — so it runs // in both the browser and node (e.g. the `scripts/smoke-map.ts` check). // // How a map record carries its render (see tilemap.world `lib/mapRecord.ts`): // the editor bakes the live scene into a self-contained `MapExport`, then stores // it as two blobs on the record's `render` field — `data` (the MapExport JSON, // minus the inline atlas) and `image` (the atlas PNG). We fetch `data`, splice // the atlas blob URL back into `sheet.dataUrl`, and hand the result to the // engine. The editable `nodes`/`grid` on the record are ignored here — the baked // render is the runtime source of truth. // --------------------------------------------------------------------------- import type { MapExport } from '../../render/contract' const PLC_DIRECTORY = 'https://plc.directory' /** A parsed `at:////` URI. */ export interface AtUriParts { did: string collection: string rkey: string } export function parseAtUri(uri: string): AtUriParts { const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(uri) if (!m) throw new Error(`not a record AT-URI: ${uri}`) return { did: m[1]!, collection: m[2]!, rkey: m[3]! } } /** Resolve a DID to its PDS service endpoint (supports did:plc and did:web). */ export async function resolvePds(did: string): Promise { const doc = await fetchDidDoc(did) const svc = (doc.service ?? []).find( (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer', ) if (!svc?.serviceEndpoint) throw new Error(`no #atproto_pds endpoint in DID doc for ${did}`) return svc.serviceEndpoint.replace(/\/$/, '') } interface DidService { id: string type: string serviceEndpoint: string } interface DidDoc { service?: DidService[] } async function fetchDidDoc(did: string): Promise { let url: string if (did.startsWith('did:plc:')) { url = `${PLC_DIRECTORY}/${did}` } else if (did.startsWith('did:web:')) { // did:web:host(:path…) → https://host/(path/)…/did.json const rest = did.slice('did:web:'.length).split(':').map(decodeURIComponent) const host = rest.shift()! url = `https://${host}/${rest.length ? rest.join('/') + '/' : '.well-known/'}did.json` } else { throw new Error(`unsupported DID method: ${did}`) } const res = await fetch(url) if (!res.ok) throw new Error(`DID resolution failed (${res.status}) for ${did}`) return (await res.json()) as DidDoc } /** A blob reference as it appears in a getRecord JSON response. */ interface BlobRef { $type: 'blob' ref: { $link: string } mimeType: string size: number } /** The `render` field baked onto a `world.tilemap.map` record. */ interface BakedRender { version: number cell: { w: number; h: number } bounds?: { x: number; y: number; w: number; h: number } /** the MapExport JSON (without the inline atlas dataUrl). */ data: BlobRef /** the atlas PNG (absent for a colour-only map with no art to bake). */ image?: BlobRef } interface MapRecordValue { render?: BakedRender [k: string]: unknown } /** The XRPC URL that streams a repo blob by CID. */ function blobUrl(pds: string, did: string, cid: string): string { return `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}` } async function getRecord(pds: string, parts: AtUriParts): Promise { const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parts.did)}` + `&collection=${encodeURIComponent(parts.collection)}&rkey=${encodeURIComponent(parts.rkey)}` const res = await fetch(url) if (!res.ok) throw new Error(`getRecord failed (${res.status}) for ${parts.rkey}`) const json = (await res.json()) as { value: MapRecordValue } return json.value } export interface LoadedMap { /** render-ready scene; pass to the engine's `buildScene`. */ exp: MapExport /** URL of the baked atlas PNG; pass to the engine's `loadTexture`. null = no * art baked (colour-only map), in which case `buildScene` is called with a * null atlas and only background/areas/markers render. */ atlasUrl: string | null /** the raw record value (its `nodes`/`grid` drive server-side nav). */ record: MapRecordValue } /** * Fetch a `world.tilemap.map` record by AT-URI and reconstruct the baked * `MapExport` plus its atlas URL. Throws if the record carries no baked render. */ export async function loadMapExport(atUri: string): Promise { const parts = parseAtUri(atUri) const pds = await resolvePds(parts.did) const record = await getRecord(pds, parts) const render = record.render if (!render?.data) { throw new Error(`map ${atUri} has no baked render — re-save it in the editor to bake one`) } const exp = (await fetch(blobUrl(pds, parts.did, render.data.ref.$link)).then((r) => { if (!r.ok) throw new Error(`render data blob fetch failed (${r.status})`) return r.json() })) as MapExport const atlasUrl = render.image ? blobUrl(pds, parts.did, render.image.ref.$link) : null // splice the atlas URL back into the sheet so the MapExport is self-describing // (the engine also accepts the atlas as a separate Texture arg). exp.sheet = { ...exp.sheet, dataUrl: atlasUrl } return { exp, atlasUrl, record } }