Arcade credits and games, account deletion, password reset, feed and map

- Credits are booked from what the database records (merged functions,
  peer-confirmed ones, accepted no-decompile claims, submissions that went
  on, reviews that agreed with the outcome), once each; play money for
  coin flip, dice and slots (server-side CSPRNG, session only).
- Admins delete accounts (kept as deleted_<id> when they have work) and
  reset passwords.
- /api/feed (public events) and /api/map (every function by address,
  one state letter each) for the main page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Saty 2026-10-20 02:44:18 +02:00
commit e246671873
4 changed files with 366 additions and 1 deletions

View file

@ -264,6 +264,52 @@ export class Accounts {
});
}
/**
* Deletes an account. One with work on record (submissions, reviews, symbols) stays as
* "deleted_<id>" so the history keeps its author; one without is removed. Either way it can no
* longer log in and its claims are released.
*/
deleteUser(name, adminId) {
return this.store.tx(() => {
const u = this.requireUser(name);
if (u.role === 'admin' && u.status === 'active' && this.activeAdmins() <= 1) throw new AccountError(409, 'refusing to delete the last active admin');
if (u.id === adminId) throw new AccountError(409, 'you cannot delete your own account here');
const has = (sql) => !!this.store.get(sql, u.id);
const work =
has('SELECT 1 FROM attempts WHERE user_id = ? LIMIT 1') ||
has('SELECT 1 FROM batches WHERE user_id = ? LIMIT 1') ||
has('SELECT 1 FROM peer_reviews WHERE reviewer_id = ? LIMIT 1') ||
has('SELECT 1 FROM reviews WHERE reviewer_id = ? LIMIT 1') ||
has('SELECT 1 FROM symbols WHERE created_by = ? LIMIT 1');
this.store.run(
`UPDATE tasks SET state = CASE WHEN best_attempt IS NULL THEN 'open' ELSE 'needs_cleanup' END, lease_user = NULL, lease_expires = NULL, updated_at = ? WHERE lease_user = ? AND state = 'leased'`,
now(), u.id,
);
for (const t of ['sessions', 'api_tokens', 'region_claims']) this.store.run(`DELETE FROM ${t} WHERE user_id = ?`, u.id);
this.store.run('DELETE FROM peer_leases WHERE reviewer_id = ?', u.id);
if (work) {
const newName = `deleted_${u.id}`;
this.store.run(`UPDATE users SET name = ?, status = 'revoked', password_hash = NULL, revoked_at = ? WHERE id = ?`, newName, now(), u.id);
this.store.event('user_deleted', adminId, { name: u.name, kept: newName });
return { name: u.name, deleted: true, keptAs: newName };
}
for (const t of ['credit_ledger', 'game_rounds']) this.store.run(`DELETE FROM ${t} WHERE user_id = ?`, u.id);
this.store.run('UPDATE events SET user_id = NULL WHERE user_id = ?', u.id);
this.store.run('UPDATE users SET approved_by = NULL WHERE approved_by = ?', u.id);
this.store.run('DELETE FROM users WHERE id = ?', u.id);
this.store.event('user_deleted', adminId, { name: u.name });
return { name: u.name, deleted: true, keptAs: null };
});
}
/** A new random password for an account (shown once to the admin); its sessions end. */
async resetPassword(name, adminId) {
const pw = crypto.randomBytes(12).toString('base64url');
await this.setPassword(name, pw);
this.store.event('password_reset', adminId, { name });
return { name, password: pw };
}
activeAdmins() {
return this.store.get(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND status = 'active'`).n;
}

View file

@ -22,6 +22,7 @@ import { reviewerStats } from './reviewers.js';
import { inferUnits } from './grouping.js';
import { Router, HttpError, readJson, sendJson, RateLimiter } from './http.js';
import { Regions } from './regions.js';
import { Credits, SLOT_TABLE } from './credits.js';
import { PEImage } from '../shared/pe.js';
import { loadProject, loadToolchainLock } from '../shared/project.js';
import { DEFAULT_LIMITS, lintSubmission } from '../shared/lint.js';
@ -91,7 +92,8 @@ export function openServer(dataDir, { log = console } = {}) {
const ghidra = new ServerGhidra({ config, store, originals, log });
merger.onMerged = (commit) => ghidra.scheduleRefresh(commit);
const checks = new CheckQueue({ store, repo, verifier, config, log });
return { config, store, repo, project, tasks, regions, verifier, merger, getPe, accounts, peers, originals, ghidra, checks };
const credits = new Credits({ store });
return { config, store, repo, project, tasks, regions, credits, verifier, merger, getPe, accounts, peers, originals, ghidra, checks };
}
export function createApp(ctx) {
@ -524,6 +526,96 @@ export function createApp(ctx) {
return { ...tasks.heartbeatAll(user), regions: ctx.regions.heartbeat(user) };
});
// --- the arcade: game credits (credits.js). Play money; humans play, from the website.
router.on('GET', '/api/credits/me', ({ req }) => {
const user = auth(req);
return { ...ctx.credits.wallet(user.id), slots: SLOT_TABLE };
});
router.on('POST', '/api/credits/bailout', ({ req }) => {
const user = auth(req, { session: true });
return ctx.credits.bailout(user.id);
});
router.on('GET', '/api/credits/leaderboard', () => cached('credits-board', 20_000, () => ctx.credits.leaderboard()));
const gameLimiter = new RateLimiter(600, 600_000);
router.on('POST', '/api/games/:game', async ({ req, params }) => {
const user = auth(req, { session: true });
gameLimiter.check(user.id);
const body = await readJson(req, 4096);
return ctx.credits.play(user.id, String(params.game), body);
});
// --- the main page: a live feed of what happens, and a map of every function
const FEED_TYPES = ['submit', 'merged', 'auto_accepted', 'region_claim', 'game_win', 'user_approved', 'no_decompile_claim', 'skip_accepted'];
router.on('GET', '/api/feed', ({ query }) => {
const after = Number(query.get('after') ?? 0) || 0;
// The first view is cached; later polls ask for what came after (a primary key range).
const load = () => {
const rows = store.all(
`SELECT e.id, e.at, e.type, e.data, u.name FROM events e LEFT JOIN users u ON u.id = e.user_id
WHERE e.id > ? AND e.type IN (${FEED_TYPES.map(() => '?').join(',')}) ORDER BY e.id DESC LIMIT 40`,
after, ...FEED_TYPES,
);
// Only the fields a public feed needs, never notes or code.
const pick = (type, d) => {
if (type === 'submit') return { functions: d.tasks };
if (type === 'merged') return { functions: d.functions, commit: String(d.commit ?? '').slice(0, 10) };
if (type === 'region_claim') return { key: d.key, kind: d.kind, functions: d.leased, shared: d.shared };
if (type === 'game_win') return { game: d.game, bet: d.bet, payout: d.payout };
if (type === 'no_decompile_claim') return { functions: d.functions };
if (type === 'auto_accepted') return {};
return {};
};
return rows.map((r) => ({ id: r.id, at: r.at, type: r.type, user: r.name ?? null, ...pick(r.type, JSON.parse(r.data ?? '{}')) }));
};
return after > 0 ? load() : cached('feed', 5_000, load);
});
// Every function in address order, one letter each: d byte-identical, c peer-confirmed, s needs no
// decompilation, f on its way to main, l claimed, b sent back, o open; plus its source file.
const mapData = () =>
cached('map', 60_000, () => {
const v = store.currentVersion();
if (!v) return { functions: 0, states: '', units: [], unitOf: [], ids: [] };
const rows = store.all(
`SELECT t.symbol_id, t.state, s.unit, a.size, fs.status, fs.confirmed FROM tasks t JOIN symbols s ON s.id = t.symbol_id
JOIN symbol_addresses a ON a.symbol_id = s.id AND a.version_id = ?
LEFT JOIN func_status fs ON fs.symbol_id = s.id ORDER BY a.addr`,
v.id,
);
const code = (r) => {
if (r.state === 'skipped') return 's';
if (r.state === 'done') return r.status === 'exact' || r.status === 'match_unresolved' ? 'd' : 'c';
if (['verifying', 'peer_review', 'in_review', 'approved', 'merging'].includes(r.state)) return 'f';
if (r.state === 'leased') return 'l';
if (r.state === 'needs_cleanup' || r.state === 'hard') return 'b';
return 'o';
};
const units = [];
const index = new Map();
const unitOf = rows.map((r) => {
const u = r.unit ?? '(not placed yet)';
if (!index.has(u)) index.set(u, units.push(u) - 1);
return index.get(u);
});
return { functions: rows.length, states: rows.map(code).join(''), units, unitOf, ids: rows.map((r) => r.symbol_id), sizes: rows.map((r) => r.size ?? 0) };
});
router.on('GET', '/api/map', () => {
const m = mapData();
return { functions: m.functions, states: m.states, units: m.units, unitOf: m.unitOf };
});
router.on('GET', '/api/map/:i', ({ params }) => {
const m = mapData();
const i = intParam(params.i);
if (i < 0 || i >= m.functions) throw new HttpError(404, 'no such function');
const s = store.get(
`SELECT s.id, s.name, s.unit, t.state, u.name AS holder FROM symbols s JOIN tasks t ON t.symbol_id = s.id LEFT JOIN users u ON u.id = t.lease_user AND t.state = 'leased' WHERE s.id = ?`,
m.ids[i],
);
return { index: i, symbol: s?.id, name: s?.name ?? null, unit: s?.unit ?? null, state: s?.state, holder: s?.holder ?? null, size: m.sizes[i] };
});
// --- regions: a source file or class, claimed and worked on as a whole (regions.js)
router.on('GET', '/api/regions', ({ req, query }) => {
@ -853,6 +945,8 @@ export function createApp(ctx) {
// Pause or allow an account's peer reviews (e.g. rubber-stamping); its existing verdicts stay.
'block-reviews': (name, admin) => setReviewBlock(name, 1, admin),
'allow-reviews': (name, admin) => setReviewBlock(name, 0, admin),
delete: (name, admin) => accounts.deleteUser(name, admin.id),
'reset-password': (name, admin) => accounts.resetPassword(name, admin.id),
};
const setReviewBlock = (name, blocked, admin) => {
const u = accounts.byName(name);
@ -1006,6 +1100,8 @@ export async function startServer(dataDir, { role = 'all' } = {}) {
let server = null;
if (role !== 'worker') {
const sweeper = setInterval(() => { try { ctx.tasks.sweepExpired(); ctx.regions.sweepExpired(); } catch (e) { console.error(`lease sweep: ${e.message}`); } }, 60_000);
const booker = setInterval(() => { try { ctx.credits.sync(); } catch (e) { console.error(`credits: ${e.message}`); } }, 30_000);
stops.push(() => clearInterval(booker));
stops.push(() => clearInterval(sweeper));
server = createApp(ctx);
await new Promise((r) => server.listen(ctx.config.listen.port, ctx.config.listen.host, r));

166
src/server/credits.js Normal file
View file

@ -0,0 +1,166 @@
// Game credits: play money for the arcade, earned with work that reached main and reviews that
// agreed with the outcome. They have no value outside the site.
//
// Earnings are derived from what the database already records (merged attempts, decided peer
// reviews, accepted "no decompile" claims), so nothing in the pipeline has to remember to pay: a
// sync every half minute books whatever is new, once per (user, reason, ref).
import crypto from 'node:crypto';
import { HttpError } from './http.js';
import { now } from './db.js';
export const AWARDS = {
identical: 10, // a function merged byte-identical
confirmed: 6, // a function merged after five peers confirmed its logic
skip: 3, // an accepted "no decompile" claim
submitted: 1, // a submitted function that compiled and went on (peer review or approved)
review: 2, // a peer verdict that agreed with how the function was decided
};
const MAX_BET = 1000;
const BAILOUT = 25; // once a day for anyone below 5 credits
// Slots: reel symbols with weights out of 100, paying for three of a kind (and two gears).
const REEL = [
{ s: '⚙️', w: 30, three: 10 },
{ s: '🔩', w: 25, three: 15 },
{ s: '💾', w: 18, three: 25 },
{ s: '🐞', w: 15, three: 30 },
{ s: '👾', w: 8, three: 100 },
{ s: '7️⃣', w: 4, three: 500 },
];
export class Credits {
constructor({ store }) {
this.store = store;
}
balance(userId) {
return this.store.get('SELECT COALESCE(SUM(amount), 0) AS n FROM credit_ledger WHERE user_id = ?', userId).n;
}
book(userId, amount, reason, ref) {
return Number(this.store.run('INSERT OR IGNORE INTO credit_ledger (user_id, amount, reason, ref, created_at) VALUES (?, ?, ?, ?, ?)', userId, amount, reason, ref, now()).changes);
}
/** Books every earning that is not booked yet. Returns how many entries were added. */
sync() {
let n = 0;
this.store.tx(() => {
const missing = (reason, refExpr) => `NOT EXISTS (SELECT 1 FROM credit_ledger l WHERE l.user_id = a.user_id AND l.reason = '${reason}' AND l.ref = ${refExpr})`;
// Merged code: byte-identical or peer-confirmed.
for (const r of this.store.all(
`SELECT a.id, a.user_id, json_extract(a.result_json, '$.feedback.target.status') AS status FROM attempts a
WHERE a.state = 'merged' AND a.kind = 'code' AND ${missing('identical', "'attempt:' || a.id")} AND ${missing('confirmed', "'attempt:' || a.id")}`,
)) {
const identical = r.status === 'exact' || r.status === 'match_unresolved';
n += this.book(r.user_id, identical ? AWARDS.identical : AWARDS.confirmed, identical ? 'identical' : 'confirmed', `attempt:${r.id}`);
}
// Accepted "no decompile" claims.
for (const r of this.store.all(`SELECT a.id, a.user_id FROM attempts a WHERE a.kind = 'skip' AND a.state IN ('merged', 'skipped', 'approved') AND ${missing('skip', "'attempt:' || a.id")}`)) {
n += this.book(r.user_id, AWARDS.skip, 'skip', `attempt:${r.id}`);
}
// Submitted code that compiled and went on.
for (const r of this.store.all(
`SELECT a.id, a.user_id FROM attempts a WHERE a.kind = 'code' AND a.state IN ('peer_review', 'in_review', 'approved', 'merging', 'merged') AND ${missing('submitted', "'attempt:' || a.id")}`,
)) {
n += this.book(r.user_id, AWARDS.submitted, 'submitted', `attempt:${r.id}`);
}
// Reviews that agreed with the outcome: "same" on what merged, "different" on what peers sent back.
for (const r of this.store.all(
`SELECT p.id, p.reviewer_id AS user_id FROM peer_reviews p JOIN attempts t ON t.id = p.attempt_id
WHERE ((p.verdict = 'same' AND t.state IN ('merged', 'approved', 'merging')) OR (p.verdict = 'different' AND t.state IN ('needs_cleanup', 'hard', 'returned') AND
(SELECT COUNT(*) FROM peer_reviews q WHERE q.attempt_id = t.id AND q.verdict = 'different') >= 3))
AND NOT EXISTS (SELECT 1 FROM credit_ledger l WHERE l.user_id = p.reviewer_id AND l.reason = 'review' AND l.ref = 'review:' || p.id)`,
)) {
n += this.book(r.user_id, AWARDS.review, 'review', `review:${r.id}`);
}
});
return n;
}
/** What the arcade shows a player: balance, recent entries, whether the daily bailout is open. */
wallet(userId) {
const balance = this.balance(userId);
const earned = this.store.all(`SELECT reason, SUM(amount) AS n, COUNT(*) AS c FROM credit_ledger WHERE user_id = ? AND amount > 0 AND reason NOT LIKE 'game:%' GROUP BY reason`, userId);
const recent = this.store.all('SELECT id, game, bet, payout, detail, created_at FROM game_rounds WHERE user_id = ? ORDER BY id DESC LIMIT 15', userId).map((r) => ({ ...r, detail: JSON.parse(r.detail ?? '{}') }));
return { balance, earned: Object.fromEntries(earned.map((e) => [e.reason, { credits: e.n, times: e.c }])), recent, bailout: balance < 5 && !this.bailoutTaken(userId), awards: AWARDS, maxBet: MAX_BET };
}
bailoutTaken(userId) {
return !!this.store.get(`SELECT 1 FROM credit_ledger WHERE user_id = ? AND reason = 'bailout' AND ref = ?`, userId, new Date().toISOString().slice(0, 10));
}
bailout(userId) {
return this.store.tx(() => {
if (this.balance(userId) >= 5) throw new HttpError(409, 'the bailout is for players below 5 credits');
if (!this.book(userId, BAILOUT, 'bailout', new Date().toISOString().slice(0, 10))) throw new HttpError(409, 'already taken today; come back tomorrow (or merge a function)');
return { balance: this.balance(userId), granted: BAILOUT };
});
}
/** One round of a game. The outcome comes from the server's CSPRNG; the bet is booked first. */
play(userId, game, params = {}) {
const bet = Math.floor(Number(params.bet));
if (!Number.isFinite(bet) || bet < 1 || bet > MAX_BET) throw new HttpError(400, `bet 1 to ${MAX_BET} credits`);
const outcome = GAMES[game];
if (!outcome) throw new HttpError(404, `no game ${game}; try ${Object.keys(GAMES).join(', ')}`);
return this.store.tx(() => {
if (this.balance(userId) < bet) throw new HttpError(409, 'not enough credits');
const { payout, detail } = outcome(bet, params);
const id = Number(this.store.run('INSERT INTO game_rounds (user_id, game, bet, payout, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)', userId, game, bet, payout, JSON.stringify(detail), now()).lastInsertRowid);
this.book(userId, -bet, `game:${game}`, `round:${id}:bet`);
if (payout) this.book(userId, payout, `game:${game}`, `round:${id}:win`);
if (payout >= bet * 10 && payout >= 100) this.store.event('game_win', userId, { game, bet, payout });
return { round: id, game, bet, payout, detail, balance: this.balance(userId) };
});
}
leaderboard(limit = 15) {
const rich = this.store.all(
`SELECT u.name, SUM(l.amount) AS balance, SUM(CASE WHEN l.amount > 0 AND l.reason NOT LIKE 'game:%' AND l.reason != 'bailout' THEN l.amount ELSE 0 END) AS earned
FROM credit_ledger l JOIN users u ON u.id = l.user_id WHERE u.status = 'active' GROUP BY u.id ORDER BY balance DESC LIMIT ?`,
limit,
);
const wins = this.store.all(
`SELECT u.name, r.game, r.bet, r.payout, r.created_at FROM game_rounds r JOIN users u ON u.id = r.user_id WHERE r.created_at > ? AND r.payout > r.bet ORDER BY r.payout DESC LIMIT 10`,
now() - 86_400_000,
);
return { rich, wins };
}
}
const roll = (n) => crypto.randomInt(n);
export const GAMES = {
// Heads or tails at 1.95x.
coinflip(bet, { side }) {
if (side !== 'heads' && side !== 'tails') throw new HttpError(400, 'side: heads or tails');
const result = roll(2) ? 'heads' : 'tails';
return { payout: result === side ? Math.floor(bet * 1.95) : 0, detail: { side, result } };
},
// Roll 1-100; win when the roll is below the target. The payout follows the odds (3% edge).
dice(bet, { under }) {
const target = Math.floor(Number(under));
if (!(target >= 3 && target <= 96)) throw new HttpError(400, 'under: a target from 3 to 96');
const result = roll(100) + 1;
const multiplier = Math.floor((97 / (target - 1)) * 100) / 100;
return { payout: result < target ? Math.floor(bet * multiplier) : 0, detail: { under: target, result, multiplier } };
},
// Three reels; three of a kind pays per symbol, two gears on the first reels pay the bet back.
slots(bet) {
const total = REEL.reduce((t, x) => t + x.w, 0);
const spin = () => {
let r = roll(total);
for (const x of REEL) if ((r -= x.w) < 0) return x;
return REEL[0];
};
const reels = [spin(), spin(), spin()];
let payout = 0;
if (reels[0] === reels[1] && reels[1] === reels[2]) payout = bet * reels[0].three;
else if (reels[0].s === '⚙️' && reels[1].s === '⚙️') payout = bet;
return { payout, detail: { reels: reels.map((x) => x.s) } };
},
};
export const SLOT_TABLE = REEL.map(({ s, three }) => ({ symbol: s, three }));

View file

@ -1119,3 +1119,60 @@ test('the server does not compile checks unless remoteChecks is on', async () =>
assert.match(r.body.error, /td toolchain install/);
ctx.config.remoteChecks = true;
});
test('credits: merged work and agreeing reviews pay once; games book bet and payout', async () => {
await activeUser('crauthor');
await activeUser('crpeer');
const author = ctx.accounts.byName('crauthor');
const peer = ctx.accounts.byName('crpeer');
const v = ctx.store.currentVersion();
const id = ctx.store.ensureSymbol({ kind: 'function', versionId: v.id, addr: 0x402900, size: 8, unit: 'src/credits.cpp' }).symbol.id;
ctx.tasks.generate();
const task = ctx.store.get('SELECT id FROM tasks WHERE symbol_id = ?', id).id;
const attempt = Number(ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, result_json, created_at) VALUES (?, ?, ?, '{}', 'merged', ?, ?)`, task, author.id, ctx.repo.head(), JSON.stringify({ feedback: { target: { status: 'mismatch' } } }), now()).lastInsertRowid);
ctx.store.run(`INSERT INTO peer_reviews (attempt_id, reviewer_id, verdict, reason, created_at) VALUES (?, ?, 'same', 'the same calls and returns', ?)`, attempt, peer.id, now());
ctx.credits.sync();
ctx.credits.sync();
assert.equal(ctx.credits.balance(author.id), 6 + 1, 'peer-confirmed merge plus the submission, booked once');
assert.equal(ctx.credits.balance(peer.id), 2, 'an agreeing review');
const cookie = await login('crauthor');
const play = (game, body) => request('POST', `/api/games/${game}`, body, { cookie });
assert.equal((await play('coinflip', { bet: 100, side: 'heads' })).status, 409, 'cannot bet more than the balance');
assert.equal((await play('coinflip', { bet: 3, side: 'edge' })).status, 400);
const r = await play('coinflip', { bet: 3, side: 'heads' });
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(r.body.balance, 7 - 3 + r.body.payout);
assert.equal(ctx.credits.balance(author.id), r.body.balance);
const d = await play('dice', { bet: 1, under: 50 });
assert.equal(d.status, 200);
assert.ok(d.body.detail.result >= 1 && d.body.detail.result <= 100);
const s = await play('slots', { bet: 1 });
assert.equal(s.body.detail.reels.length, 3);
const token = await activeUser('crbot');
assert.equal((await call('POST', '/api/games/slots', { bet: 1 }, token)).status, 403, 'games are for people on the website, not agent tokens');
ctx.store.run(`UPDATE attempts SET state = 'rejected' WHERE id = ?`, attempt);
ctx.store.run(`UPDATE tasks SET state = 'done' WHERE id = ?`, task);
});
test('deleting an account: kept as deleted_<id> when it has work, removed otherwise', async () => {
await ctx.accounts.createUser('deladmin', 'admin', PASSWORD);
const admin = await login('deladmin');
await activeUser('delworker');
await activeUser('delidle');
const worker = ctx.accounts.byName('delworker');
const task = ctx.store.get(`SELECT id FROM tasks LIMIT 1`).id;
ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, created_at) VALUES (?, ?, ?, '{}', 'rejected', ?)`, task, worker.id, ctx.repo.head(), now());
const del = (name) => request('POST', `/api/admin/users/${name}/delete`, {}, { cookie: admin });
const kept = await del('delworker');
assert.equal(kept.status, 200, JSON.stringify(kept.body));
assert.equal(kept.body.keptAs, `deleted_${worker.id}`);
assert.equal(ctx.accounts.byName(`deleted_${worker.id}`).status, 'revoked');
assert.equal(ctx.accounts.byName(`deleted_${worker.id}`).password_hash, null);
const gone = await del('delidle');
assert.equal(gone.body.keptAs, null);
assert.equal(ctx.accounts.byName('delidle'), null);
const reset = await request('POST', '/api/admin/users/deladmin/reset-password', {}, { cookie: admin });
assert.equal(reset.status, 200);
assert.ok(reset.body.password.length >= 12);
});