// ROUND 2 of NPC-account setup for Deception. For each NPC account created in // round 1 (scripts/setup-npc-accounts.ts), using that account's OWN app password: // // 1. updateHandle: .pardy.family -> .deception.town // (DNS _atproto..deception.town already resolves to the account's DID) // 2. uploadBlob the sprite PNG (scripts/) + putRecord actor.rpg.sprite/self // — includes `origin: "deception.town"` (per rpg.actor guidance) and the // custom `cellHeightEighths` on-map size, like scripts/upload-sprites.ts. // 3. putRecord town.deception.npc/self — the decorative-NPC behaviour record // (type key + on-map size + wander/idle/spin tunables). Sprite is implicit: // the same repo's actor.rpg.sprite/self. // // Records are written with validate:false (town.deception.npc has no published // lexicon yet; actor.rpg.sprite is keyed at non-self in our deviation — see // upload-sprites.ts). Idempotent: rkey "self" overwrites; updateHandle is skipped // when already correct. // // App passwords come from NPC_PASSWORDS (JSON {name: appPassword}); never on disk. // // NPC_PASSWORDS='{"cow":"xxxx-xxxx-xxxx-xxxx"}' tsx scripts/upload-npc-records.ts cow // NPC_PASSWORDS='{...all 7...}' tsx scripts/upload-npc-records.ts # all listed // // # overrides (env): PDS_SERVICE, SPRITE_DIR, ZONE_DOMAIN import { readFile } from 'node:fs/promises' import { join } from 'node:path' const PDS = process.env.PDS_SERVICE ?? 'https://pds.pardisio.xyz' const SPRITE_DIR = process.env.SPRITE_DIR ?? 'scripts' const ZONE_DOMAIN = process.env.ZONE_DOMAIN ?? 'deception.town' const SPRITE_COLLECTION = 'actor.rpg.sprite' const NPC_COLLECTION = 'town.deception.npc' const ORIGIN = 'deception.town' const COLS = 3 const ROWS = 4 // Skip handle + sprite; only (re)write town.deception.npc/self. For refreshing // behaviour/emoji without re-minting sprite blobs. const RECORD_ONLY = process.env.NPC_RECORD_ONLY === '1' // Round-1 output (name -> did/type/size/sprite file), plus per-type behaviour. interface NpcSpec { name: string did: string type: 'cow' | 'pig' | 'chicken' sizeEighths: number file: string /** emoji this critter emotes above its head — represents the animal. */ emoji: string } const NPCS: NpcSpec[] = [ { name: 'cow', did: 'did:plc:ytznjnt4dh3gylkaihztnrp4', type: 'cow', sizeEighths: 13, file: 'cow.png', emoji: 'šŸ„' }, { name: 'cow-black', did: 'did:plc:7jzk6tqrjtpuhcaqvicceyei', type: 'cow', sizeEighths: 13, file: 'cow-black.png', emoji: 'šŸ„' }, { name: 'cow-baby', did: 'did:plc:4ip6zi55ucuuvmwk6he3pwdn', type: 'cow', sizeEighths: 8, file: 'cow-baby.png', emoji: '🐮' }, { name: 'pig', did: 'did:plc:cnkcslr4b263o36lgzfdq2xm', type: 'pig', sizeEighths: 9, file: 'pig.png', emoji: '🐷' }, { name: 'chicken', did: 'did:plc:gr2odyrnxomps4afs72ftmes', type: 'chicken', sizeEighths: 5, file: 'chicken.png', emoji: 'šŸ”' }, { name: 'chicken-brown', did: 'did:plc:6gzl34lx6fbg6dmgwi2cdeo5', type: 'chicken', sizeEighths: 5, file: 'chicken-brown.png', emoji: 'šŸ”' }, { name: 'chicken-baby', did: 'did:plc:zol7dxf4fa7mqh7wzlnsyoaw', type: 'chicken', sizeEighths: 3, file: 'chicken-baby.png', emoji: '🐄' }, ] // Per-type wander behaviour (px / px-per-sec / relative weights). All optional in // the record; these are sensible, easily-retuned starting points. Cows amble in a // wide area; chickens are quick, skittish, and turn in place more. const BEHAVIOUR: Record> = { cow: { wanderRadius: 96, speed: 28, yieldRadius: 64, idleWeight: 4, spinWeight: 1, wanderWeight: 2 }, pig: { wanderRadius: 72, speed: 34, yieldRadius: 56, idleWeight: 3, spinWeight: 1, wanderWeight: 2 }, chicken: { wanderRadius: 64, speed: 52, yieldRadius: 40, idleWeight: 2, spinWeight: 2, wanderWeight: 3 }, } /** Read intrinsic pixel dimensions from a PNG's IHDR chunk (bytes 16–24). */ function pngSize(buf: Uint8Array): { width: number; height: number } { const isPng = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 if (!isPng || buf.length < 24) throw new Error('not a PNG (bad signature/length)') const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) return { width: view.getUint32(16), height: view.getUint32(20) } } class Client { private jwt: string | null = null handle = '' constructor(readonly did: string, private readonly password: string) {} async login(): Promise { const out = await this.post('com.atproto.server.createSession', { identifier: this.did, password: this.password, }) this.jwt = out.accessJwt as string this.handle = out.handle as string } async post(nsid: string, body: unknown): Promise> { const res = await fetch(`${PDS}/xrpc/${nsid}`, { method: 'POST', headers: { 'content-type': 'application/json', ...(this.jwt ? { authorization: `Bearer ${this.jwt}` } : {}), }, body: JSON.stringify(body), }) const json = (await res.json().catch(() => ({}))) as Record if (!res.ok) throw new Error(`${nsid}: ${json.error ?? res.status} ${json.message ?? ''}`.trim()) return json } async uploadBlob(bytes: Uint8Array): Promise { const res = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'content-type': 'image/png', authorization: `Bearer ${this.jwt}` }, body: bytes as unknown as BodyInit, }) const json = (await res.json().catch(() => ({}))) as Record if (!res.ok) throw new Error(`uploadBlob: ${json.error ?? res.status} ${json.message ?? ''}`.trim()) return (json as { blob: unknown }).blob } putRecord(collection: string, rkey: string, record: Record) { return this.post('com.atproto.repo.putRecord', { repo: this.did, collection, rkey, record, validate: false, }) } } async function setupOne(spec: NpcSpec, password: string): Promise { const targetHandle = `${spec.name}.${ZONE_DOMAIN}` const client = new Client(spec.did, password) await client.login() console.log(`\n${spec.name} (${spec.did})`) console.log(` logged in as ${client.handle}`) // 1+2 (handle + sprite) are skipped in record-only mode — used to just refresh // the town.deception.npc/self behaviour record (e.g. adding the emoji) without // re-minting sprite blobs. if (!RECORD_ONLY) { // 1. handle if (client.handle === targetHandle) { console.log(` • handle already ${targetHandle}`) } else { await client.post('com.atproto.identity.updateHandle', { handle: targetHandle }) console.log(` āœ“ handle -> ${targetHandle}`) } // 2. sprite const bytes = new Uint8Array(await readFile(join(SPRITE_DIR, spec.file))) const { width, height } = pngSize(bytes) if (width % COLS !== 0 || height % ROWS !== 0) { throw new Error(`${spec.file}: ${width}x${height} not divisible by ${COLS}x${ROWS} grid`) } const blob = await client.uploadBlob(bytes) const spriteRecord = { $type: SPRITE_COLLECTION, columns: COLS, rows: ROWS, frames: COLS * ROWS, frameWidth: width / COLS, frameHeight: height / ROWS, width, height, spriteSheet: blob, cellHeightEighths: spec.sizeEighths, origin: ORIGIN, createdAt: new Date().toISOString(), } await client.putRecord(SPRITE_COLLECTION, 'self', spriteRecord) console.log(` āœ“ ${SPRITE_COLLECTION}/self (${width}x${height}, ${spec.sizeEighths}/8 cells, origin=${ORIGIN})`) } // 3. npc behaviour (+ emoji) const npcRecord = { $type: NPC_COLLECTION, key: spec.type, sizeEighths: spec.sizeEighths, emoji: spec.emoji, ...BEHAVIOUR[spec.type], createdAt: new Date().toISOString(), } await client.putRecord(NPC_COLLECTION, 'self', npcRecord) console.log(` āœ“ ${NPC_COLLECTION}/self (key=${spec.type}, emoji=${spec.emoji}, ${JSON.stringify(BEHAVIOUR[spec.type])})`) } async function main() { const passwords = JSON.parse(process.env.NPC_PASSWORDS ?? '{}') as Record const requested = process.argv.slice(2) const names = requested.length > 0 ? requested : Object.keys(passwords) if (names.length === 0) { console.error('no NPCs given and NPC_PASSWORDS is empty') process.exit(1) } let ok = 0 for (const name of names) { const spec = NPCS.find((n) => n.name === name) if (!spec) { console.error(`\nāœ— unknown NPC "${name}" (known: ${NPCS.map((n) => n.name).join(', ')})`) continue } const pw = passwords[name] if (!pw) { console.error(`\nāœ— ${name}: no app password in NPC_PASSWORDS`) continue } try { await setupOne(spec, pw) ok++ } catch (err) { console.error(`\nāœ— ${name}: ${err instanceof Error ? err.message : err}`) } } console.log(`\ndone: ${ok}/${names.length} NPC(s) set up`) process.exit(ok === names.length ? 0 : 1) } main().catch((err) => { console.error('\nFATAL:', err instanceof Error ? err.message : err) process.exit(1) })