Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
9.4 kB · 245 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246// The thrift-shop easter egg (PLAN: an item swap with a central account). A logged-in player can// DONATE one of their equipment.rpg.item records to the shop (it lands in the shop's repo and is// deleted from theirs) or WITHDRAW one from the shop (it lands in their repo and is deleted from the// shop's). Both directions are one primitive — the rpg.actor "provider grants item to recipient" hop:// the GIVER writes the equipment.rpg.give attestation + deletes their item; the RECEIVER writes the// equipment.rpg.item. We can only write to a repo we hold auth for, so the player side uses their// OAuth session (agentForDid) and the shop side uses an app-password AtpAgent.//// Every give + item record we write is validated against the vendored equipment.rpg.* lexicons// (lexgen) BEFORE it is written, and the giver's copy is deleted only AFTER the receiver's copy is// safely created — so a crash duplicates an item (recoverable) rather than losing it.
import { AtpAgent, type Agent } from '@atproto/api'import { BlobRef } from '@atproto/lexicon'import { validateRecord as validateItem, type Record as ItemRecord } from '../../lexgen/types/equipment/rpg/item.js'import { validateRecord as validateGive, type Record as GiveRecord } from '../../lexgen/types/equipment/rpg/give.js'import { getRecord, fetchBlob, resolvePds } from './atproto.js'
export const ITEM_NSID = 'equipment.rpg.item'export const GIVE_NSID = 'equipment.rpg.give'
// --- shop account (app-password) --------------------------------------------
const SHOP_HANDLE = process.env.SABOTAGE_SHOP_HANDLE?.trim()const SHOP_PASSWORD = process.env.SABOTAGE_SHOP_PASSWORDconst SHOP_PDS = (process.env.SABOTAGE_SHOP_PDS?.trim() || 'https://bsky.social').replace(/\/$/, '')
let shopPromise: Promise<ShopSession> | null = null
export interface ShopSession { agent: AtpAgent did: string}
/** True when the shop's app-password credentials are configured (else the routes 503). */export function shopConfigured(): boolean { return Boolean(SHOP_HANDLE && SHOP_PASSWORD)}
/** The logged-in shop AtpAgent + its DID, cached for the process lifetime. */export function getShop(): Promise<ShopSession> { if (!shopConfigured()) { return Promise.reject(new ShopError(503, 'shop-unconfigured', 'set SABOTAGE_SHOP_HANDLE + SABOTAGE_SHOP_PASSWORD')) } if (!shopPromise) { shopPromise = (async () => { const agent = new AtpAgent({ service: SHOP_PDS }) await agent.login({ identifier: SHOP_HANDLE!, password: SHOP_PASSWORD! }) const did = agent.session!.did console.log(`[shop] logged in as ${SHOP_HANDLE} (${did})`) return { agent, did } })().catch((err) => { shopPromise = null // allow a later retry rather than wedging on a transient login failure throw err }) } return shopPromise}
// --- errors -----------------------------------------------------------------
export class ShopError extends Error { constructor( readonly status: number, readonly code: string, message: string, ) { super(message) }}
// --- a repo we can write to (player OAuth session or the shop agent) ---------
export interface Party { did: string agent: Agent}
// --- listing ----------------------------------------------------------------
export interface ItemSummary { uri: string rkey: string did: string item: string title: string description?: string /** Public blob URL for the icon (falls back to the asset) on this repo's PDS, or null. */ iconUrl: string | null}
/** List a repo's equipment.rpg.item records as display summaries (icon URLs resolved to its PDS). */export async function listInventory(repo: string, agent: Agent): Promise<ItemSummary[]> { const pdsBase = (await resolvePds(repo))?.replace(/\/$/, '') ?? null const out: ItemSummary[] = [] let cursor: string | undefined do { const res = await agent.com.atproto.repo.listRecords({ repo, collection: ITEM_NSID, limit: 100, cursor }) for (const rec of res.data.records) { const v = rec.value as Record<string, unknown> const rkey = rec.uri.split('/').pop() ?? '' const iconCid = blobCid(v.icon) ?? blobCid(v.asset) out.push({ uri: rec.uri, rkey, did: repo, item: str(v.item) ?? rkey, title: str(v.title) ?? str(v.item) ?? 'Untitled', description: str(v.description), iconUrl: iconCid && pdsBase ? `${pdsBase}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(repo)}&cid=${encodeURIComponent(iconCid)}` : null, }) } cursor = res.data.cursor } while (cursor) return out}
// --- the transfer engine ----------------------------------------------------
/** * Move one item from `giver` to `receiver`. Create-before-delete; both written records are * lexicon-validated first. Returns the new item + give AT-URIs. */export async function transfer( giver: Party, receiver: Party, sourceRkey: string,): Promise<{ itemUri: string; giveUri: string }> { // 1. Read the source item from the giver's repo and guard its shape. const src = await getRecord(giver.did, ITEM_NSID, sourceRkey) if (!src) throw new ShopError(404, 'item-not-found', `no ${ITEM_NSID}/${sourceRkey} on ${giver.did}`) const item = str(src.item) const title = str(src.title) if (!item || !title) throw new ShopError(422, 'item-malformed', 'source item is missing item/title')
// 2. Re-upload its blobs into the RECEIVER's repo (blobs can't be referenced across repos). const asset = await reuploadBlob(giver.did, src.asset, receiver.agent) const icon = await reuploadBlob(giver.did, src.icon, receiver.agent) const assetCid = asset?.ref.toString() ?? icon?.ref.toString() const iconCid = icon?.ref.toString()
const now = new Date().toISOString() const carried = { ...(str(src.kind) ? { kind: str(src.kind)! } : {}), ...(str(src.category) ? { category: str(src.category)! } : {}), ...(str(src.context) ? { context: str(src.context)! } : {}), ...(str(src.description) ? { description: str(src.description)! } : {}), }
// 3. Build + validate the give (giver attests) and the new item (receiver owns). const giveRecord: GiveRecord = { $type: GIVE_NSID, recipient: receiver.did, item, title, ...carried, ...(assetCid ? { assetCid } : {}), ...(iconCid ? { iconCid } : {}), givenAt: now, } assertValid('give', validateGive(giveRecord))
// 4. The giver writes the give to their own repo (mint the rkey we'll share with the item). const giveRes = await giver.agent.com.atproto.repo.createRecord({ repo: giver.did, collection: GIVE_NSID, record: giveRecord, validate: false, // we validated above; don't depend on PDS-side lexicon resolution }) const giveUri = giveRes.data.uri const rkey = giveUri.split('/').pop()!
const itemRecord: ItemRecord = { $type: ITEM_NSID, item, title, give: giveUri, provider: giver.did, acceptedAt: now, ...carried, ...(asset ? { asset } : {}), ...(icon ? { icon } : {}), ...(assetCid ? { assetCid } : {}), } assertValid('item', validateItem(itemRecord))
// 5. The receiver writes the item (shared rkey with the give, per rpg.actor convention). const itemRes = await receiver.agent.com.atproto.repo.createRecord({ repo: receiver.did, collection: ITEM_NSID, rkey, record: itemRecord, validate: false, })
// 6. Delete the giver's original — last and best-effort. A failure here merely duplicates the item. try { await giver.agent.com.atproto.repo.deleteRecord({ repo: giver.did, collection: ITEM_NSID, rkey: sourceRkey }) } catch (err) { console.warn( `[shop] could not delete source ${ITEM_NSID}/${sourceRkey} from ${giver.did} (item duplicated):`, err instanceof Error ? err.message : err, ) }
return { itemUri: itemRes.data.uri, giveUri }}
// --- helpers ----------------------------------------------------------------
type ValidationResult = { success: boolean; error?: unknown }
function assertValid(kind: 'give' | 'item', res: ValidationResult): void { if (!res.success) { const detail = res.error instanceof Error ? res.error.message : String(res.error) throw new ShopError(422, `${kind}-invalid`, `${kind} record failed lexicon validation: ${detail}`) }}
/** Pull a raw record blob's bytes from `sourceDid` and re-upload them into `agent`'s repo. */async function reuploadBlob(sourceDid: string, blob: unknown, agent: Agent): Promise<BlobRef | null> { const cid = blobCid(blob) if (!cid) return null const bytes = await fetchBlob(sourceDid, cid) const mime = (blob as { mimeType?: string })?.mimeType || 'image/png' const up = await agent.com.atproto.repo.uploadBlob(bytes, { encoding: mime }) return up.data.blob}
/** The CID string of a raw-JSON blob ref (`{$type:'blob', ref:{$link}}`), or null. */function blobCid(blob: unknown): string | null { if (!blob || typeof blob !== 'object') return null const ref = (blob as { ref?: unknown }).ref if (ref && typeof ref === 'object' && typeof (ref as { $link?: unknown }).$link === 'string') { return (ref as { $link: string }).$link } return null}
function str(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined}