Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
9.2 kB · 219 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220// 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: <name>.pardy.family -> <name>.deception.town// (DNS _atproto.<name>.deception.town already resolves to the account's DID)// 2. uploadBlob the sprite PNG (scripts/<file>) + 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 = 3const 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<NpcSpec['type'], Record<string, number>> = { 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<void> { 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<Record<string, unknown>> { 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<string, unknown> if (!res.ok) throw new Error(`${nsid}: ${json.error ?? res.status} ${json.message ?? ''}`.trim()) return json }
async uploadBlob(bytes: Uint8Array): Promise<unknown> { 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<string, unknown> 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<string, unknown>) { return this.post('com.atproto.repo.putRecord', { repo: this.did, collection, rkey, record, validate: false, }) }}
async function setupOne(spec: NpcSpec, password: string): Promise<void> { 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<string, string> 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)})