Files
polhunter/lib/badge.js
T
martbost c316dab8e6 Recruiter badge: the card for bringing someone who actually hunts
Matches the existing set at 1080x1080, same violet and gold, same laurel
wreath and floating Polygon coins, with a blank ribbon for the hunter's name.
Inside the wreath, four anonymous figures wired together as a team tree, one
at the top lighting up the three below it.

Awarded on a bounty rather than a match, so it means a person they brought
turned up and found their first code, not merely that an existing hunter
kept hunting. It shows locked on the board until then, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:10:00 -05:00

48 lines
3.5 KiB
JavaScript

// Badge cards: the AI artwork for each achievement (public/badges/badge-<id>.jpg, 1080x1080, a blank
// gold ribbon at ribbonY) with the hunter's name drawn onto the ribbon by ffmpeg on the server, so the
// card looks the same whatever device unlocked it (the IAP lesson: phones ran out of canvas memory).
// Rendered cards are cached on the volume: DATA_DIR/badges/<who>-<id>.jpg.
'use strict';
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
let PUBLIC_DIR = null, CACHE_DIR = null, FONT;
// per-badge ribbon position (fraction of height); tuned to the artwork
const ART = { first: 0.734, hunter: 0.753, tracker: 0.715, sweep: 0.689, lucky: 0.753, streak: 0.702, top: 0.725, recruiter: 0.834 };
function init(opts) { PUBLIC_DIR = opts.publicDir; CACHE_DIR = path.join(opts.dataDir, 'badges'); try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch (e) {} }
function artFile(id) { return path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'); }
function hasArt(id) { return fs.existsSync(artFile(id)); }
function font() {
if (FONT !== undefined) return FONT;
FONT = process.env.BADGE_FONT || null; if (FONT) return FONT;
const walk = d => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { return null; } for (const e of ents) { const f = path.join(d, e.name); if (e.isDirectory()) { const r = walk(f); if (r) return r; } else if (/bold\.ttf$/i.test(e.name) || /Bold\.ttf$/.test(e.name)) return f; } return null; };
for (const d of ['/usr/share/fonts', '/usr/local/share/fonts']) { const r = walk(d); if (r) { FONT = r; break; } }
if (!FONT && process.platform === 'win32' && fs.existsSync('C:/Windows/Fonts/arialbd.ttf')) FONT = 'C:/Windows/Fonts/arialbd.ttf';
return FONT;
}
const ffText = t => String(t).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\:').replace(/%/g, '%%');
const safe = s => String(s || '').toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 40);
// the card for one hunter + badge: cached JPEG path, or null when ffmpeg / font / art is missing
function render(id, who) {
return new Promise(resolve => {
if (!(id in ART) || !hasArt(id)) return resolve(null);
const F = font(); if (!F) return resolve(null);
const out = path.join(CACHE_DIR, safe(who) + '-' + id + '.jpg');
if (fs.existsSync(out)) return resolve(out);
const name = String(who || '').slice(0, 28);
const winPath = /:/.test(F); const fontFile = winPath ? path.basename(F) : F;
// 1080-wide art: 58px bold gold text with a dark outline, centred on the ribbon
const vf = 'drawtext=fontfile=' + fontFile + ":text='" + ffText(name) + "':fontcolor=#1a0f3d:fontsize=60:borderw=3:bordercolor=0xfff1c2@0.85:x=(w-text_w)/2:y=" + ART[id] + '*h-text_h/2';
const p = spawn('ffmpeg', ['-v', 'error', '-y', '-i', artFile(id), '-vf', vf, '-frames:v', '1', '-q:v', '3', out], { stdio: ['ignore', 'ignore', 'pipe'], cwd: winPath ? path.dirname(F) : undefined });
let err = ''; p.stderr.on('data', c => { err += c; });
p.on('error', () => resolve(null));
p.on('close', code => { if (code === 0 && fs.existsSync(out)) resolve(out); else { console.error('badge render failed', code, err.slice(0, 200)); resolve(null); } });
});
}
function available() { return new Promise(resolve => { const p = spawn('ffmpeg', ['-version'], { stdio: 'ignore' }); p.on('error', () => resolve(false)); p.on('close', c => resolve(c === 0 && !!font())); }); }
module.exports = { init, render, hasArt, available, safe, ART };