@bongos/core 1.21.1 → 1.21.3
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 +56 -36
- package/docs/copy-inventory.md +50 -49
- package/docs/copy-registry.json +60 -51
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +49 -41
- package/migrations/core_270_craft_achievements.sql +128 -0
- package/migrations/core_271_bongos_shipper_badge_name.sql +14 -0
- package/modules/economy/achievements.js +199 -4
- package/modules/economy/credits.js +16 -0
- package/modules/economy/reward.js +3 -0
- package/modules/hall-ui/public/builders.js +1 -1
- package/modules/hall-ui/public/hall-render.js +24 -2
- package/modules/hall-ui/public/roster.css +21 -0
- package/modules/hall-ui/public/roster.html +3 -0
- package/modules/hall-ui/public/roster.js +130 -31
- package/modules/hall-ui/public/roster.states.json +26 -1
- package/modules/hall-ui/records/builders-roster.md +20 -0
- package/modules/lifecycle/publish-reconciler.js +25 -0
- package/modules/status-ui/public/status.js +51 -18
- package/modules/status-ui/public/style.css +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/hall-preview/refresh-fixtures.js +2 -0
- package/src/bongos/db.js +13 -0
- package/src/bongos/role-stats.js +36 -2
- package/src/bongos/routes/public.js +13 -2
- package/src/module-api.js +1 -1
- package/tests/achievements_reconcile.mjs +13 -0
- package/tests/craft_achievements.mjs +388 -0
- package/tests/founding_builders.mjs +2 -1
- package/tests/hall_builders_page_pass.mjs +2 -1
- package/tests/leaderboard_roles.mjs +272 -0
|
@@ -429,9 +429,21 @@ async function bookIdeaSplit(client, { payees, reason, taskId = null, describe }
|
|
|
429
429
|
if (rowCount > 0) credited += p.delta;
|
|
430
430
|
booked.push({ ...p, booked: rowCount > 0 });
|
|
431
431
|
}
|
|
432
|
+
// task 1004438: an idea credit is an ideator's moment (First Thought Ignited,
|
|
433
|
+
// First Full Idea, Ratified by the Board, A Thought That Built Ten), and it is
|
|
434
|
+
// booked inside someone else's ship, so the shipper's reconcile never reaches
|
|
435
|
+
// the payee. Every idea stream books through here; each payee just paid is
|
|
436
|
+
// reconciled, and the hook can never fail the payment.
|
|
437
|
+
for (const p of booked) {
|
|
438
|
+
if (p.booked) await craftAchievements().reconcileAfterCraftCredit(client, p.builderId, { taskId });
|
|
439
|
+
}
|
|
432
440
|
return { credited, payees: booked };
|
|
433
441
|
}
|
|
434
442
|
|
|
443
|
+
// Lazy: achievements.js sits beside this file and requires nothing of it, but a
|
|
444
|
+
// load-time require would make the two files' load order matter for no reason.
|
|
445
|
+
function craftAchievements() { return require('./achievements'); }
|
|
446
|
+
|
|
435
447
|
// Stream 2 (ADR 0172 / IC2): a Full Idea with a passing completeness score pays
|
|
436
448
|
// its author IDEA_FULL_PASS_CREDIT, ONCE. Transaction-participant like the rest
|
|
437
449
|
// of the reward port — the caller passes its `client` (a pool works too, since
|
|
@@ -1243,6 +1255,10 @@ async function awardArtistPageCredit(client, { taskId, config = rewardConfig() }
|
|
|
1243
1255
|
[t.artist_id, taskId, priced.effective, reason,
|
|
1244
1256
|
`Approved page tweak, task #${taskId}: ${priced.description} to its artist (artist lane, ADR 0341 D10)`]
|
|
1245
1257
|
);
|
|
1258
|
+
// task 1004438: an approved page is the artist's moment (First Page Approved,
|
|
1259
|
+
// Ten Pages Approved, A Whole Surface). Reconciled here as well as at ship, so
|
|
1260
|
+
// a re-sweep that pays the round outside the ship still reaches the shelf.
|
|
1261
|
+
if (rowCount > 0) await craftAchievements().reconcileAfterCraftCredit(client, t.artist_id, { taskId });
|
|
1246
1262
|
return rowCount > 0
|
|
1247
1263
|
? { credited: priced.effective, reason: 'paid', artistId: t.artist_id, delta: priced.effective }
|
|
1248
1264
|
: { credited: 0, reason: 'already_paid', artistId: t.artist_id, delta: priced.effective };
|
|
@@ -55,6 +55,9 @@ module.exports = {
|
|
|
55
55
|
// fires on EVERY ship path (incl. confirmTask / grader_bypass, which never
|
|
56
56
|
// evaluated achievements before, so most builders only ever got first-claim).
|
|
57
57
|
reconcileBuilderAchievements: (...a) => achievements.reconcileBuilderAchievements(...a),
|
|
58
|
+
// task 1004438: the craft badges a migration cannot backfill ("A Whole Surface"
|
|
59
|
+
// needs the page inventory, a file) — the lifecycle's sweep runs this once per start.
|
|
60
|
+
backfillCraftAchievements: (...a) => achievements.backfillCraftAchievements(...a),
|
|
58
61
|
// pure helpers + constants (display / tests)
|
|
59
62
|
applyKindMultiplier: (...a) => credits.applyKindMultiplier(...a),
|
|
60
63
|
computeSessionTokenReward: (...a) => credits.computeSessionTokenReward(...a),
|
|
@@ -1174,7 +1174,7 @@
|
|
|
1174
1174
|
getJSON(`${API}/achievements`),
|
|
1175
1175
|
getJSON(`${API}/me/recent-ships?limit=all`),
|
|
1176
1176
|
getJSON(`${API}/versions`),
|
|
1177
|
-
getJSON(`${API}/leaderboard`),
|
|
1177
|
+
getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
|
|
1178
1178
|
getJSON(`${API}/public/progress`), // task 1003464: "<version> is N percent shipped"
|
|
1179
1179
|
]);
|
|
1180
1180
|
|
|
@@ -578,6 +578,7 @@
|
|
|
578
578
|
<span>Member since ${fmtDate(builder.created_at)}</span>
|
|
579
579
|
<span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
|
|
580
580
|
${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
|
|
581
|
+
${rolePlaceText(rollPlace) ? `<span title="Your place among the ${rollPlace.role.total} builders shown as ${ROLE_AS[rollPlace.role.craft]}, ranked by the ${curLabel()} that role earned.">${rolePlaceText(rollPlace)}</span>` : ''}
|
|
581
582
|
</div>
|
|
582
583
|
<div class="profile__disciplines">
|
|
583
584
|
<span class="profile__disc-label">Preferred work:</span>
|
|
@@ -611,7 +612,7 @@
|
|
|
611
612
|
setText('stat-karma-sub', karmaTrendText);
|
|
612
613
|
const statRank = document.getElementById('stat-rank');
|
|
613
614
|
if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
|
|
614
|
-
setText('stat-rank-sub', rollPlace ? `#${rollPlace.place} on the leaderboard
|
|
615
|
+
setText('stat-rank-sub', rollPlace ? [`#${rollPlace.place} on the leaderboard`, rolePlaceText(rollPlace)].filter(Boolean).join(' · ') : '');
|
|
615
616
|
const statsSection = document.getElementById('home-stats');
|
|
616
617
|
if (statsSection && !ctx.newcomerGated) statsSection.hidden = false;
|
|
617
618
|
|
|
@@ -1274,7 +1275,28 @@
|
|
|
1274
1275
|
(a, b) => (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0),
|
|
1275
1276
|
);
|
|
1276
1277
|
const idx = ranked.findIndex((r) => r.github_login === login);
|
|
1277
|
-
|
|
1278
|
+
if (idx === -1) return null;
|
|
1279
|
+
// task 1004437: the place inside the builder's MAIN role too, the roster's
|
|
1280
|
+
// role toggle read the same way (credits earned in that craft, among the
|
|
1281
|
+
// builders it is shown for), so an artist's card can say where they stand
|
|
1282
|
+
// as an artist and not only beside every engineer's session pay.
|
|
1283
|
+
const me = ranked[idx];
|
|
1284
|
+
const main = me.roles && me.roles.main;
|
|
1285
|
+
let role = null;
|
|
1286
|
+
if (main && me.roles.credits_by_craft) {
|
|
1287
|
+
const credit = (r) => Number(r.roles && r.roles.credits_by_craft && r.roles.credits_by_craft[main]) || 0;
|
|
1288
|
+
const peers = ranked.filter((r) => r.roles && Array.isArray(r.roles.shown) && r.roles.shown.includes(main));
|
|
1289
|
+
const order = peers.map((r, i) => ({ r, i })).sort((a, b) => (credit(b.r) - credit(a.r)) || (a.i - b.i));
|
|
1290
|
+
const at = order.findIndex((x) => x.r === me);
|
|
1291
|
+
if (at !== -1) role = { craft: main, place: at + 1, total: peers.length };
|
|
1292
|
+
}
|
|
1293
|
+
return { place: idx + 1, total: ranked.length, role };
|
|
1294
|
+
}
|
|
1295
|
+
const ROLE_AS = { engineer: 'an engineer', artist: 'an artist', ideator: 'an ideator' };
|
|
1296
|
+
// "#2 as an artist" — only when it says something the overall place does not.
|
|
1297
|
+
function rolePlaceText(p) {
|
|
1298
|
+
if (!p || !p.role || !ROLE_AS[p.role.craft] || p.role.place === p.place) return '';
|
|
1299
|
+
return `#${p.role.place} as ${ROLE_AS[p.role.craft]}`;
|
|
1278
1300
|
}
|
|
1279
1301
|
|
|
1280
1302
|
// task 1003459 — the Claim button on each Home "Next up" row, through the
|
|
@@ -22,6 +22,27 @@
|
|
|
22
22
|
.dir-who__t { display: flex; flex-direction: column; gap: 1px; min-width: 132px; }
|
|
23
23
|
.dir-name { font-weight: 500; color: var(--ink); overflow-wrap: anywhere; }
|
|
24
24
|
.dir-login { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-faint); }
|
|
25
|
+
/* ---- the roles (task 1004437, the approved mock's leaderboard) -------------
|
|
26
|
+
The toggle: pill chips on the glass, the pressed one filled in ink (the mock's
|
|
27
|
+
.chip). The roles column: one pill per role shown, the main one a step
|
|
28
|
+
stronger (the mock's .role / .role--lead, the profile header's chips). */
|
|
29
|
+
.roster-roles { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 var(--sp-sm); }
|
|
30
|
+
.roster-role {
|
|
31
|
+
min-height: 34px; padding: 0 14px; border: 0; border-radius: var(--radius-pill);
|
|
32
|
+
background: var(--glass); color: var(--ink-soft);
|
|
33
|
+
font: inherit; font-size: 13px; cursor: pointer;
|
|
34
|
+
}
|
|
35
|
+
.roster-role:hover { color: var(--ink); background: var(--glass-strong); }
|
|
36
|
+
.roster-role[aria-pressed="true"] { background: var(--ink); color: var(--bg-card); }
|
|
37
|
+
.lb-roles { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
|
38
|
+
.lb-role {
|
|
39
|
+
display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px;
|
|
40
|
+
border-radius: var(--radius-pill); background: var(--glass);
|
|
41
|
+
font-size: 12.5px; color: var(--ink-soft); white-space: nowrap;
|
|
42
|
+
}
|
|
43
|
+
.lb-role--lead { background: var(--glass-strong); color: var(--ink); }
|
|
44
|
+
.lb-roles--none, .lb-none { color: var(--ink-faint); }
|
|
45
|
+
|
|
25
46
|
/* The roster's place cell carries two facts: the place, and under it the
|
|
26
47
|
builder's number in the same mono/faint voice the login uses (task
|
|
27
48
|
1002837). Block display so the id never wraps up beside the place. */
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
link into index.html. It is a page now; /#leaderboard redirects here. -->
|
|
35
35
|
<section class="scroll" id="roster-scroll" aria-labelledby="roster-h">
|
|
36
36
|
<h2 id="roster-h" class="scroll__h">The roster<span class="count-pill" id="roster-count" hidden></span></h2>
|
|
37
|
+
<!-- The role toggle (task 1004437, owner decision (b)): Everyone, then one
|
|
38
|
+
chip per craft; a craft ranks by the credits earned in it. -->
|
|
39
|
+
<div class="roster-roles" id="roster-roles" role="group" aria-label="Show"></div>
|
|
37
40
|
<div id="roster-filters"></div>
|
|
38
41
|
<div id="roster-body">
|
|
39
42
|
<div class="profile__placeholder">Loading the roster…</div>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// marked `private` still appears on the roster of a project they build on
|
|
15
15
|
// (owner, 2026-08-30; ADR 0225, task 1003443).
|
|
16
16
|
//
|
|
17
|
-
// The laurels (
|
|
17
|
+
// The laurels (ACH + achRowHtml) and the sort lenses MOVED here from
|
|
18
18
|
// hall-render.js rather than being copied — the leaderboard row was their only
|
|
19
19
|
// caller, so the home page is left carrying no dead renderer.
|
|
20
20
|
(function () {
|
|
@@ -43,23 +43,36 @@
|
|
|
43
43
|
return '<span class="rank-badge ' + variant + '" aria-label="' + esc(aria) + '">' + esc(rankLabel(rank)) + '</span>';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'
|
|
53
|
-
'
|
|
54
|
-
'
|
|
46
|
+
// Each laurel's emoji AND its catalog name (the achievements table's `name`,
|
|
47
|
+
// migrations 006/019/031/core_270), so a hover and a screen reader say what the
|
|
48
|
+
// badge is called (task 1004532) — never the id, and never words derived from it,
|
|
49
|
+
// which disagree with the catalog the moment a badge is renamed.
|
|
50
|
+
// tests/craft_achievements.mjs holds every row to the catalog.
|
|
51
|
+
var ACH = {
|
|
52
|
+
'first-ship': ['\u{1F6A2}', 'First Ship'],
|
|
53
|
+
'first-claim': ['\u{1FA99}', 'First Claim'],
|
|
54
|
+
'first-parallel-claim': ['⚓', 'First Parallel Claim'],
|
|
55
|
+
'streak-3': ['\u{1F525}', 'Streak: 3 Nights'],
|
|
56
|
+
'streak-7': ['\u{1F30B}', 'Streak: 7 Nights'],
|
|
57
|
+
// task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
|
|
58
|
+
'art-pipeline-shipper': ['\u{1F6E0}\u{FE0F}', 'Art Pipeline Engineer'],
|
|
59
|
+
'subtractor': ['\u{1FA93}', 'Subtractor'],
|
|
60
|
+
'gds-shipper': ['⚙️', 'Bongos Shipper'],
|
|
61
|
+
'breaking-the-100c-bar': ['\u{1F4AF}', 'Breaking the 100c Bar'],
|
|
62
|
+
'first-thought-ignited': ['\u{1F4A1}', 'First Thought Ignited'],
|
|
63
|
+
'first-full-idea': ['\u{1F4DC}', 'First Full Idea'],
|
|
64
|
+
'first-board-ratification': ['\u{1F3DB}\u{FE0F}', 'Ratified by the Board'],
|
|
65
|
+
'thought-built-ten': ['\u{1F31F}', 'A Thought That Built Ten'],
|
|
66
|
+
'first-page-approved': ['\u{1F3A8}', 'First Page Approved'],
|
|
67
|
+
'ten-pages-approved': ['\u{1F5BC}\u{FE0F}', 'Ten Pages Approved'],
|
|
68
|
+
'whole-surface-tweaked': ['\u{1F5FA}\u{FE0F}', 'A Whole Surface'],
|
|
55
69
|
};
|
|
56
70
|
function achRowHtml(ids) {
|
|
57
71
|
if (!Array.isArray(ids) || !ids.length) return '';
|
|
58
72
|
var inner = ids.map(function (id) {
|
|
59
|
-
var
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
return emoji ? '<span class="lb-ach" title="' + esc(id) + '" aria-label="' + esc(id) + '">' + emoji + '</span>' : '';
|
|
73
|
+
var a = ACH[id];
|
|
74
|
+
// The title is the badge's catalog name: what a hover shows and a screen reader says.
|
|
75
|
+
return a ? '<span class="lb-ach" title="' + esc(a[1]) + '" aria-label="' + esc(a[1]) + '">' + a[0] + '</span>' : '';
|
|
63
76
|
}).join('');
|
|
64
77
|
return inner ? '<span class="lb-ach-row" aria-label="achievements">' + inner + '</span>' : '';
|
|
65
78
|
}
|
|
@@ -94,9 +107,34 @@
|
|
|
94
107
|
return '<span class="dir-who">' + avatar + '<span class="dir-who__t">' + nameHtml + login + '</span></span>';
|
|
95
108
|
}
|
|
96
109
|
|
|
110
|
+
// task 1004437 (WA6.RP07): the roles. Each row's `roles` block is the public
|
|
111
|
+
// list's own (src/bongos/role-stats.js readRolesForBuilders): `shown` is the
|
|
112
|
+
// main role first, then every craft the builder has EARNED in — evidence of
|
|
113
|
+
// work, not only what they declared — and `credits_by_craft` is the ledger
|
|
114
|
+
// split. Owner decision (b): a column naming them AND the role toggle above,
|
|
115
|
+
// which ranks by the credits earned in that craft. The figure a role ranks by
|
|
116
|
+
// is in the row (ADR 0255: a public list is ordered only by what it publishes).
|
|
117
|
+
var CRAFTS = ['engineer', 'artist', 'ideator'];
|
|
118
|
+
var CRAFT_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
|
|
119
|
+
var ROLE_CHIPS = [['', 'Everyone'], ['engineer', 'Engineers'], ['artist', 'Artists'], ['ideator', 'Ideators']];
|
|
120
|
+
function shownOf(b) { return (b.roles && Array.isArray(b.roles.shown)) ? b.roles.shown : []; }
|
|
121
|
+
function craftCredits(b, craft) {
|
|
122
|
+
var by = b.roles && b.roles.credits_by_craft;
|
|
123
|
+
return by ? (Number(by[craft]) || 0) : 0;
|
|
124
|
+
}
|
|
125
|
+
function creditOf(b) { return role ? craftCredits(b, role) : (Number(b.total_credits) || 0); }
|
|
126
|
+
function rolesHtml(b) {
|
|
127
|
+
var shown = shownOf(b);
|
|
128
|
+
if (!shown.length) return '<span class="lb-roles lb-roles--none">—</span>';
|
|
129
|
+
return '<span class="lb-roles">' + shown.map(function (c, i) {
|
|
130
|
+
return '<span class="lb-role' + (i === 0 ? ' lb-role--lead' : '') + '">' + esc(CRAFT_WORD[c] || c) + '</span>';
|
|
131
|
+
}).join('') + '</span>';
|
|
132
|
+
}
|
|
133
|
+
|
|
97
134
|
var all = [];
|
|
98
135
|
var query = '';
|
|
99
136
|
var sortBy = '';
|
|
137
|
+
var role = '';
|
|
100
138
|
|
|
101
139
|
// PLACE IS CANONICAL, not the row's position in the current sort.
|
|
102
140
|
// hall-render's leaderboardPlace() already defines a builder's place as
|
|
@@ -108,6 +146,16 @@
|
|
|
108
146
|
rows.slice()
|
|
109
147
|
.sort(function (a, b) { return (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0); })
|
|
110
148
|
.forEach(function (b, i) { b.__place = i + 1; });
|
|
149
|
+
// A role's place is the same idea inside that role: credits earned in the
|
|
150
|
+
// craft, descending, among the builders it is shown for. Stamped once too,
|
|
151
|
+
// so the place under a role toggle does not renumber under a search.
|
|
152
|
+
rows.forEach(function (b) { b.__placeBy = {}; });
|
|
153
|
+
CRAFTS.forEach(function (c) {
|
|
154
|
+
rows.filter(function (b) { return shownOf(b).indexOf(c) !== -1; })
|
|
155
|
+
.map(function (b, i) { return { b: b, i: i }; })
|
|
156
|
+
.sort(function (x, y) { return (craftCredits(y.b, c) - craftCredits(x.b, c)) || (x.i - y.i); })
|
|
157
|
+
.forEach(function (x, i) { x.b.__placeBy[c] = i + 1; });
|
|
158
|
+
});
|
|
111
159
|
// The efficiency title is stamped here too, so the division and the string
|
|
112
160
|
// work happen ONCE per load rather than per visible row per render (every
|
|
113
161
|
// sort, search keystroke and page turn re-renders).
|
|
@@ -116,6 +164,7 @@
|
|
|
116
164
|
}
|
|
117
165
|
|
|
118
166
|
function matches(b) {
|
|
167
|
+
if (role && shownOf(b).indexOf(role) === -1) return false;
|
|
119
168
|
if (!query) return true;
|
|
120
169
|
var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
|
|
121
170
|
return hay.indexOf(query) !== -1;
|
|
@@ -130,7 +179,8 @@
|
|
|
130
179
|
tokens: function (b) { return -(Number(b.tokens_used) || 0); },
|
|
131
180
|
};
|
|
132
181
|
function ordered(rows) {
|
|
133
|
-
|
|
182
|
+
// The default lens is credits; under a role it is the credits that role earned.
|
|
183
|
+
var key = SORTS[sortBy] || (role ? function (b) { return -craftCredits(b, role); } : null);
|
|
134
184
|
if (!key) return rows;
|
|
135
185
|
return rows.map(function (b, i) { return { b: b, i: i }; })
|
|
136
186
|
.sort(function (x, y) { return (key(x.b) - key(y.b)) || (x.i - y.i); })
|
|
@@ -140,7 +190,14 @@
|
|
|
140
190
|
var pager = kit.makePager('roster', 'hall-roster-per-page', { defaultPerPage: 25 });
|
|
141
191
|
var ledger = null;
|
|
142
192
|
|
|
193
|
+
// Tokens only mean something for AI sessions. A builder who works in the
|
|
194
|
+
// browser (an artist in the studio, an ideator in the room) records none, and
|
|
195
|
+
// a 0 beside them reads as "did nothing" (task 1004437): their cell says so in
|
|
196
|
+
// words instead, and the tokens-per-credit figure is offered only where there
|
|
197
|
+
// are tokens to divide.
|
|
198
|
+
function hasTokens(b) { return (Number(b.tokens_used) || 0) > 0; }
|
|
143
199
|
function efficiencyTitle(b) {
|
|
200
|
+
if (!hasTokens(b)) return 'No AI sessions recorded — this builder’s work is not measured in tokens';
|
|
144
201
|
var credits = Number(b.total_credits) || 0;
|
|
145
202
|
var one = currencyLabel().toLowerCase().replace(/s$/, '');
|
|
146
203
|
if (credits <= 0) return 'no ' + currencyLabel().toLowerCase() + ' earned yet';
|
|
@@ -168,20 +225,69 @@
|
|
|
168
225
|
}
|
|
169
226
|
|
|
170
227
|
function rowHtml(b) {
|
|
228
|
+
var place = role ? (b.__placeBy && b.__placeBy[role]) : b.__place;
|
|
229
|
+
var tokens = hasTokens(b)
|
|
230
|
+
? esc(fmtInt(b.tokens_used))
|
|
231
|
+
: '<span class="lb-none" aria-label="No AI sessions">—</span>';
|
|
171
232
|
return '<tr>'
|
|
172
|
-
+ '<td class="ledger__num ledger__place">' + esc(String(
|
|
233
|
+
+ '<td class="ledger__num ledger__place">' + esc(String(place || '—')) + idHtml(b) + '</td>'
|
|
173
234
|
+ '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
|
|
235
|
+
+ '<td class="ledger__roles">' + rolesHtml(b) + '</td>'
|
|
174
236
|
+ '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
|
|
175
|
-
+ '<td class="ledger__num">' + esc(fmtInt(b
|
|
237
|
+
+ '<td class="ledger__num">' + esc(fmtInt(creditOf(b))) + '</td>'
|
|
176
238
|
+ '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
|
|
177
|
-
+ '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' +
|
|
239
|
+
+ '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + tokens + '</td>'
|
|
178
240
|
+ '</tr>';
|
|
179
241
|
}
|
|
180
242
|
|
|
243
|
+
function columns() {
|
|
244
|
+
return ['Place', 'Builder', 'Roles', 'Rank',
|
|
245
|
+
{ label: role ? currencyLabel() + ' as ' + CRAFT_WORD[role].toLowerCase() : currencyLabel(), class: 'ledger__num' },
|
|
246
|
+
{ label: 'Karma', class: 'ledger__num' },
|
|
247
|
+
{ label: 'Tokens', class: 'ledger__num' }];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function makeRosterLedger() {
|
|
251
|
+
body.textContent = '';
|
|
252
|
+
body.className = '';
|
|
253
|
+
ledger = kit.makeLedger({
|
|
254
|
+
mount: body,
|
|
255
|
+
columns: columns(),
|
|
256
|
+
rowHtml: rowHtml,
|
|
257
|
+
emptyMsg: role
|
|
258
|
+
? 'No builder on the roster has earned as ' + CRAFT_WORD[role].toLowerCase() + ' yet, or none matches that search.'
|
|
259
|
+
: 'No builder on the roster matches that search.',
|
|
260
|
+
pager: pager,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
181
264
|
function rerender() {
|
|
182
265
|
if (ledger) ledger.render(ordered(all.filter(matches)));
|
|
183
266
|
}
|
|
184
267
|
|
|
268
|
+
// The role toggle (owner decision (b)): Everyone, then one per craft. A pressed
|
|
269
|
+
// chip ranks by the credits that craft earned and shows only the builders it
|
|
270
|
+
// is shown for. The ledger is rebuilt so the credits header names the craft.
|
|
271
|
+
function roleChipsHtml() {
|
|
272
|
+
return ROLE_CHIPS.map(function (c) {
|
|
273
|
+
return '<button type="button" class="roster-role" data-role="' + esc(c[0]) + '" aria-pressed="' + (c[0] === role) + '">' + esc(c[1]) + '</button>';
|
|
274
|
+
}).join('');
|
|
275
|
+
}
|
|
276
|
+
function mountRoleChips() {
|
|
277
|
+
var mount = document.getElementById('roster-roles');
|
|
278
|
+
if (!mount) return;
|
|
279
|
+
mount.innerHTML = roleChipsHtml();
|
|
280
|
+
mount.addEventListener('click', function (e) {
|
|
281
|
+
var btn = e.target.closest('[data-role]');
|
|
282
|
+
if (!btn || btn.dataset.role === role) return;
|
|
283
|
+
role = btn.dataset.role;
|
|
284
|
+
mount.querySelectorAll('[data-role]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.role === role)); });
|
|
285
|
+
pager.page = 1;
|
|
286
|
+
makeRosterLedger();
|
|
287
|
+
rerender();
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
185
291
|
function notice(msg) {
|
|
186
292
|
body.innerHTML = '';
|
|
187
293
|
body.className = 'note';
|
|
@@ -221,23 +327,16 @@
|
|
|
221
327
|
rerender();
|
|
222
328
|
},
|
|
223
329
|
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
ledger = kit.makeLedger({
|
|
227
|
-
mount: body,
|
|
228
|
-
columns: ['Place', 'Builder', 'Rank',
|
|
229
|
-
{ label: currencyLabel(), class: 'ledger__num' },
|
|
230
|
-
{ label: 'Karma', class: 'ledger__num' },
|
|
231
|
-
{ label: 'Tokens', class: 'ledger__num' }],
|
|
232
|
-
rowHtml: rowHtml,
|
|
233
|
-
emptyMsg: 'No builder on the roster matches that search.',
|
|
234
|
-
pager: pager,
|
|
235
|
-
});
|
|
330
|
+
mountRoleChips();
|
|
331
|
+
makeRosterLedger();
|
|
236
332
|
rerender();
|
|
237
333
|
}
|
|
238
334
|
|
|
239
335
|
var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
|
|
240
|
-
|
|
336
|
+
// The PUBLIC list (task 1004437): the same rows the authed /leaderboard
|
|
337
|
+
// carries plus each builder's roles, and the one the status page reads, so
|
|
338
|
+
// the hall and the public page cannot rank a role differently.
|
|
339
|
+
fetch('/api/bongos/public/leaderboard', { headers: { Accept: 'application/json' } })
|
|
241
340
|
.then(function (r) {
|
|
242
341
|
if (!r.ok) throw new Error('http ' + r.status);
|
|
243
342
|
return r.json();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/
|
|
2
|
+
"_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/public__leaderboard.json, since task 1004437 the list the page reads: fifteen builders with avatars, ranks, karma, tokens and each one's roles, including an artist and an ideator with no AI sessions).",
|
|
3
3
|
"page": "/builders/roster",
|
|
4
4
|
"surface": "hall-ui",
|
|
5
5
|
"stub": {
|
|
@@ -44,6 +44,31 @@
|
|
|
44
44
|
]
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
|
+
"artists": {
|
|
48
|
+
"_": "The role toggle with Artists pressed (task 1004437, owner decision (b)): only the builders shown as artists, ranked by the credits that craft earned, the credits header naming the craft. Against the harness the browser-only artist (2001) leads, with a dash where an AI builder's tokens would be. The harness serves public__leaderboard.json, which carries the roles.",
|
|
49
|
+
"auth": true,
|
|
50
|
+
"actions": [
|
|
51
|
+
[
|
|
52
|
+
"wait",
|
|
53
|
+
700
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
"click",
|
|
57
|
+
"[data-role=\"artist\"]"
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
"wait",
|
|
61
|
+
200
|
|
62
|
+
]
|
|
63
|
+
],
|
|
64
|
+
"expect": {
|
|
65
|
+
"visible": [
|
|
66
|
+
"#roster-scroll",
|
|
67
|
+
"#roster-roles",
|
|
68
|
+
".lb-roles"
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
47
72
|
"search": {
|
|
48
73
|
"auth": true,
|
|
49
74
|
"actions": [
|
|
@@ -8,3 +8,23 @@
|
|
|
8
8
|
- **It is deliberately UNGATED,** and `tests/hall_page_gate_map.mjs` spells that out rather than defaulting. Its one data source, `GET /leaderboard`, is deliberately unauthenticated and mirrors the public status surface; a page must not be gated tighter than the API it fronts, and it was already reachable to a stranger as a view on the ungated index. No visibility predicate is applied and that is correct — privacy suppresses cross-project surfaces only, so a `private` builder still appears on the roster of a project they build on (ADR 0225).
|
|
9
9
|
|
|
10
10
|
Pinned by `tests/hall_nav.mjs` (the Community group's middle id is `roster`) and `tests/hall_page_gate_map.mjs`. Measured through `roster.states.json` — whose `out` state renders the REAL page, since the page is ungated.
|
|
11
|
+
|
|
12
|
+
## The roles (task 1004437, WA6.RP07)
|
|
13
|
+
|
|
14
|
+
Owner decision (b), 2026-09-30: the leaderboard gets BOTH a column naming each builder's roles and the Everyone / Engineers / Artists / Ideators toggle above it. The approved mock is `docs/design/mocks/profile-roles/` (`?state=board`, `?state=board-artists`).
|
|
15
|
+
|
|
16
|
+
- **The roster now reads `GET /public/leaderboard`,** the list the status page reads, which carries each row's `roles` (`src/bongos/role-stats.js` `readRolesForBuilders`: the profile's own main/shown rules, two reads for the whole page) and, since this task, `karma`. The hall and the public page cannot rank a role differently. The authed `/leaderboard` is unchanged and no longer read by the hall.
|
|
17
|
+
- **Roles column:** one pill per role in `roles.shown`, the main one a step stronger. `shown` is the main role plus every craft the builder has EARNED in, so it is evidence of work, not only a declared preference.
|
|
18
|
+
- **The toggle** keeps the builders a craft is shown for and ranks them by the credits that craft earned (`roles.credits_by_craft`, in the row: ADR 0255). The place column becomes the place inside that craft, the credits header says "Credits as artist", and the karma/rank/tokens lenses still apply on top.
|
|
19
|
+
- **Tokens only where they apply.** A builder with no recorded AI session (an artist in the studio, an ideator in the room) reads a dash, and the cell's tooltip says no AI sessions were recorded, instead of a 0 and a "0 tokens per credit" figure that read as "did nothing".
|
|
20
|
+
- **Home's Standing card** reads the same list and adds "#N as an artist" beside "#N on the leaderboard" when the place inside the builder's main role differs from the overall one (`hall-render.js` `rolePlaceText`).
|
|
21
|
+
- **The status page** names each builder's roles under their name, on that page's own palette (`status.js` `renderRoles`).
|
|
22
|
+
|
|
23
|
+
Pinned by `tests/leaderboard_roles.mjs` (the read, the route, the REAL roster.js over the harness fixture with the toggle clicked, the Standing card's place and the status renderer, run). The `artists` state in `roster.states.json` renders the toggle pressed.
|
|
24
|
+
|
|
25
|
+
### Deliberate differences from the mock (RP07)
|
|
26
|
+
|
|
27
|
+
- **No "Best known for" column.** Each row's headline count (works shipped, pages approved, thoughts ignited) is a per-builder read in three different modules; the list would pay three more reads for every page of builders. The profile, one click away, leads with exactly that number.
|
|
28
|
+
- **The roster keeps its live columns**: place with the builder's number, rank, karma and tokens, the founding mark and the laurels, and its search and sort lenses. The mock draws the leaderboard bare; the live hall's chrome wins (the blend rule).
|
|
29
|
+
- **The column is "Roles", not "Preferred roles"**: it lists roles the builder has earned in as well as the one they chose, so "preferred" would understate it.
|
|
30
|
+
- **The status page has the roles but not the toggle.** It is a one-glance public dashboard of the top builders on its own palette; the toggle lives on the roster, which is the leaderboard a builder works from.
|
|
@@ -944,6 +944,29 @@ async function runPromotionSweep(deps = {}) {
|
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
+
// The craft-achievement backfill (task 1004438, WA6.RP08): the one badge a
|
|
948
|
+
// migration cannot backfill, "A Whole Surface", needs the page inventory, which is
|
|
949
|
+
// a file the database never sees. Run ONCE per process start, on the first tick,
|
|
950
|
+
// through the reward port (economy is optional; no port, no sweep). Pure DB and
|
|
951
|
+
// idempotent, so a restart that runs it again unlocks nothing twice; a failure is
|
|
952
|
+
// logged and never touches the other sweeps.
|
|
953
|
+
let craftBackfillDone = false;
|
|
954
|
+
async function runCraftAchievementBackfill(deps = {}) {
|
|
955
|
+
const { logFn, errFn } = loggers(deps);
|
|
956
|
+
if (craftBackfillDone && !deps.forceCraftBackfill) return { skipped: 'already_ran' };
|
|
957
|
+
craftBackfillDone = true;
|
|
958
|
+
const reward = deps.reward || seams.resolveOptional('reward');
|
|
959
|
+
if (!reward || typeof reward.backfillCraftAchievements !== 'function') return { skipped: 'unsupported' };
|
|
960
|
+
try {
|
|
961
|
+
const out = await reward.backfillCraftAchievements({});
|
|
962
|
+
if (out && out.unlocked) logFn(`[publish-reconciler] craft achievements: ${out.unlocked} unlocked across ${out.checked} builder(s)`);
|
|
963
|
+
return out;
|
|
964
|
+
} catch (e) {
|
|
965
|
+
errFn(`[publish-reconciler] craft achievement backfill (non-blocking): ${e && e.message}`);
|
|
966
|
+
return { skipped: 'error', error: e && e.message };
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
947
970
|
// Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
|
|
948
971
|
// by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
|
|
949
972
|
// default whenever a push credential is configured; the route's getPublishStatus
|
|
@@ -964,6 +987,7 @@ function start(deps = {}) {
|
|
|
964
987
|
runCostSweep(deps).catch(crashHandler('cost sweep'));
|
|
965
988
|
runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
|
|
966
989
|
runPromotionSweep(deps).catch(crashHandler('promotion sweep'));
|
|
990
|
+
runCraftAchievementBackfill(deps).catch(crashHandler('craft achievement backfill'));
|
|
967
991
|
};
|
|
968
992
|
|
|
969
993
|
firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
|
|
@@ -1002,6 +1026,7 @@ module.exports = {
|
|
|
1002
1026
|
runCostSweep,
|
|
1003
1027
|
runPromotionSweep,
|
|
1004
1028
|
runCriterionSweep,
|
|
1029
|
+
runCraftAchievementBackfill,
|
|
1005
1030
|
couldBeStaleGenerated,
|
|
1006
1031
|
describeTarget,
|
|
1007
1032
|
INTERVAL_MS,
|
|
@@ -1076,19 +1076,31 @@ function updateHeadlineShippedFromItems() {
|
|
|
1076
1076
|
|
|
1077
1077
|
// ---------- leaderboard ----------
|
|
1078
1078
|
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
'
|
|
1088
|
-
'
|
|
1089
|
-
'
|
|
1090
|
-
'
|
|
1091
|
-
'
|
|
1079
|
+
// The catalog is small and changes only by migration (the latest: the seven
|
|
1080
|
+
// craft achievements of task 1004438, migrations/core_270), so hardcoding it here
|
|
1081
|
+
// is cheaper than another API round-trip per page load. Each laurel carries its
|
|
1082
|
+
// emoji AND its catalog name (task 1004532), so a hover and a screen reader say
|
|
1083
|
+
// what the badge is called — never the id, and never words derived from it.
|
|
1084
|
+
// Mirrors modules/hall-ui/public/roster.js ACH — tests/craft_achievements.mjs
|
|
1085
|
+
// holds both maps to the catalog's ids, icons and names.
|
|
1086
|
+
const ACH = {
|
|
1087
|
+
'first-ship': ['🚢', 'First Ship'],
|
|
1088
|
+
'first-claim': ['🪙', 'First Claim'],
|
|
1089
|
+
'first-parallel-claim': ['⚓', 'First Parallel Claim'],
|
|
1090
|
+
'streak-3': ['🔥', 'Streak: 3 Nights'],
|
|
1091
|
+
'streak-7': ['🌋', 'Streak: 7 Nights'],
|
|
1092
|
+
// task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
|
|
1093
|
+
'art-pipeline-shipper': ['🛠️', 'Art Pipeline Engineer'],
|
|
1094
|
+
'subtractor': ['🪓', 'Subtractor'],
|
|
1095
|
+
'gds-shipper': ['⚙️', 'Bongos Shipper'],
|
|
1096
|
+
'breaking-the-100c-bar': ['💯', 'Breaking the 100c Bar'],
|
|
1097
|
+
'first-thought-ignited': ['💡', 'First Thought Ignited'],
|
|
1098
|
+
'first-full-idea': ['📜', 'First Full Idea'],
|
|
1099
|
+
'first-board-ratification': ['🏛️', 'Ratified by the Board'],
|
|
1100
|
+
'thought-built-ten': ['🌟', 'A Thought That Built Ten'],
|
|
1101
|
+
'first-page-approved': ['🎨', 'First Page Approved'],
|
|
1102
|
+
'ten-pages-approved': ['🖼️', 'Ten Pages Approved'],
|
|
1103
|
+
'whole-surface-tweaked': ['🗺️', 'A Whole Surface'],
|
|
1092
1104
|
};
|
|
1093
1105
|
|
|
1094
1106
|
function renderAchievementRow(ids) {
|
|
@@ -1097,18 +1109,37 @@ function renderAchievementRow(ids) {
|
|
|
1097
1109
|
row.className = 'leader__achievements';
|
|
1098
1110
|
row.setAttribute('aria-label', 'achievements');
|
|
1099
1111
|
for (const id of ids) {
|
|
1100
|
-
const
|
|
1101
|
-
if (!
|
|
1112
|
+
const a = ACH[id];
|
|
1113
|
+
if (!a) continue;
|
|
1102
1114
|
const span = document.createElement('span');
|
|
1103
1115
|
span.className = 'leader__ach';
|
|
1104
|
-
span.title =
|
|
1105
|
-
span.setAttribute('aria-label',
|
|
1106
|
-
span.textContent =
|
|
1116
|
+
span.title = a[1];
|
|
1117
|
+
span.setAttribute('aria-label', a[1]);
|
|
1118
|
+
span.textContent = a[0];
|
|
1107
1119
|
row.appendChild(span);
|
|
1108
1120
|
}
|
|
1109
1121
|
return row.children.length > 0 ? row : null;
|
|
1110
1122
|
}
|
|
1111
1123
|
|
|
1124
|
+
// task 1004437 (WA6.RP07): each row names its roles, main first — the crafts
|
|
1125
|
+
// the builder has EARNED in, from the row's own `roles.shown`, not only what
|
|
1126
|
+
// they declared. The same words the hall's roster uses. Text only, never markup.
|
|
1127
|
+
const ROLE_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
|
|
1128
|
+
function renderRoles(roles) {
|
|
1129
|
+
const shown = roles && Array.isArray(roles.shown) ? roles.shown.filter((c) => ROLE_WORD[c]) : [];
|
|
1130
|
+
if (!shown.length) return null;
|
|
1131
|
+
const row = document.createElement('span');
|
|
1132
|
+
row.className = 'leader__roles';
|
|
1133
|
+
row.setAttribute('aria-label', 'roles');
|
|
1134
|
+
shown.forEach((c, i) => {
|
|
1135
|
+
const chip = document.createElement('span');
|
|
1136
|
+
chip.className = i === 0 ? 'leader__role leader__role--lead' : 'leader__role';
|
|
1137
|
+
chip.textContent = ROLE_WORD[c];
|
|
1138
|
+
row.appendChild(chip);
|
|
1139
|
+
});
|
|
1140
|
+
return row;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1112
1143
|
async function loadLeaderboard() {
|
|
1113
1144
|
const data = await fetchJson('/api/bongos/public/leaderboard');
|
|
1114
1145
|
const rows = data.leaderboard || [];
|
|
@@ -1138,6 +1169,8 @@ async function loadLeaderboard() {
|
|
|
1138
1169
|
login.className = 'leader__login';
|
|
1139
1170
|
login.textContent = `@${b.github_login}`;
|
|
1140
1171
|
name.appendChild(login);
|
|
1172
|
+
const roles = renderRoles(b.roles);
|
|
1173
|
+
if (roles) name.appendChild(roles);
|
|
1141
1174
|
const badges = renderAchievementRow(b.achievements);
|
|
1142
1175
|
if (badges) name.appendChild(badges);
|
|
1143
1176
|
const credits = document.createElement('div');
|
|
@@ -749,6 +749,30 @@ main {
|
|
|
749
749
|
letter-spacing: 0;
|
|
750
750
|
}
|
|
751
751
|
|
|
752
|
+
/* The roles under each builder's name (task 1004437): one small tag per role
|
|
753
|
+
the builder has earned in, the main one a step stronger, as the hall's
|
|
754
|
+
roster and the approved mock draw them, on this page's own palette. */
|
|
755
|
+
.leader__roles {
|
|
756
|
+
display: flex;
|
|
757
|
+
flex-wrap: wrap;
|
|
758
|
+
gap: 4px;
|
|
759
|
+
margin-top: 4px;
|
|
760
|
+
}
|
|
761
|
+
.leader__role {
|
|
762
|
+
display: inline-flex;
|
|
763
|
+
align-items: center;
|
|
764
|
+
min-height: 22px;
|
|
765
|
+
padding: 0 8px;
|
|
766
|
+
border: 1px solid var(--rule-soft);
|
|
767
|
+
font-family: var(--font-body);
|
|
768
|
+
font-size: 0.78rem;
|
|
769
|
+
font-weight: 400;
|
|
770
|
+
letter-spacing: 0;
|
|
771
|
+
color: var(--ink-soft);
|
|
772
|
+
text-transform: none;
|
|
773
|
+
}
|
|
774
|
+
.leader__role--lead { color: var(--ink); border-color: var(--rule); }
|
|
775
|
+
|
|
752
776
|
/* Inline laurels next to each architect's name. Slightly dimmed so the
|
|
753
777
|
name still leads the row; emoji glyphs would otherwise punch above the
|
|
754
778
|
marble palette. PMS-V2 #54 */
|