@bongos/core 1.20.57 → 1.20.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +188 -53
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +14 -4
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/api/openapi.json +220 -20
- package/docs/api-reference.md +8 -3
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +21 -5
- package/docs/copy-registry.json +169 -15
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +8 -3
- package/docs/page-readings.json +72 -72
- package/migrations/core_268_builders_main_discipline.sql +35 -0
- package/modules/copy-desk/artist-stats.js +56 -0
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-status.js +39 -0
- package/modules/copy-desk/routes/copy-desk.js +7 -16
- package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
- package/modules/economy/credits-by-craft.js +208 -0
- package/modules/economy/reward.js +8 -0
- package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
- package/modules/hall-ui/public/genesis-home.js +349 -0
- package/modules/hall-ui/public/hall-render.js +2 -2
- package/modules/hall-ui/public/index-genesis-decide.states.json +13 -0
- package/modules/hall-ui/public/index-genesis.states.json +13 -0
- package/modules/hall-ui/public/index.html +2 -0
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/style.css +65 -0
- package/modules/hall-ui/records/genesis-home.md +18 -0
- package/modules/ideas/ideator-stats.js +149 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +7 -0
- package/modules/lifecycle/activity.js +5 -2
- package/modules/lifecycle/db-analytics.js +4 -2
- package/modules/lifecycle/db-tasks.js +49 -1
- package/modules/lifecycle/db.js +3 -2
- package/modules/lifecycle/kickoff-checklist.js +54 -1
- package/modules/lifecycle/kickoff-seed.js +61 -2
- package/modules/lifecycle/page-tweak-reads.js +23 -5
- package/modules/lifecycle/routes/kickoff-seed.js +7 -1
- package/modules/onboarding/genesis-home.js +274 -0
- package/modules/onboarding/routes/onboarding.js +56 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-decide.json +108 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-stage.json +162 -0
- package/modules/ui-design/kit/fixtures/government-constitution-sample.json +205 -0
- package/modules/ui-design/kit/serve.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/bongos/db.js +44 -6
- package/src/bongos/role-stats.js +155 -0
- package/src/bongos/routes/builders.js +15 -2
- package/src/bongos/routes/me.js +11 -3
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_reads.mjs +1 -1
- package/tests/fixtures/page-ledger.mjs +1 -1
- package/tests/genesis_home.mjs +288 -0
- package/tests/main_discipline.mjs +81 -0
- package/tests/profile_role_stats.mjs +416 -0
- package/tests/role_stats_db.mjs +237 -0
- package/tests/tweak_replaces_artist_review.mjs +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// modules/copy-desk/artist-stats.js — the artist's LEDGER READS: the studio
|
|
2
|
+
// corner's credit rows, and the profile's artist numbers (task 1004433 /
|
|
3
|
+
// WA6.RP03). Apart from routes/copy-desk.js because that file is at the
|
|
4
|
+
// 1,500-line ratchet (the page-decisions.js precedent); the route requires
|
|
5
|
+
// `tallyCreditRows` from here for GET /copy-desk/tally, so the studio and the
|
|
6
|
+
// profile read the same rows through the same call.
|
|
7
|
+
//
|
|
8
|
+
// Reads only. Nothing here writes, and nothing here returns a word of copy:
|
|
9
|
+
// counts, sums and surface labels. That is what lets `copy-desk.artistStats` be
|
|
10
|
+
// the one port this module provides without opening a copy write path
|
|
11
|
+
// (tests/copy_no_cms.mjs pins exactly this one).
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const api = require('../../src/module-api');
|
|
16
|
+
const pageData = require('./page-data');
|
|
17
|
+
const pageStatus = require('./page-status');
|
|
18
|
+
|
|
19
|
+
// Every `tweak.page_approved:` credit row the builder has, through the reward
|
|
20
|
+
// port (credit_log is economy's table). No reward port, or no approved page
|
|
21
|
+
// landed yet (economy books one row per landed round, task 1004324), reads as
|
|
22
|
+
// none: a tally of 0 is the truth then.
|
|
23
|
+
//
|
|
24
|
+
// ONE bounded query. An approved page books one row, so the cap is thousands of
|
|
25
|
+
// pages for one artist; past it the tally says `truncated` (creditLedger's
|
|
26
|
+
// `total` is the unpaginated count) rather than paging in a loop.
|
|
27
|
+
const TALLY_ROW_CAP = 5000;
|
|
28
|
+
async function tallyCreditRows(builderId) {
|
|
29
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
30
|
+
if (!reward || typeof reward.creditLedger !== 'function') return { rows: [], truncated: false };
|
|
31
|
+
const { rows, total } = await reward.creditLedger(builderId, { reasonPrefix: pageStatus.TALLY_REASON_PREFIX, limit: TALLY_ROW_CAP });
|
|
32
|
+
const list = rows || [];
|
|
33
|
+
return { rows: list, truncated: Number(total) > list.length };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The profile's artist numbers, for core's profile route through the
|
|
37
|
+
// `copy-desk.artistStats` port: the SAME reads the studio's tally and page list
|
|
38
|
+
// make, composed by page-status.js composeArtistStats. No lifecycle port reads
|
|
39
|
+
// as null (could not ask), never as zero. A missing page inventory leaves the
|
|
40
|
+
// per-surface lines empty and keeps the tally, which comes from the ledger.
|
|
41
|
+
async function readArtistStats(builderId, { now = new Date() } = {}) {
|
|
42
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
43
|
+
if (!lifecycle || typeof lifecycle.listPageTweakTasks !== 'function') return null;
|
|
44
|
+
const inv = pageData.loadInventory();
|
|
45
|
+
// Only this artist's rounds, read from their own web claims: a public page
|
|
46
|
+
// must not cost a scan of every round the project has ever had.
|
|
47
|
+
const [tasks, credit] = await Promise.all([lifecycle.listPageTweakTasks({ artistId: builderId }), tallyCreditRows(builderId)]);
|
|
48
|
+
const out = pageStatus.composeArtistStats({
|
|
49
|
+
builderId, inventory: inv.ok ? inv.data : null, tasks, creditRows: credit.rows, now,
|
|
50
|
+
});
|
|
51
|
+
out.inventory_known = !!inv.ok;
|
|
52
|
+
out.truncated = credit.truncated;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { tallyCreditRows, readArtistStats, TALLY_ROW_CAP };
|
|
@@ -347,6 +347,44 @@ function composeTally({ builderId, tasks, creditRows, now }) {
|
|
|
347
347
|
};
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
// The profile's ARTIST numbers (task 1004433 / WA6.RP03; the mock is
|
|
351
|
+
// docs/design/mocks/profile-roles/): the corner tally above, unchanged, plus
|
|
352
|
+
// "N of M tweaked" per surface for THIS artist. The studio's per-surface line
|
|
353
|
+
// (composePages) counts every artist's rounds; a profile is one person's record,
|
|
354
|
+
// so a page counts here when it has a shipped round whose artist is them. M is
|
|
355
|
+
// the surface's whole page count, the same denominator the studio shows.
|
|
356
|
+
//
|
|
357
|
+
// `pages_approved` is the tally's own count of approved ROUNDS (each one paid);
|
|
358
|
+
// `pages` is how many DISTINCT pages those rounds touched, the sum of the
|
|
359
|
+
// per-surface N. The two differ once an artist tweaks the same page twice.
|
|
360
|
+
//
|
|
361
|
+
// Counts and sums only: no wording, no page title beyond the surface's label,
|
|
362
|
+
// because the owner's decision (a) keeps these numbers public and the pages
|
|
363
|
+
// themselves behind a sign-in.
|
|
364
|
+
function composeArtistStats({ builderId, inventory, tasks, creditRows, now }) {
|
|
365
|
+
const tally = composeTally({ builderId, tasks, creditRows, now });
|
|
366
|
+
const byPage = indexRounds(tasks);
|
|
367
|
+
const mine = new Set();
|
|
368
|
+
for (const [pageId, rounds] of byPage) {
|
|
369
|
+
if (rounds.some((r) => r.status === 'shipped' && String(r.artist_id) === String(builderId))) mine.add(pageId);
|
|
370
|
+
}
|
|
371
|
+
const all = (inventory && inventory.pages) || [];
|
|
372
|
+
const surfaces = orderedSurfaces(inventory).map((id) => {
|
|
373
|
+
const on = all.filter((p) => p && p.surface === id);
|
|
374
|
+
const tweaked = on.filter((p) => mine.has(p.id)).length;
|
|
375
|
+
return { id, label: SURFACE_LABELS[id] || id, tweaked, total: on.length, line: `${tweaked} of ${on.length} tweaked` };
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
pages_approved: tally.pages_tweaked,
|
|
379
|
+
pages: surfaces.reduce((n, s) => n + s.tweaked, 0),
|
|
380
|
+
surfaces_touched: surfaces.filter((s) => s.tweaked > 0).length,
|
|
381
|
+
credits: tally.credits,
|
|
382
|
+
this_week: tally.this_week,
|
|
383
|
+
week_since: tally.week_since,
|
|
384
|
+
surfaces,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
350
388
|
// ---------------------------------------------------------------------------
|
|
351
389
|
// The recommendation, "what should I tweak next" (task 1004318 / BV2.TW07,
|
|
352
390
|
// ADR 0341 D8). The fourth read, over the same ledger rows as the other three
|
|
@@ -689,4 +727,5 @@ module.exports = {
|
|
|
689
727
|
composePage,
|
|
690
728
|
lastRoundShippedAt,
|
|
691
729
|
composeTally,
|
|
730
|
+
composeArtistStats,
|
|
692
731
|
};
|
|
@@ -62,6 +62,7 @@ const pageStatus = require('../page-status');
|
|
|
62
62
|
const pages = require('../pages');
|
|
63
63
|
const docx = require('../docx');
|
|
64
64
|
const { waitingRoundOrFail, refuseDecision, nextWaiting } = require('../page-decisions');
|
|
65
|
+
const { tallyCreditRows, readArtistStats } = require('../artist-stats');
|
|
65
66
|
|
|
66
67
|
const { validateOrRespond } = api;
|
|
67
68
|
|
|
@@ -360,26 +361,16 @@ function pageReadsOrFail(res) {
|
|
|
360
361
|
return lifecycle;
|
|
361
362
|
}
|
|
362
363
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
// landed yet (economy books one row per landed round, task 1004324), reads as
|
|
366
|
-
// none: a tally of 0 is the truth then.
|
|
367
|
-
//
|
|
368
|
-
// ONE bounded query. An approved page books one row, so the cap is thousands of
|
|
369
|
-
// pages for one artist; past it the tally says `truncated` (creditLedger's
|
|
370
|
-
// `total` is the unpaginated count) rather than paging in a loop.
|
|
371
|
-
const TALLY_ROW_CAP = 5000;
|
|
372
|
-
async function tallyCreditRows(builderId) {
|
|
373
|
-
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
374
|
-
if (!reward || typeof reward.creditLedger !== 'function') return { rows: [], truncated: false };
|
|
375
|
-
const { rows, total } = await reward.creditLedger(builderId, { reasonPrefix: pageStatus.TALLY_REASON_PREFIX, limit: TALLY_ROW_CAP });
|
|
376
|
-
const list = rows || [];
|
|
377
|
-
return { rows: list, truncated: Number(total) > list.length };
|
|
378
|
-
}
|
|
364
|
+
// The tally's credit rows and the profile's artist numbers live in
|
|
365
|
+
// ../artist-stats.js (task 1004433); this file is at the 1,500-line ratchet.
|
|
379
366
|
|
|
380
367
|
module.exports = function buildCopyDeskRouter() {
|
|
381
368
|
const router = express.Router();
|
|
382
369
|
|
|
370
|
+
if (!api.hasProvider('copy-desk.artistStats')) {
|
|
371
|
+
api.registerProvider('copy-desk.artistStats', readArtistStats);
|
|
372
|
+
}
|
|
373
|
+
|
|
383
374
|
// Page asks resolve when the page's tweak ships (ADR 0341 D6). Re-registered
|
|
384
375
|
// on every router build (tests rebuild it), never stacked.
|
|
385
376
|
if (_askListenerOff) _askListenerOff();
|
|
@@ -287,7 +287,16 @@ await test('the Approval queue decides only through the port, and a send-back wr
|
|
|
287
287
|
|
|
288
288
|
await test('the module manifest declares no seam another module could write copy through', () => {
|
|
289
289
|
const manifest = JSON.parse(read('module.json'));
|
|
290
|
-
|
|
290
|
+
// ONE port, and it is a read of counts (task 1004433 / WA6.RP03): the
|
|
291
|
+
// profile's artist numbers. Anything more provided here would be a seam
|
|
292
|
+
// another module could reach copy through, so the list is pinned exactly and
|
|
293
|
+
// the provider is held to returning no wording below.
|
|
294
|
+
assert.deepEqual(manifest.provides, ['copy-desk.artistStats'], 'a provided port here would be a copy write path for every other module');
|
|
295
|
+
const stats = read('artist-stats.js');
|
|
296
|
+
assert.equal(/\b(pool|query|writeFile|appendFile|INSERT|UPDATE|DELETE)\b/.test(stats.replace(/\/\/.*$/gm, '')), false,
|
|
297
|
+
'the artist-stats port reads through other ports and writes nothing');
|
|
298
|
+
assert.equal(/\b(lines|before|after|reading|description|title)\s*:/.test(stats.replace(/\/\/.*$/gm, '')), false,
|
|
299
|
+
'the artist-stats port returns counts and sums, never wording');
|
|
291
300
|
// R03 consumes `lifecycle` to create the proposal task. That direction is the
|
|
292
301
|
// safe one and the point: this module can put a work request INTO the ledger,
|
|
293
302
|
// and nothing can pull copy OUT of this module.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/credits-by-craft.js — WHICH CRAFT EARNED A BUILDER'S CREDITS
|
|
4
|
+
// (task 1004433 / WA6.RP03, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
5
|
+
//
|
|
6
|
+
// WHY. builders.total_credits is one number, and every public surface read it as
|
|
7
|
+
// "payout from shipped work". That is true for an engineer and false for the
|
|
8
|
+
// other two crafts: an artist is paid per approved page (`tweak.page_approved:`),
|
|
9
|
+
// an ideator per idea stream (`idea.credit.*`), and neither has to ship a task to
|
|
10
|
+
// earn. The owner's 2026-09-30 rules need the split: a role other than the main
|
|
11
|
+
// one shows (chip, Activity tab, leaderboard entry) only once it has EARNED
|
|
12
|
+
// credits, and a leaderboard filtered to one role ranks by the credits earned in
|
|
13
|
+
// that craft. So the split is a ledger fact, read here, once.
|
|
14
|
+
//
|
|
15
|
+
// THE RULE, per credit_log row:
|
|
16
|
+
//
|
|
17
|
+
// tweak.page_approved:<task> artist (ADR 0341 D10, the artist lane)
|
|
18
|
+
// idea.credit.* · idea_promotion_bonus ideator (ADR 0172, every idea stream,
|
|
19
|
+
// the spark/developer split too)
|
|
20
|
+
// task.shipped · task.confirmed · the craft of the TASK's discipline
|
|
21
|
+
// ship.net_negative
|
|
22
|
+
// session.token_reward the craft of the tasks that session
|
|
23
|
+
// shipped, split evenly across them; a
|
|
24
|
+
// session that names none is engineering
|
|
25
|
+
// (an AI build session is, by default)
|
|
26
|
+
// anything else (achievement.*, `other` — credited, but to no craft
|
|
27
|
+
// rank_change, a hand grant)
|
|
28
|
+
//
|
|
29
|
+
// A task's discipline maps to a craft through DISCIPLINE_CRAFT. `ui` is the
|
|
30
|
+
// module-contributed design discipline (ADR 0079, 0272) and counts as ARTIST:
|
|
31
|
+
// the artist's craft is how the project looks and reads (docs/packs/artist.md),
|
|
32
|
+
// and UI design is that. A discipline this table does not know (a module may
|
|
33
|
+
// contribute one later) counts as engineering, the default craft, rather than
|
|
34
|
+
// vanishing from every total.
|
|
35
|
+
//
|
|
36
|
+
// THE SUM IS EXACT. A session's reward split across three tasks is a third each,
|
|
37
|
+
// so the per-craft figures arrive as fractions. They are rounded by LARGEST
|
|
38
|
+
// REMAINDER against the builder's true ledger total, so the crafts and `other`
|
|
39
|
+
// always add up to exactly what the ledger holds. Rounding each craft on its own
|
|
40
|
+
// would let the parts disagree with the whole by a credit, on a page that shows
|
|
41
|
+
// both.
|
|
42
|
+
//
|
|
43
|
+
// ONLY READS. Nothing here writes, and nothing is reachable from a ship path.
|
|
44
|
+
|
|
45
|
+
const api = require('../../src/module-api');
|
|
46
|
+
const { pool } = api;
|
|
47
|
+
const { ARTIST_PAGE_REASON_PREFIX } = require('./credits');
|
|
48
|
+
|
|
49
|
+
const CRAFTS = Object.freeze(['engineer', 'artist', 'ideator']);
|
|
50
|
+
const DEFAULT_CRAFT = 'engineer';
|
|
51
|
+
const OTHER = 'other';
|
|
52
|
+
|
|
53
|
+
const DISCIPLINE_CRAFT = Object.freeze({
|
|
54
|
+
engineer: 'engineer',
|
|
55
|
+
artist: 'artist',
|
|
56
|
+
ui: 'artist',
|
|
57
|
+
ideator: 'ideator',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// The per-task reasons whose craft is the task's own. The same three the
|
|
61
|
+
// lifecycle's CREDITS_AWARDED_SUBQUERY sums as a task's payout.
|
|
62
|
+
const TASK_REASONS = Object.freeze(['task.shipped', 'task.confirmed', 'ship.net_negative']);
|
|
63
|
+
|
|
64
|
+
// The craft a builder's PREFERRED discipline names, or null for one that is not
|
|
65
|
+
// a craft at all. The same table, read the other way round: an unknown
|
|
66
|
+
// discipline is engineering money (above) but is no role chip. core's
|
|
67
|
+
// src/bongos/role-stats.js asks this through the reward port, so the ui-is-artist
|
|
68
|
+
// rule has exactly one home.
|
|
69
|
+
function craftForPreference(discipline) {
|
|
70
|
+
return DISCIPLINE_CRAFT[discipline] || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function craftOfDiscipline(discipline) {
|
|
74
|
+
if (discipline === OTHER) return OTHER;
|
|
75
|
+
return DISCIPLINE_CRAFT[discipline] || DEFAULT_CRAFT;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The artist prefix goes into the SQL as a LIKE pattern, so its underscore (a
|
|
79
|
+
// LIKE wildcard) is escaped, the idea-size.js rule. Checked at load: the prefix
|
|
80
|
+
// is economy's own constant, never a request value.
|
|
81
|
+
if (!/^[a-z0-9_.]+:$/i.test(ARTIST_PAGE_REASON_PREFIX)) {
|
|
82
|
+
throw new Error('economy/credits-by-craft: the artist reason prefix is not a plain literal');
|
|
83
|
+
}
|
|
84
|
+
const ARTIST_LIKE = ARTIST_PAGE_REASON_PREFIX.replace(/([\\%_])/g, '\\$1') + '%';
|
|
85
|
+
|
|
86
|
+
// One statement for the whole set of builders. Each ledger row becomes one or
|
|
87
|
+
// more (builder, discipline, amount) parts, then they are grouped. The session
|
|
88
|
+
// branch is the only one that fans out: one row per task its session shipped.
|
|
89
|
+
//
|
|
90
|
+
// THE INDEXES IT RIDES, all pre-existing, so this needs no migration: both
|
|
91
|
+
// branches filter `cl.builder_id = ANY($1)`, served by idx_credit_log_builder
|
|
92
|
+
// (builder_id, recorded_at) from migrations/003_pms.sql, so the read is that
|
|
93
|
+
// person's rows, never the whole ledger; the task join is the tasks primary
|
|
94
|
+
// key, and the session join is session_records' UNIQUE (session_id) from
|
|
95
|
+
// migrations/062_session_records.sql. tests/profile_role_stats.mjs pins both
|
|
96
|
+
// index definitions, so dropping either reds a test rather than going quiet.
|
|
97
|
+
const CREDITS_BY_CRAFT_SQL = `
|
|
98
|
+
WITH parts AS (
|
|
99
|
+
SELECT cl.builder_id,
|
|
100
|
+
cl.delta::numeric AS amount,
|
|
101
|
+
CASE
|
|
102
|
+
WHEN cl.reason LIKE $2 THEN 'artist'
|
|
103
|
+
WHEN cl.reason LIKE 'idea.credit.%' OR cl.reason = 'idea_promotion_bonus' THEN 'ideator'
|
|
104
|
+
WHEN cl.reason = ANY($3::text[]) THEN COALESCE(t.discipline, '${DEFAULT_CRAFT}')
|
|
105
|
+
ELSE '${OTHER}'
|
|
106
|
+
END AS discipline
|
|
107
|
+
FROM credit_log cl
|
|
108
|
+
LEFT JOIN tasks t ON t.id = cl.task_id
|
|
109
|
+
WHERE cl.builder_id = ANY($1::bigint[])
|
|
110
|
+
AND cl.reason <> 'session.token_reward'
|
|
111
|
+
UNION ALL
|
|
112
|
+
SELECT cl.builder_id,
|
|
113
|
+
cl.delta::numeric / GREATEST(COALESCE(cardinality(sr.task_ids), 0), 1) AS amount,
|
|
114
|
+
COALESCE(st.discipline, '${DEFAULT_CRAFT}') AS discipline
|
|
115
|
+
FROM credit_log cl
|
|
116
|
+
LEFT JOIN session_records sr ON sr.session_id = cl.session_id
|
|
117
|
+
LEFT JOIN LATERAL unnest(COALESCE(sr.task_ids, '{}'::bigint[])) AS u(task_id) ON true
|
|
118
|
+
LEFT JOIN tasks st ON st.id = u.task_id
|
|
119
|
+
WHERE cl.builder_id = ANY($1::bigint[])
|
|
120
|
+
AND cl.reason = 'session.token_reward'
|
|
121
|
+
)
|
|
122
|
+
SELECT builder_id, discipline, SUM(amount)::float8 AS amount
|
|
123
|
+
FROM parts
|
|
124
|
+
GROUP BY builder_id, discipline`;
|
|
125
|
+
|
|
126
|
+
// apportion — round fractional parts to integers that sum to `total` exactly
|
|
127
|
+
// (largest remainder; ties go to the earlier key, so the answer is stable).
|
|
128
|
+
// Pure. `parts` is { key: number }, in the order keys should win ties.
|
|
129
|
+
function apportion(parts, total) {
|
|
130
|
+
const keys = Object.keys(parts);
|
|
131
|
+
const out = {};
|
|
132
|
+
let floorSum = 0;
|
|
133
|
+
const rema = [];
|
|
134
|
+
keys.forEach((k, i) => {
|
|
135
|
+
const v = Number(parts[k]) || 0;
|
|
136
|
+
const f = Math.floor(v);
|
|
137
|
+
out[k] = f;
|
|
138
|
+
floorSum += f;
|
|
139
|
+
rema.push({ k, r: v - f, i });
|
|
140
|
+
});
|
|
141
|
+
// When `total` is the rounded sum of the parts (the only way this file calls
|
|
142
|
+
// it), `left` is round(sum of remainders), so 0 <= left <= keys.length and one
|
|
143
|
+
// pass hands out every unit.
|
|
144
|
+
const left = Math.round(Number(total) || 0) - floorSum;
|
|
145
|
+
rema.sort((a, b) => b.r - a.r || a.i - b.i);
|
|
146
|
+
for (let j = 0; j < left && j < rema.length; j++) out[rema[j].k] += 1;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// foldCraftRows — the grouped SQL rows into one exact row per builder. Pure,
|
|
151
|
+
// so the rounding and the discipline mapping are testable without a database.
|
|
152
|
+
// Returns Map<builderIdString, { engineer, artist, ideator, other, total }>,
|
|
153
|
+
// with every builder asked for present (at zero when they have earned nothing:
|
|
154
|
+
// "no credits" is an answer, not a gap).
|
|
155
|
+
function foldCraftRows(ids, rows) {
|
|
156
|
+
const raw = new Map();
|
|
157
|
+
for (const id of ids) {
|
|
158
|
+
const parts = {};
|
|
159
|
+
for (const c of CRAFTS) parts[c] = 0;
|
|
160
|
+
parts[OTHER] = 0;
|
|
161
|
+
raw.set(id, parts);
|
|
162
|
+
}
|
|
163
|
+
for (const r of rows || []) {
|
|
164
|
+
const parts = raw.get(String(r.builder_id));
|
|
165
|
+
if (!parts) continue;
|
|
166
|
+
parts[craftOfDiscipline(r.discipline)] += Number(r.amount) || 0;
|
|
167
|
+
}
|
|
168
|
+
const out = new Map();
|
|
169
|
+
for (const [id, parts] of raw) {
|
|
170
|
+
const total = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
|
|
171
|
+
out.set(id, { ...apportion(parts, total), total });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function idOf(v) {
|
|
177
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// creditsByCraft — the split for MANY builders in ONE query. The leaderboard's
|
|
181
|
+
// role toggle (RP07) asks for a page of builders at once, the profile for one.
|
|
182
|
+
async function creditsByCraft(builderIds, { client = null } = {}) {
|
|
183
|
+
const ids = [...new Set((Array.isArray(builderIds) ? builderIds : [builderIds]).map(idOf).filter(Boolean))];
|
|
184
|
+
if (ids.length === 0) return new Map();
|
|
185
|
+
const q = client || pool;
|
|
186
|
+
const { rows } = await q.query(CREDITS_BY_CRAFT_SQL, [ids, ARTIST_LIKE, TASK_REASONS]);
|
|
187
|
+
return foldCraftRows(ids, rows);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The crafts a builder has EARNED in, in CRAFTS order. The owner's rule is that
|
|
191
|
+
// a role other than the main one shows only once it has earned credits; this is
|
|
192
|
+
// the "has earned" half, and the only place it is decided.
|
|
193
|
+
function earnedCrafts(row) {
|
|
194
|
+
if (!row) return [];
|
|
195
|
+
return CRAFTS.filter((c) => Number(row[c]) > 0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
creditsByCraft,
|
|
200
|
+
foldCraftRows,
|
|
201
|
+
apportion,
|
|
202
|
+
craftOfDiscipline,
|
|
203
|
+
craftForPreference,
|
|
204
|
+
earnedCrafts,
|
|
205
|
+
CRAFTS,
|
|
206
|
+
DISCIPLINE_CRAFT,
|
|
207
|
+
CREDITS_BY_CRAFT_SQL,
|
|
208
|
+
};
|
|
@@ -22,6 +22,7 @@ const streak = require('./streak');
|
|
|
22
22
|
const ideaSizeLib = require('./idea-size');
|
|
23
23
|
const ideaSizeHistorical = require('./idea-size-historical');
|
|
24
24
|
const ideaBrightnessLib = require('./idea-brightness');
|
|
25
|
+
const creditsByCraftLib = require('./credits-by-craft');
|
|
25
26
|
|
|
26
27
|
module.exports = {
|
|
27
28
|
// credit allocation — transaction-participant: caller passes its tx `client`
|
|
@@ -125,6 +126,13 @@ module.exports = {
|
|
|
125
126
|
// still alive.
|
|
126
127
|
ideaBrightness: (...a) => ideaBrightnessLib.ideaBrightness(...a),
|
|
127
128
|
ideaBrightnesses: (...a) => ideaBrightnessLib.ideaBrightnesses(...a),
|
|
129
|
+
// WHICH CRAFT EARNED IT (task 1004433 / WA6.RP03). total_credits split into
|
|
130
|
+
// engineer / artist / ideator / other, exact to the ledger. The profile reads
|
|
131
|
+
// it to decide which roles show; the leaderboard's role toggle ranks by it.
|
|
132
|
+
creditsByCraft: (...a) => creditsByCraftLib.creditsByCraft(...a),
|
|
133
|
+
earnedCrafts: (...a) => creditsByCraftLib.earnedCrafts(...a),
|
|
134
|
+
craftForPreference: (...a) => creditsByCraftLib.craftForPreference(...a),
|
|
135
|
+
get CRAFTS() { return creditsByCraftLib.CRAFTS; },
|
|
128
136
|
computeStreak: (...a) => streak.computeStreak(...a),
|
|
129
137
|
wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
|
|
130
138
|
};
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|