// Mint a `town.deception.resource` record: upload a sprite image and write the record carrying its // display name + plural, then print the new record's AT-URI. That AT-URI is what you paste into a // world.tilemap.map station marker (`town.deception.collect` / `deposit` / `scoreboard`) in the // tilemap.world editor — the game identifies the resource by this URI, and the client fetches this // record for the name/sprite (the sprite serves as both the menu/scoreboard icon and the carry // sprite). Replaces the old browser Scenario editor's resource authoring. // // Auth is an app password (Settings → App Passwords on your PDS), prompted on the TTY (hidden); // defaults target chris.pardy.family on pds.pardisio.xyz. Same pattern as upload-sprites.ts. // // tsx scripts/mint-resource.ts ./wood.png "Wood" "Logs" // // # overrides (env): PDS_HANDLE, PDS_SERVICE, PDS_PASSWORD (skips the prompt) // PDS_HANDLE=you.example.com tsx scripts/mint-resource.ts ./ore.webp "Ore" // // Args: [plural]. The image must be image/png or image/webp (≤ 1 MB, per the // town.deception.resource lexicon's `sprite` blob constraint). import { readFile } from 'node:fs/promises' import { extname } from 'node:path' import { createInterface } from 'node:readline' import { AtpAgent } from '@atproto/api' const COLLECTION = 'town.deception.resource' const DEFAULT_SERVICE = 'https://pds.pardisio.xyz' const DEFAULT_HANDLE = 'chris.pardy.family' const MAX_BLOB_BYTES = 1_000_000 // matches the lexicon's `sprite` maxSize /** Prompt for a secret on the TTY without echoing keystrokes. */ function promptPassword(query: string): Promise { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }) let muted = false ;(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = (s: string) => { if (!muted) process.stdout.write(s) } rl.question(query, (value) => { process.stdout.write('\n') rl.close() resolve(value) }) muted = true }) } function env(name: string, fallback?: string): string { const v = process.env[name] ?? fallback if (v === undefined) { console.error(`missing required env var ${name}`) process.exit(1) } return v } /** image/png or image/webp from the file extension; rejects anything else (the lexicon's accept set). */ function mimeFor(file: string): string { const ext = extname(file).toLowerCase() if (ext === '.png') return 'image/png' if (ext === '.webp') return 'image/webp' console.error(`unsupported sprite type ${ext || '(none)'} — expected .png or .webp`) process.exit(1) } async function main() { const [spritePath, name, plural] = process.argv.slice(2) if (!spritePath || !name) { console.error('usage: tsx scripts/mint-resource.ts [plural]') process.exit(1) } const mime = mimeFor(spritePath) const bytes = new Uint8Array(await readFile(spritePath)) if (bytes.byteLength === 0) { console.error(`${spritePath} is empty`) process.exit(1) } if (bytes.byteLength > MAX_BLOB_BYTES) { console.error(`${spritePath} is ${bytes.byteLength} bytes — max ${MAX_BLOB_BYTES}`) process.exit(1) } const identifier = env('PDS_HANDLE', DEFAULT_HANDLE) const password = process.env.PDS_PASSWORD ?? (await promptPassword(`app password for ${identifier}: `)) if (!password) { console.error('no password provided') process.exit(1) } const agent = new AtpAgent({ service: env('PDS_SERVICE', DEFAULT_SERVICE) }) await agent.login({ identifier, password }) const did = agent.session!.did console.log(`logged in as ${did}`) const { data: up } = await agent.com.atproto.repo.uploadBlob(bytes, { encoding: mime }) const record = { $type: COLLECTION, name, ...(plural ? { plural } : {}), sprite: up.blob, createdAt: new Date().toISOString(), } const { data: put } = await agent.com.atproto.repo.createRecord({ repo: did, collection: COLLECTION, record, // town.deception.* isn't a PDS-resolvable lexicon, so skip server-side schema validation (same // as the game-declaration / sprite writes). validate: false, }) console.log(` ✓ ${name}${plural ? ` / ${plural}` : ''}`) console.log(` ${put.uri}`) console.log('\nPaste that AT-URI into a station marker (town.deception.collect / deposit / scoreboard).') } main().catch((err) => { console.error(err) process.exit(1) })