From 8eecc7c0cbe0e83cf3e5c112314f2aba361181fb Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 19 Sep 2026 20:31:20 -0500 Subject: [PATCH] Badge cards: seven AI artworks for the hunting achievements, the hunter's name stamped on the ribbon server-side (ffmpeg), public share pages /b// 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 --- Dockerfile | 2 ++ lib/badge.js | 47 ++++++++++++++++++++++++++++++++ lib/social.js | 10 ++++++- public/admin.html | 2 +- public/app.html | 11 ++++++-- public/app.js | 21 ++++++++++++++ public/badges/badge-first.jpg | Bin 0 -> 170552 bytes public/badges/badge-hunter.jpg | Bin 0 -> 196710 bytes public/badges/badge-lucky.jpg | Bin 0 -> 185219 bytes public/badges/badge-streak.jpg | Bin 0 -> 182480 bytes public/badges/badge-sweep.jpg | Bin 0 -> 181535 bytes public/badges/badge-top.jpg | Bin 0 -> 228338 bytes public/badges/badge-tracker.jpg | Bin 0 -> 161974 bytes public/index.html | 2 +- public/leaders.html | 6 ++-- public/promo.html | 2 +- public/style.css | 11 ++++++++ server.js | 44 ++++++++++++++++++++++++++++-- test/run.js | 12 ++++++-- 19 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 lib/badge.js create mode 100644 public/badges/badge-first.jpg create mode 100644 public/badges/badge-hunter.jpg create mode 100644 public/badges/badge-lucky.jpg create mode 100644 public/badges/badge-streak.jpg create mode 100644 public/badges/badge-sweep.jpg create mode 100644 public/badges/badge-top.jpg create mode 100644 public/badges/badge-tracker.jpg diff --git a/Dockerfile b/Dockerfile index 4dc953c..217bc94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM node:22-alpine WORKDIR /app +# badge cards: the hunter's name is drawn onto the artwork with ffmpeg +RUN apk add --no-cache ffmpeg ttf-dejavu COPY package.json ./ RUN npm install --omit=dev --no-audit --no-fund COPY . . diff --git a/lib/badge.js b/lib/badge.js new file mode 100644 index 0000000..08ac67f --- /dev/null +++ b/lib/badge.js @@ -0,0 +1,47 @@ +// Badge cards: the AI artwork for each achievement (public/badges/badge-.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/-.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 }; diff --git a/lib/social.js b/lib/social.js index d5ecba1..b9c7c95 100644 --- a/lib/social.js +++ b/lib/social.js @@ -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/ link for everyone who signs up from it +// the public name used in share URLs: the IAP username, else m; 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 }; diff --git a/public/admin.html b/public/admin.html index f3fde6b..8ac8613 100644 --- a/public/admin.html +++ b/public/admin.html @@ -5,7 +5,7 @@ Admin · PolHunter - +