Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
8.5 kB · 202 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203// Upload RPG actor sprite sheets to a PDS as `actor.rpg.sprite` records, one per// PNG, keyed by the file's basename ("cow.png" -> rkey "cow"). Each PNG is a// standard directional walk sheet: a fixed grid of `columns × rows` frames// (default 3×4 — south/west/east/north, 3-step walk), matching the format the// renderer slices in render/entity.ts (`walkSheetClips`).//// The record shape matches the *published* actor.rpg.sprite lexicon (resolved// via DNS: _lexicon.rpg.actor -> did:plc:kwgllf365cwmxbnxitx4pjdj, schema at// https://rpg.actor). Required fields are `spriteSheet` + `createdAt`; the rest// (columns/rows/frames/frame*/width/height/animationSpeed) are optional hints.// width/height are read from the PNG header; frameWidth/Height are derived by// dividing the sheet by the grid (so off-grid sheets are rejected early).//// DELIBERATE DEVIATION: the lexicon declares `key: literal:self` (one record per// repo, rkey "self"). We key by filename instead to keep a multi-sprite library// (cow/pig/...). These records are therefore non-conformant — a PDS or reader// that strictly validates against this lexicon may reject or ignore them.//// Auth is an app password (Settings → App Passwords on your PDS), which is all a// one-off uploader needs — no OAuth dance. The password is prompted for on the// TTY (hidden); defaults target chris.pardy.family on pds.pardisio.xyz.//// tsx scripts/upload-sprites.ts scripts # prompts: app password for chris.pardy.family://// # overrides (env): PDS_HANDLE, PDS_SERVICE, PDS_PASSWORD (skips the prompt),// # SPRITE_COLS, SPRITE_ROWS, SPRITE_CELL_EIGHTHS// PDS_HANDLE=you.example.com tsx scripts/upload-sprites.ts ./sprites cow.png pig.png//// Pass a directory to upload every *.png in it, or a directory plus an explicit// list of filenames to upload just those.
import { readFile, readdir } from 'node:fs/promises'import { basename, extname, join } from 'node:path'import { createInterface } from 'node:readline'import { AtpAgent } from '@atproto/api'
const COLLECTION = 'actor.rpg.sprite'const DEFAULT_SERVICE = 'https://pds.pardisio.xyz'const DEFAULT_HANDLE = 'chris.pardy.family'
/** Prompt for a secret on the TTY without echoing keystrokes. */function promptPassword(query: string): Promise<string> { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }) let muted = false // rl.question writes the query synchronously here; muting afterwards hides // only the typed echo (no asterisks — sudo-style). ;(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}
/** 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) }}
/** Turn a sprite filename into a clean record key: "Cow_Sheet.png" -> "cow_sheet". */function rkeyFor(file: string): string { return basename(file, extname(file)) .toLowerCase() .replace(/[^a-z0-9._~-]/g, '-') // atproto rkey-safe charset}
async function main() { const dir = process.argv[2] if (!dir) { console.error('usage: tsx scripts/upload-sprites.ts <dir> [file.png ...]') process.exit(1) } const explicit = process.argv.slice(3) const files = explicit.length > 0 ? explicit : (await readdir(dir)).filter((f) => extname(f).toLowerCase() === '.png').sort() if (files.length === 0) { console.error(`no PNGs found in ${dir}`) process.exit(1) }
const cols = Number(process.env.SPRITE_COLS ?? 3) const rows = Number(process.env.SPRITE_ROWS ?? 4) // ms per frame on playback; the lexicon defaults to 200 if omitted. const animMs = process.env.SPRITE_ANIM_MS ? Number(process.env.SPRITE_ANIM_MS) : undefined
// Optional per-sprite size, expressed as a frame's height in MAP GRID CELLS, // stored as an integer count of EIGHTHS of a cell (atproto has no floats): 5 = // 5/8 = 0.625 cells. Base-8 (not percent) because pixel art and tile grids are // 8-aligned, so 1/8 of any 8-multiple cell is a whole number of px — the render // stays pixel-clean. Grid-relative rather than absolute px or a frame // multiplier, so a creature keeps the same footprint across maps with different // cell sizes and regardless of the source art's frame size. Consumers derive a // uniform scale = (cellHeightEighths/8 * mapCell.h) / frameHeight (absent -> // native size). Keyed by rkey. Sourced from SPRITE_CELL_EIGHTHS (inline JSON) // if set, else an optional `sprite-scales.json` sidecar in the target dir, e.g. // {"chicken":5,"cow":13}. NB: not a field in the actual actor.rpg.sprite // lexicon — a custom extra (see header). let cellEighths: Record<string, number> = {} if (process.env.SPRITE_CELL_EIGHTHS) { cellEighths = JSON.parse(process.env.SPRITE_CELL_EIGHTHS) } else { try { cellEighths = JSON.parse(await readFile(join(dir, 'sprite-scales.json'), 'utf8')) } catch { /* no sidecar — every sprite renders at native size */ } }
const identifier = env('PDS_HANDLE', DEFAULT_HANDLE) // Prefer an env password (non-interactive/CI), otherwise prompt on the TTY. 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}\nuploading ${files.length} sprite(s) to ${COLLECTION}/\n`)
let ok = 0 for (const file of files) { const rkey = rkeyFor(file) try { const bytes = new Uint8Array(await readFile(join(dir, file))) const { width, height } = pngSize(bytes) if (width % cols !== 0 || height % rows !== 0) { throw new Error( `sheet ${width}×${height} not divisible by grid ${cols}×${rows} ` + `(set SPRITE_COLS/SPRITE_ROWS to match this sheet)`, ) }
const { data: up } = await agent.com.atproto.repo.uploadBlob(bytes, { encoding: 'image/png', })
const record = { $type: COLLECTION, columns: cols, rows, frames: cols * rows, frameWidth: width / cols, frameHeight: height / rows, width, height, spriteSheet: up.blob, ...(animMs !== undefined ? { animationSpeed: animMs } : {}), ...(cellEighths[rkey] !== undefined ? { cellHeightEighths: cellEighths[rkey] } : {}), createdAt: new Date().toISOString(), }
const { data: put } = await agent.com.atproto.repo.putRecord({ repo: did, collection: COLLECTION, rkey, record, // We knowingly write under a non-self rkey, deviating from the published // actor.rpg.sprite lexicon (key: literal:self) — skip lexicon validation // so the PDS doesn't reject the record on schema/key grounds. validate: false, }) const sz = cellEighths[rkey] !== undefined ? `, ${cellEighths[rkey] / 8} cells tall` : '' console.log(` ✓ ${file} -> ${rkey} (${width}×${height}, ${cols * rows} frames${sz})`) console.log(` ${put.uri}`) ok++ } catch (err) { const msg = err instanceof Error ? err.message : String(err) console.error(` ✗ ${file} -> ${rkey}: ${msg}`) } }
console.log(`\ndone: ${ok}/${files.length} uploaded`) process.exit(ok === files.length ? 0 : 1)}
main().catch((err) => { console.error(err) process.exit(1)})