@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.
Files changed (73) hide show
  1. package/.bongos-core.json +188 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +14 -4
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/api/openapi.json +220 -20
  8. package/docs/api-reference.md +8 -3
  9. package/docs/architecture.md +1 -0
  10. package/docs/copy-inventory.md +21 -5
  11. package/docs/copy-registry.json +169 -15
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +8 -3
  14. package/docs/page-readings.json +72 -72
  15. package/migrations/core_268_builders_main_discipline.sql +35 -0
  16. package/modules/copy-desk/artist-stats.js +56 -0
  17. package/modules/copy-desk/module.json +1 -1
  18. package/modules/copy-desk/page-status.js +39 -0
  19. package/modules/copy-desk/routes/copy-desk.js +7 -16
  20. package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
  21. package/modules/economy/credits-by-craft.js +208 -0
  22. package/modules/economy/reward.js +8 -0
  23. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea-cut.webp +0 -0
  24. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  25. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  26. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  27. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  28. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  29. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  30. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  31. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  32. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  33. package/modules/hall-ui/public/genesis-home.js +349 -0
  34. package/modules/hall-ui/public/hall-render.js +2 -2
  35. package/modules/hall-ui/public/index-genesis-decide.states.json +13 -0
  36. package/modules/hall-ui/public/index-genesis.states.json +13 -0
  37. package/modules/hall-ui/public/index.html +2 -0
  38. package/modules/hall-ui/public/profile.js +1 -1
  39. package/modules/hall-ui/public/style.css +65 -0
  40. package/modules/hall-ui/records/genesis-home.md +18 -0
  41. package/modules/ideas/ideator-stats.js +149 -0
  42. package/modules/ideas/module.json +2 -1
  43. package/modules/ideas/routes/inbox.js +7 -0
  44. package/modules/lifecycle/activity.js +5 -2
  45. package/modules/lifecycle/db-analytics.js +4 -2
  46. package/modules/lifecycle/db-tasks.js +49 -1
  47. package/modules/lifecycle/db.js +3 -2
  48. package/modules/lifecycle/kickoff-checklist.js +54 -1
  49. package/modules/lifecycle/kickoff-seed.js +61 -2
  50. package/modules/lifecycle/page-tweak-reads.js +23 -5
  51. package/modules/lifecycle/routes/kickoff-seed.js +7 -1
  52. package/modules/onboarding/genesis-home.js +274 -0
  53. package/modules/onboarding/routes/onboarding.js +56 -0
  54. package/modules/ui-design/kit/fixtures/founding-genesis-decide.json +108 -0
  55. package/modules/ui-design/kit/fixtures/founding-genesis-stage.json +162 -0
  56. package/modules/ui-design/kit/fixtures/government-constitution-sample.json +205 -0
  57. package/modules/ui-design/kit/serve.js +7 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +12 -0
  61. package/scripts/gds/run-unit-tests.js +5 -0
  62. package/src/bongos/db.js +44 -6
  63. package/src/bongos/role-stats.js +155 -0
  64. package/src/bongos/routes/builders.js +15 -2
  65. package/src/bongos/routes/me.js +11 -3
  66. package/src/module-api.js +1 -1
  67. package/tests/copy_desk_page_reads.mjs +1 -1
  68. package/tests/fixtures/page-ledger.mjs +1 -1
  69. package/tests/genesis_home.mjs +288 -0
  70. package/tests/main_discipline.mjs +81 -0
  71. package/tests/profile_role_stats.mjs +416 -0
  72. package/tests/role_stats_db.mjs +237 -0
  73. 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 };
@@ -7,6 +7,6 @@
7
7
  "default": true,
8
8
  "maintenance": { "status": "core-maintained" },
9
9
  "contributes": { "routes": ["copy-desk"], "migrations": true, "skills": ["tweak"] },
10
- "provides": [],
10
+ "provides": ["copy-desk.artistStats"],
11
11
  "consumes": ["lifecycle"]
12
12
  }
@@ -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
- // Every `tweak.page_approved:` credit row the caller has, through the reward
364
- // port (credit_log is economy's table). No reward port, or no approved page
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
- assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
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
  };