diff --git a/package.json b/package.json index 68175ae..cd591e1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "bin": { "buddy-statusline": "dist/statusline-wrapper.js", "buddy-onboard": "dist/cli/onboard.js", - "buddy-doctor": "dist/cli/doctor-cli.js" + "buddy-doctor": "dist/cli/doctor-cli.js", + "buddy-snapshot": "dist/cli/snapshot-cli.js" }, "files": [ "dist", diff --git a/src/cli/snapshot-cli.ts b/src/cli/snapshot-cli.ts new file mode 100644 index 0000000..f003223 --- /dev/null +++ b/src/cli/snapshot-cli.ts @@ -0,0 +1,52 @@ +import { initDb, db } from '../db/schema.js'; +import { loadCompanion } from '../lib/companion.js'; +import { captureSnapshot } from '../lib/snapshot.js'; +import { join } from 'path'; +import { parseArgs } from 'util'; + +function usage(): never { + console.log(`Usage: buddy-snapshot [options] + +Options: + -o, --output Output PNG path (default: ./buddy_snapshot.png) + -m, --message Speech bubble message + --stat Delta stat name (e.g. WISDOM) + --points Delta points (default: 0) + -h, --help Show this help`); + process.exit(0); +} + +async function main() { + const { values } = parseArgs({ + options: { + output: { type: 'string', short: 'o' }, + message: { type: 'string', short: 'm' }, + stat: { type: 'string' }, + points: { type: 'string' }, + help: { type: 'boolean', short: 'h' }, + }, + strict: true, + }); + + if (values.help) usage(); + + initDb(); + const row = db.prepare("SELECT * FROM companions LIMIT 1").get() as any; + if (!row) { + console.error("No buddy found. Hatch one first!"); + process.exit(1); + } + + const companion = loadCompanion(row)!; + const outPath = values.output || join(process.cwd(), 'buddy_snapshot.png'); + const delta = values.stat ? { stat: values.stat, points: parseInt(values.points || '0') } : undefined; + + console.log(`Generating snapshot for ${companion.name}...`); + await captureSnapshot(companion, outPath, values.message, delta); + console.log(`Snapshot saved to: ${outPath}`); +} + +main().catch(err => { + console.error("Failed to generate snapshot:", err); + process.exit(1); +}); diff --git a/src/lib/share.ts b/src/lib/share.ts new file mode 100644 index 0000000..e6e7c1e --- /dev/null +++ b/src/lib/share.ts @@ -0,0 +1,309 @@ +import { type Companion, STAT_NAMES, RARITY_STARS } from './types.js'; +import { levelProgress } from './leveling.js'; + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export type ShareDelta = { + stat: string; + points: number; +}; + +export function renderShareHtml(companion: Companion, message?: string, delta?: ShareDelta): string { + const stars = RARITY_STARS[companion.rarity]; + const { level, currentXp, neededXp } = levelProgress(companion.xp); + + const statsHtml = STAT_NAMES.map(s => { + const isDelta = delta && delta.stat.toUpperCase() === s; + const baseValue = isDelta ? Math.max(0, companion.stats[s] - delta.points) : companion.stats[s]; + const displayValue = companion.stats[s]; + + return ` +
+ ${s} +
+
+ ${isDelta ? `
` : ''} +
+
+ ${isDelta ? `+${delta.points}` : ''} + ${displayValue} +
+
+ `; + }).join(''); + + const bubbleHtml = message ? ` +
+
+ ${escapeHtml(message)} +
+
+
+ ` : ''; + + return ` + + + + + + +
+
+
${stars} ${companion.rarity}
+
${companion.species}
+
+ +
+
+
RENDER_SPRITE_HERE
+
+ ${bubbleHtml} +
+ +
+

${companion.name}

+
"${companion.personalityBio}"
+
+ +
+ ${statsHtml} +
+ + + + +
+ + + `; +} diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts new file mode 100644 index 0000000..9cde4bf --- /dev/null +++ b/src/lib/snapshot.ts @@ -0,0 +1,33 @@ +import puppeteer from 'puppeteer'; +import { type Companion } from './types.js'; +import { renderShareHtml, type ShareDelta } from './share.js'; +import { renderSprite } from './species.js'; + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +export async function captureSnapshot(companion: Companion, outPath: string, message?: string, delta?: ShareDelta) { + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + try { + const page = await browser.newPage(); + await page.setViewport({ width: 600, height: 900, deviceScaleFactor: 2 }); + + let html = renderShareHtml(companion, message, delta); + + const spriteLines = renderSprite(companion); + const spriteHtml = escapeHtml(spriteLines.join('\n')); + html = html.replace('RENDER_SPRITE_HERE', spriteHtml); + + await page.setContent(html); + await page.evaluateHandle(() => document.fonts.ready); + + await page.screenshot({ path: outPath, fullPage: false }); + } finally { + await browser.close(); + } +}