Badge cards: seven AI artworks for the hunting achievements, the hunter's name stamped on the ribbon server-side (ffmpeg), public share pages /b/<who>/<id> with OG cards, badge strip + share modal + unlock celebration on the board, Telegram photo post gated by OUTBOUND

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 20:31:20 -05:00
parent 6338af9efe
commit 8eecc7c0cb
19 changed files with 156 additions and 14 deletions
+47
View File
@@ -0,0 +1,47 @@
// 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 };
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 };
+9 -1
View File
@@ -105,6 +105,14 @@ function rankOf(memberId, period) {
// the member's share link: PolHunter's landing with their IAP referral, which the landing turns into
// their instantadpay.com/join/<ref> link for everyone who signs up from it
// the public name used in share URLs: the IAP username, else m<memberId>; and back again
function nameOf(me) { return me.username ? String(me.username).toLowerCase() : 'm' + me.memberId; }
function memberByName(who) {
const w = String(who || '').toLowerCase(); if (!w) return null;
const all = store.read('payouts', []);
const p = all.find(x => (x.username && String(x.username).toLowerCase() === w) || 'm' + x.memberId === w);
return p ? { memberId: p.memberId, username: p.username || null, who: p.username ? '@' + p.username : '#' + p.memberId } : null;
}
function shareLink(site, me) { const ref = me.username || me.memberId; return site + '/?r=' + encodeURIComponent(String(ref)); }
module.exports = { BADGES, badgesFor, leaderboard, rankOf, shareLink, weekOf, weekRows, awardWeek, awardDue, prizes, prizeRules };
module.exports = { BADGES, badgesFor, leaderboard, rankOf, shareLink, weekOf, weekRows, awardWeek, awardDue, prizes, prizeRules, nameOf, memberByName };