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:
parent
360fb9a5ce
commit
e246671873
4 changed files with 366 additions and 1 deletions
|
|
@ -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() {
|
activeAdmins() {
|
||||||
return this.store.get(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND status = 'active'`).n;
|
return this.store.get(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND status = 'active'`).n;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { reviewerStats } from './reviewers.js';
|
||||||
import { inferUnits } from './grouping.js';
|
import { inferUnits } from './grouping.js';
|
||||||
import { Router, HttpError, readJson, sendJson, RateLimiter } from './http.js';
|
import { Router, HttpError, readJson, sendJson, RateLimiter } from './http.js';
|
||||||
import { Regions } from './regions.js';
|
import { Regions } from './regions.js';
|
||||||
|
import { Credits, SLOT_TABLE } from './credits.js';
|
||||||
import { PEImage } from '../shared/pe.js';
|
import { PEImage } from '../shared/pe.js';
|
||||||
import { loadProject, loadToolchainLock } from '../shared/project.js';
|
import { loadProject, loadToolchainLock } from '../shared/project.js';
|
||||||
import { DEFAULT_LIMITS, lintSubmission } from '../shared/lint.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 });
|
const ghidra = new ServerGhidra({ config, store, originals, log });
|
||||||
merger.onMerged = (commit) => ghidra.scheduleRefresh(commit);
|
merger.onMerged = (commit) => ghidra.scheduleRefresh(commit);
|
||||||
const checks = new CheckQueue({ store, repo, verifier, config, log });
|
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) {
|
export function createApp(ctx) {
|
||||||
|
|
@ -524,6 +526,96 @@ export function createApp(ctx) {
|
||||||
return { ...tasks.heartbeatAll(user), regions: ctx.regions.heartbeat(user) };
|
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)
|
// --- regions: a source file or class, claimed and worked on as a whole (regions.js)
|
||||||
|
|
||||||
router.on('GET', '/api/regions', ({ req, query }) => {
|
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.
|
// Pause or allow an account's peer reviews (e.g. rubber-stamping); its existing verdicts stay.
|
||||||
'block-reviews': (name, admin) => setReviewBlock(name, 1, admin),
|
'block-reviews': (name, admin) => setReviewBlock(name, 1, admin),
|
||||||
'allow-reviews': (name, admin) => setReviewBlock(name, 0, 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 setReviewBlock = (name, blocked, admin) => {
|
||||||
const u = accounts.byName(name);
|
const u = accounts.byName(name);
|
||||||
|
|
@ -1006,6 +1100,8 @@ export async function startServer(dataDir, { role = 'all' } = {}) {
|
||||||
let server = null;
|
let server = null;
|
||||||
if (role !== 'worker') {
|
if (role !== 'worker') {
|
||||||
const sweeper = setInterval(() => { try { ctx.tasks.sweepExpired(); ctx.regions.sweepExpired(); } catch (e) { console.error(`lease sweep: ${e.message}`); } }, 60_000);
|
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));
|
stops.push(() => clearInterval(sweeper));
|
||||||
server = createApp(ctx);
|
server = createApp(ctx);
|
||||||
await new Promise((r) => server.listen(ctx.config.listen.port, ctx.config.listen.host, r));
|
await new Promise((r) => server.listen(ctx.config.listen.port, ctx.config.listen.host, r));
|
||||||
|
|
|
||||||
166
src/server/credits.js
Normal file
166
src/server/credits.js
Normal 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 }));
|
||||||
|
|
@ -1119,3 +1119,60 @@ test('the server does not compile checks unless remoteChecks is on', async () =>
|
||||||
assert.match(r.body.error, /td toolchain install/);
|
assert.match(r.body.error, /td toolchain install/);
|
||||||
ctx.config.remoteChecks = true;
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue