Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
7.8 kB · 212 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213// ROUND 1 of NPC-account setup for Deception (deception.town decorative critters).//// For each NPC this:// 1. Creates a DELEGATED account on the tranquil PDS (pds.pardisio.xyz),// controlled by the admin (chris.pardy.family), with a TEMPORARY service// handle `<name>.pardy.family`. Captures the new account's DID.// 2. Appends an `_atproto.<name>` TXT record (value `did=<did>`) to the admin's// `at.marque.dns/deception.town` zone record so that `<name>.deception.town`// will resolve to the new account once marque propagates it.//// It does NOT update handles or write sprite/npc records — those need each// account's own app password (ROUND 2), which the operator supplies after this.//// Idempotent: re-running skips accounts whose temp handle already resolves and// skips DNS entries that already exist, so a partial run is safe to repeat.//// Auth: the admin's app password (createDelegatedAccount + putRecord only need an// active session; no admin flag / invite code). Never written to disk.//// ADMIN_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx tsx scripts/setup-npc-accounts.ts cow// ADMIN_APP_PASSWORD=... tsx scripts/setup-npc-accounts.ts # all 7//// # overrides (env): PDS_SERVICE, ADMIN_HANDLE, TEMP_DOMAIN, ZONE_DOMAIN
const PDS = process.env.PDS_SERVICE ?? 'https://pds.pardisio.xyz'const ADMIN_HANDLE = process.env.ADMIN_HANDLE ?? 'chris.pardy.family'const TEMP_DOMAIN = process.env.TEMP_DOMAIN ?? 'pardy.family' // service domain; auto-accepted at creationconst ZONE_DOMAIN = process.env.ZONE_DOMAIN ?? 'deception.town'const DNS_COLLECTION = 'at.marque.dns'const CONTROLLER_SCOPES = 'atproto' // "owner" preset — full control, lets us act later if needed
// The decorative critters. `name` is the handle label + DNS subdomain + sprite rkey.const ALL_NPCS = [ 'cow', 'cow-black', 'cow-baby', 'pig', 'chicken', 'chicken-brown', 'chicken-baby',]
interface DnsEntry { name: string recordType: string ttl: number value: string}
async function xrpc( nsid: string, body: unknown, jwt?: string,): Promise<Record<string, unknown>> { const res = await fetch(`${PDS}/xrpc/${nsid}`, { method: 'POST', headers: { 'content-type': 'application/json', ...(jwt ? { authorization: `Bearer ${jwt}` } : {}), }, body: JSON.stringify(body), }) const json = (await res.json().catch(() => ({}))) as Record<string, unknown> if (!res.ok) { const err = `${json.error ?? res.status}: ${json.message ?? ''}`.trim() const e = new Error(err) as Error & { code?: string } e.code = typeof json.error === 'string' ? json.error : undefined throw e } return json}
async function resolveHandle(handle: string): Promise<string | null> { const res = await fetch( `${PDS}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, ) if (!res.ok) return null const { did } = (await res.json()) as { did?: string } return did ?? null}
async function main() { const password = process.env.ADMIN_APP_PASSWORD if (!password) { console.error('missing ADMIN_APP_PASSWORD env var') process.exit(1) } const names = process.argv.slice(2) const npcs = names.length > 0 ? names : ALL_NPCS const unknown = npcs.filter((n) => !ALL_NPCS.includes(n)) if (unknown.length) { console.error(`unknown NPC(s): ${unknown.join(', ')}\nknown: ${ALL_NPCS.join(', ')}`) process.exit(1) }
// --- admin login --- const session = await xrpc('com.atproto.server.createSession', { identifier: ADMIN_HANDLE, password, }) const adminJwt = session.accessJwt as string const adminDid = session.did as string console.log(`admin: ${ADMIN_HANDLE} (${adminDid})`) console.log(`creating ${npcs.length} delegated account(s): ${npcs.join(', ')}\n`)
// This PDS requires an invite code for account creation. Mint one (admin) with // enough uses to cover the batch; createDelegatedAccount accepts it as `inviteCode`. // Re-use INVITE_CODE if the operator already has one. let inviteCode = process.env.INVITE_CODE if (!inviteCode) { const inv = await xrpc( 'com.atproto.server.createInviteCode', { useCount: npcs.length }, adminJwt, ) inviteCode = inv.code as string console.log(`minted invite code ${inviteCode} (${npcs.length} uses)\n`) }
// --- 1. create (or find) each delegated account --- const created: { name: string; did: string; tempHandle: string; isNew: boolean }[] = [] for (const name of npcs) { const tempHandle = `${name}.${TEMP_DOMAIN}` try { const out = await xrpc( '_delegation.createDelegatedAccount', { handle: tempHandle, controllerScopes: CONTROLLER_SCOPES, inviteCode }, adminJwt, ) const did = out.did as string console.log(` ✓ created ${tempHandle} -> ${did}`) created.push({ name, did, tempHandle, isNew: true }) } catch (err) { const e = err as Error & { code?: string } // Already created on a prior run — reuse the existing DID. if (e.code === 'HandleNotAvailable' || /not available|taken/i.test(e.message)) { const did = await resolveHandle(tempHandle) if (did) { console.log(` • exists ${tempHandle} -> ${did} (skip create)`) created.push({ name, did, tempHandle, isNew: false }) continue } } console.error(` ✗ ${tempHandle}: ${e.message}`) throw err } }
// --- 2. append _atproto.<name> TXT entries to the deception.town zone record --- console.log(`\nupdating ${DNS_COLLECTION}/${ZONE_DOMAIN} zone record…`) const getRes = await fetch( `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${adminDid}&collection=${DNS_COLLECTION}&rkey=${ZONE_DOMAIN}`, ) if (!getRes.ok) throw new Error(`could not read zone record: ${getRes.status}`) const zone = (await getRes.json()) as { cid: string value: { records: DnsEntry[]; [k: string]: unknown } } const records = zone.value.records let added = 0 for (const { name, did } of created) { const dnsName = `_atproto.${name}` const existing = records.find((r) => r.name === dnsName && r.recordType === 'TXT') const value = `did=${did}` if (existing) { if (existing.value !== value) { console.log(` ~ ${dnsName}: ${existing.value} -> ${value} (rewrite)`) existing.value = value added++ } else { console.log(` • ${dnsName} already -> ${value} (skip)`) } continue } records.push({ name: dnsName, recordType: 'TXT', ttl: 3600, value }) console.log(` + ${dnsName} -> ${value}`) added++ }
if (added > 0) { await xrpc( 'com.atproto.repo.putRecord', { repo: adminDid, collection: DNS_COLLECTION, rkey: ZONE_DOMAIN, record: zone.value, swapRecord: zone.cid, // fail loudly if the zone changed under us validate: false, // at.marque.dns is a custom lexicon; marque reads it, PDS needn't validate }, adminJwt, ) console.log(` wrote zone record (${added} entr${added === 1 ? 'y' : 'ies'} changed)`) } else { console.log(' no DNS changes needed') }
// --- summary --- console.log('\n=== NPC accounts (round 1 done) ===') for (const c of created) { console.log(` ${c.name.padEnd(14)} ${c.did} (temp: ${c.tempHandle} -> ${c.name}.${ZONE_DOMAIN})`) } console.log( '\nNext: provide an app password per account, then ROUND 2 updates each handle\n' + `to <name>.${ZONE_DOMAIN} and writes its actor.rpg.sprite/self + town.deception.npc/self.`, )}
main().catch((err) => { console.error('\nFATAL:', err instanceof Error ? err.message : err) process.exit(1)})