openvibers/openvibe.live Commits

Arena: yap level-up coins are marked paid only when the wallet confirms; housekeeping retries unpaid levels

OP OpenVibers committed 30 Aug 2026 · 131eb574ed · 3 files, +27 −9

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/server/arena/arena-job.js b/server/arena/arena-job.js
iindex 5b316127..7dde1c11 100644
---- a/server/arena/arena-job.js
++++ b/server/arena/arena-job.js
@@@ -70,6 +70,7 @@ async function housekeeping() {
try { await board.discoverTopics(); } catch (e) { console.warn('[Arena] discover:', e.message); } // every 5 min, only with new material
try { await board.loreSweep(3); } catch (e) { console.warn('[Arena] lore:', e.message); } // only topics with ≥ 3 new moments
try { await require('./chatters').cardSweep(); } catch (e) { console.warn('[Arena] yap cards:', e.message); } // ≤ 4 chatters per minute, level ≥ 3, once a day each
++ try { await require('./chatters').settleCoins(); } catch (e) { console.warn('[Arena] yap coins:', e.message); } // retry level-up coins the wallet hasn't confirmed
}
function stop() {
ddiff --git a/server/arena/chatters.js b/server/arena/chatters.js
iindex b9af2959..2e72de7f 100644
---- a/server/arena/chatters.js
++++ b/server/arena/chatters.js
@@@ -161,13 +161,7 @@ function onLevelUp(key, from, to, { streamId = null } = {}) {
const paidTo = r?.coins_paid_level || 0;
let total = 0;
for (let l = Math.max(paidTo + 1, from + 1); l <= to; l++) total += l * COINS_PER_LEVEL;
-- if (total > 0) {
-- const opencoins = require('../monetization/opencoins');
-- Promise.resolve(opencoins.credit(p.user_id, total, 'arena_yap_level', `live:arena_yap_level:${p.user_id}:${to}`, { level: to })).then(res => {
-- if (res) console.log(`[Arena] ${name}: +${total} OpenCoins for reaching yap level ${to}`);
-- }).catch(e => console.warn('[Arena] yap coins:', e.message));
-- db.run('UPDATE chatter_profiles SET coins_paid_level = ? WHERE key = ?', [to, key]);
-- }
++ if (total > 0) payCoins(key, p.user_id, name, total, to);
} catch (e) { console.warn('[Arena] yap coins:', e.message); }
}
// Tell the room.
@@@ -179,6 +173,27 @@ function onLevelUp(key, from, to, { streamId = null } = {}) {
}
/** A chat line landed as a moment on a subject. */
++/** Credit level-up coins; only marked paid once the wallet confirms (idempotent per level, so retries are safe). */
++function payCoins(key, userId, name, total, level) {
++ try {
++ const opencoins = require('../monetization/opencoins');
++ return Promise.resolve(opencoins.credit(userId, total, 'arena_yap_level', `live:arena_yap_level:${userId}:${level}`, { level })).then(res => {
++ if (res) { db.run('UPDATE chatter_profiles SET coins_paid_level = ? WHERE key = ?', [level, key]); console.log(`[Arena] ${name}: +${total} OpenCoins for reaching yap level ${level}`); }
++ return !!res;
++ }).catch(e => { console.warn('[Arena] yap coins:', e.message); return false; });
++ } catch (e) { console.warn('[Arena] yap coins:', e.message); return Promise.resolve(false); }
++}
++/** Housekeeping: retry unpaid level-ups (wallet down, account linked later). A few per call. */
++async function settleCoins(max = 5) {
++ ensureTables();
++ let n = 0;
++ for (const r of db.all(`SELECT key, user_id, display_name, level, coins_paid_level FROM chatter_profiles WHERE kind = 'user' AND user_id IS NOT NULL AND level > coins_paid_level ORDER BY last_seen DESC LIMIT ?`, [max])) {
++ let total = 0; for (let l = (r.coins_paid_level || 0) + 1; l <= r.level; l++) total += l * COINS_PER_LEVEL;
++ if (total > 0 && await payCoins(r.key, r.user_id, r.display_name || r.key, total, r.level)) n++;
++ }
++ return n;
++}
++
function onMoment(moment, topic, { hot = false } = {}) {
if (!moment || moment.kind !== 'chat') return null;
const key = moment.chatter_key || keyFor(moment);
@@@ -327,4 +342,4 @@ function leaderboard(limit = 10, { days = null } = {}) {
}
function count() { ensureTables(); return db.get('SELECT COUNT(*) AS n FROM chatter_profiles WHERE xp > 0')?.n || 0; }
--module.exports = { ensureTables, keyFor, parseKey, displayFor, levelFor, xpForLevel, titleFor, addXp, onMoment, onSubjectStarted, onHype, onLore, buildCard, cardSweep, profile, leaderboard, count, view, row, TITLES, XP_MOMENT, XP_MOMENT_HOT, XP_FIRST_ON_SUBJECT, XP_SUBJECT_STARTED, XP_QUOTED_IN_LORE, COINS_PER_LEVEL, CARD_MIN_LEVEL };
++module.exports = { ensureTables, settleCoins, payCoins, keyFor, parseKey, displayFor, levelFor, xpForLevel, titleFor, addXp, onMoment, onSubjectStarted, onHype, onLore, buildCard, cardSweep, profile, leaderboard, count, view, row, TITLES, XP_MOMENT, XP_MOMENT_HOT, XP_FIRST_ON_SUBJECT, XP_SUBJECT_STARTED, XP_QUOTED_IN_LORE, COINS_PER_LEVEL, CARD_MIN_LEVEL };
ddiff --git a/test/arena-chatters.test.js b/test/arena-chatters.test.js
iindex ec823d83..e63a128e 100644
---- a/test/arena-chatters.test.js
++++ b/test/arena-chatters.test.js
@@@ -52,7 +52,9 @@ console.log('✅ moments → XP for accounts, anon and relay chatters');
// Level up: force XP over the level-3 line; coins are attempted once per level (wallet is unlinked here → no-op, but marked paid).
const r = ch.addXp(`user:${uid}`, 200, 'test');
assert.ok(r.leveled_up && r.level >= 3, JSON.stringify(r));
-- assert.strictEqual(ch.row(`user:${uid}`).coins_paid_level, r.level, 'coins paid up to the new level (idempotent)');
++ await new Promise(res => setTimeout(res, 20));
++ assert.strictEqual(ch.row(`user:${uid}`).coins_paid_level, 0, 'wallet unlinked here → not marked paid (housekeeping retries)');
++ assert.strictEqual(await ch.settleCoins(), 0, 'retry is safe when the wallet cannot credit');
const r2 = ch.addXp(`user:${uid}`, 1, 'test'); assert.strictEqual(r2.leveled_up, false);
const lb = ch.leaderboard(5);
assert.strictEqual(lb[0].key, `user:${uid}`); assert.ok(lb.length === 3 && lb[0].title === ch.titleFor(lb[0].level));