@bongos/core 1.21.1 → 1.21.2
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 +51 -36
- package/docs/copy-inventory.md +50 -49
- package/docs/copy-registry.json +60 -51
- package/docs/module-api-changelog.md +3 -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/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 +121 -21
- 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 +39 -7
- package/modules/status-ui/public/style.css +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -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 +349 -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>
|
|
@@ -49,17 +49,31 @@
|
|
|
49
49
|
'first-parallel-claim': '⚓',
|
|
50
50
|
'streak-3': '\u{1F525}',
|
|
51
51
|
'streak-7': '\u{1F30B}',
|
|
52
|
-
|
|
52
|
+
// task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
|
|
53
|
+
'art-pipeline-shipper': '\u{1F6E0}\u{FE0F}',
|
|
54
|
+
'first-thought-ignited': '\u{1F4A1}',
|
|
55
|
+
'first-full-idea': '\u{1F4DC}',
|
|
56
|
+
'first-board-ratification': '\u{1F3DB}\u{FE0F}',
|
|
57
|
+
'thought-built-ten': '\u{1F31F}',
|
|
58
|
+
'first-page-approved': '\u{1F3A8}',
|
|
59
|
+
'ten-pages-approved': '\u{1F5BC}\u{FE0F}',
|
|
60
|
+
'whole-surface-tweaked': '\u{1F5FA}\u{FE0F}',
|
|
53
61
|
'gds-shipper': '⚙️',
|
|
54
62
|
'breaking-the-100c-bar': '\u{1F4AF}',
|
|
55
63
|
};
|
|
64
|
+
// An achievement id as a sentence: "first-page-approved" → "First page approved".
|
|
65
|
+
function achWords(id) {
|
|
66
|
+
var s = String(id || '').replace(/-/g, ' ');
|
|
67
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
68
|
+
}
|
|
56
69
|
function achRowHtml(ids) {
|
|
57
70
|
if (!Array.isArray(ids) || !ids.length) return '';
|
|
58
71
|
var inner = ids.map(function (id) {
|
|
59
72
|
var emoji = ACH_EMOJI[id];
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
73
|
+
// The title is the achievement's name as words ("First page approved"), not
|
|
74
|
+
// its id slug (task 1004438): it is what a hover shows and a screen reader says.
|
|
75
|
+
var words = achWords(id);
|
|
76
|
+
return emoji ? '<span class="lb-ach" title="' + esc(words) + '" aria-label="' + esc(words) + '">' + emoji + '</span>' : '';
|
|
63
77
|
}).join('');
|
|
64
78
|
return inner ? '<span class="lb-ach-row" aria-label="achievements">' + inner + '</span>' : '';
|
|
65
79
|
}
|
|
@@ -94,9 +108,34 @@
|
|
|
94
108
|
return '<span class="dir-who">' + avatar + '<span class="dir-who__t">' + nameHtml + login + '</span></span>';
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
// task 1004437 (WA6.RP07): the roles. Each row's `roles` block is the public
|
|
112
|
+
// list's own (src/bongos/role-stats.js readRolesForBuilders): `shown` is the
|
|
113
|
+
// main role first, then every craft the builder has EARNED in — evidence of
|
|
114
|
+
// work, not only what they declared — and `credits_by_craft` is the ledger
|
|
115
|
+
// split. Owner decision (b): a column naming them AND the role toggle above,
|
|
116
|
+
// which ranks by the credits earned in that craft. The figure a role ranks by
|
|
117
|
+
// is in the row (ADR 0255: a public list is ordered only by what it publishes).
|
|
118
|
+
var CRAFTS = ['engineer', 'artist', 'ideator'];
|
|
119
|
+
var CRAFT_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
|
|
120
|
+
var ROLE_CHIPS = [['', 'Everyone'], ['engineer', 'Engineers'], ['artist', 'Artists'], ['ideator', 'Ideators']];
|
|
121
|
+
function shownOf(b) { return (b.roles && Array.isArray(b.roles.shown)) ? b.roles.shown : []; }
|
|
122
|
+
function craftCredits(b, craft) {
|
|
123
|
+
var by = b.roles && b.roles.credits_by_craft;
|
|
124
|
+
return by ? (Number(by[craft]) || 0) : 0;
|
|
125
|
+
}
|
|
126
|
+
function creditOf(b) { return role ? craftCredits(b, role) : (Number(b.total_credits) || 0); }
|
|
127
|
+
function rolesHtml(b) {
|
|
128
|
+
var shown = shownOf(b);
|
|
129
|
+
if (!shown.length) return '<span class="lb-roles lb-roles--none">—</span>';
|
|
130
|
+
return '<span class="lb-roles">' + shown.map(function (c, i) {
|
|
131
|
+
return '<span class="lb-role' + (i === 0 ? ' lb-role--lead' : '') + '">' + esc(CRAFT_WORD[c] || c) + '</span>';
|
|
132
|
+
}).join('') + '</span>';
|
|
133
|
+
}
|
|
134
|
+
|
|
97
135
|
var all = [];
|
|
98
136
|
var query = '';
|
|
99
137
|
var sortBy = '';
|
|
138
|
+
var role = '';
|
|
100
139
|
|
|
101
140
|
// PLACE IS CANONICAL, not the row's position in the current sort.
|
|
102
141
|
// hall-render's leaderboardPlace() already defines a builder's place as
|
|
@@ -108,6 +147,16 @@
|
|
|
108
147
|
rows.slice()
|
|
109
148
|
.sort(function (a, b) { return (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0); })
|
|
110
149
|
.forEach(function (b, i) { b.__place = i + 1; });
|
|
150
|
+
// A role's place is the same idea inside that role: credits earned in the
|
|
151
|
+
// craft, descending, among the builders it is shown for. Stamped once too,
|
|
152
|
+
// so the place under a role toggle does not renumber under a search.
|
|
153
|
+
rows.forEach(function (b) { b.__placeBy = {}; });
|
|
154
|
+
CRAFTS.forEach(function (c) {
|
|
155
|
+
rows.filter(function (b) { return shownOf(b).indexOf(c) !== -1; })
|
|
156
|
+
.map(function (b, i) { return { b: b, i: i }; })
|
|
157
|
+
.sort(function (x, y) { return (craftCredits(y.b, c) - craftCredits(x.b, c)) || (x.i - y.i); })
|
|
158
|
+
.forEach(function (x, i) { x.b.__placeBy[c] = i + 1; });
|
|
159
|
+
});
|
|
111
160
|
// The efficiency title is stamped here too, so the division and the string
|
|
112
161
|
// work happen ONCE per load rather than per visible row per render (every
|
|
113
162
|
// sort, search keystroke and page turn re-renders).
|
|
@@ -116,6 +165,7 @@
|
|
|
116
165
|
}
|
|
117
166
|
|
|
118
167
|
function matches(b) {
|
|
168
|
+
if (role && shownOf(b).indexOf(role) === -1) return false;
|
|
119
169
|
if (!query) return true;
|
|
120
170
|
var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
|
|
121
171
|
return hay.indexOf(query) !== -1;
|
|
@@ -130,7 +180,8 @@
|
|
|
130
180
|
tokens: function (b) { return -(Number(b.tokens_used) || 0); },
|
|
131
181
|
};
|
|
132
182
|
function ordered(rows) {
|
|
133
|
-
|
|
183
|
+
// The default lens is credits; under a role it is the credits that role earned.
|
|
184
|
+
var key = SORTS[sortBy] || (role ? function (b) { return -craftCredits(b, role); } : null);
|
|
134
185
|
if (!key) return rows;
|
|
135
186
|
return rows.map(function (b, i) { return { b: b, i: i }; })
|
|
136
187
|
.sort(function (x, y) { return (key(x.b) - key(y.b)) || (x.i - y.i); })
|
|
@@ -140,7 +191,14 @@
|
|
|
140
191
|
var pager = kit.makePager('roster', 'hall-roster-per-page', { defaultPerPage: 25 });
|
|
141
192
|
var ledger = null;
|
|
142
193
|
|
|
194
|
+
// Tokens only mean something for AI sessions. A builder who works in the
|
|
195
|
+
// browser (an artist in the studio, an ideator in the room) records none, and
|
|
196
|
+
// a 0 beside them reads as "did nothing" (task 1004437): their cell says so in
|
|
197
|
+
// words instead, and the tokens-per-credit figure is offered only where there
|
|
198
|
+
// are tokens to divide.
|
|
199
|
+
function hasTokens(b) { return (Number(b.tokens_used) || 0) > 0; }
|
|
143
200
|
function efficiencyTitle(b) {
|
|
201
|
+
if (!hasTokens(b)) return 'No AI sessions recorded — this builder’s work is not measured in tokens';
|
|
144
202
|
var credits = Number(b.total_credits) || 0;
|
|
145
203
|
var one = currencyLabel().toLowerCase().replace(/s$/, '');
|
|
146
204
|
if (credits <= 0) return 'no ' + currencyLabel().toLowerCase() + ' earned yet';
|
|
@@ -168,20 +226,69 @@
|
|
|
168
226
|
}
|
|
169
227
|
|
|
170
228
|
function rowHtml(b) {
|
|
229
|
+
var place = role ? (b.__placeBy && b.__placeBy[role]) : b.__place;
|
|
230
|
+
var tokens = hasTokens(b)
|
|
231
|
+
? esc(fmtInt(b.tokens_used))
|
|
232
|
+
: '<span class="lb-none" aria-label="No AI sessions">—</span>';
|
|
171
233
|
return '<tr>'
|
|
172
|
-
+ '<td class="ledger__num ledger__place">' + esc(String(
|
|
234
|
+
+ '<td class="ledger__num ledger__place">' + esc(String(place || '—')) + idHtml(b) + '</td>'
|
|
173
235
|
+ '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
|
|
236
|
+
+ '<td class="ledger__roles">' + rolesHtml(b) + '</td>'
|
|
174
237
|
+ '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
|
|
175
|
-
+ '<td class="ledger__num">' + esc(fmtInt(b
|
|
238
|
+
+ '<td class="ledger__num">' + esc(fmtInt(creditOf(b))) + '</td>'
|
|
176
239
|
+ '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
|
|
177
|
-
+ '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' +
|
|
240
|
+
+ '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + tokens + '</td>'
|
|
178
241
|
+ '</tr>';
|
|
179
242
|
}
|
|
180
243
|
|
|
244
|
+
function columns() {
|
|
245
|
+
return ['Place', 'Builder', 'Roles', 'Rank',
|
|
246
|
+
{ label: role ? currencyLabel() + ' as ' + CRAFT_WORD[role].toLowerCase() : currencyLabel(), class: 'ledger__num' },
|
|
247
|
+
{ label: 'Karma', class: 'ledger__num' },
|
|
248
|
+
{ label: 'Tokens', class: 'ledger__num' }];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function makeRosterLedger() {
|
|
252
|
+
body.textContent = '';
|
|
253
|
+
body.className = '';
|
|
254
|
+
ledger = kit.makeLedger({
|
|
255
|
+
mount: body,
|
|
256
|
+
columns: columns(),
|
|
257
|
+
rowHtml: rowHtml,
|
|
258
|
+
emptyMsg: role
|
|
259
|
+
? 'No builder on the roster has earned as ' + CRAFT_WORD[role].toLowerCase() + ' yet, or none matches that search.'
|
|
260
|
+
: 'No builder on the roster matches that search.',
|
|
261
|
+
pager: pager,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
181
265
|
function rerender() {
|
|
182
266
|
if (ledger) ledger.render(ordered(all.filter(matches)));
|
|
183
267
|
}
|
|
184
268
|
|
|
269
|
+
// The role toggle (owner decision (b)): Everyone, then one per craft. A pressed
|
|
270
|
+
// chip ranks by the credits that craft earned and shows only the builders it
|
|
271
|
+
// is shown for. The ledger is rebuilt so the credits header names the craft.
|
|
272
|
+
function roleChipsHtml() {
|
|
273
|
+
return ROLE_CHIPS.map(function (c) {
|
|
274
|
+
return '<button type="button" class="roster-role" data-role="' + esc(c[0]) + '" aria-pressed="' + (c[0] === role) + '">' + esc(c[1]) + '</button>';
|
|
275
|
+
}).join('');
|
|
276
|
+
}
|
|
277
|
+
function mountRoleChips() {
|
|
278
|
+
var mount = document.getElementById('roster-roles');
|
|
279
|
+
if (!mount) return;
|
|
280
|
+
mount.innerHTML = roleChipsHtml();
|
|
281
|
+
mount.addEventListener('click', function (e) {
|
|
282
|
+
var btn = e.target.closest('[data-role]');
|
|
283
|
+
if (!btn || btn.dataset.role === role) return;
|
|
284
|
+
role = btn.dataset.role;
|
|
285
|
+
mount.querySelectorAll('[data-role]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.role === role)); });
|
|
286
|
+
pager.page = 1;
|
|
287
|
+
makeRosterLedger();
|
|
288
|
+
rerender();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
185
292
|
function notice(msg) {
|
|
186
293
|
body.innerHTML = '';
|
|
187
294
|
body.className = 'note';
|
|
@@ -221,23 +328,16 @@
|
|
|
221
328
|
rerender();
|
|
222
329
|
},
|
|
223
330
|
});
|
|
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
|
-
});
|
|
331
|
+
mountRoleChips();
|
|
332
|
+
makeRosterLedger();
|
|
236
333
|
rerender();
|
|
237
334
|
}
|
|
238
335
|
|
|
239
336
|
var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
|
|
240
|
-
|
|
337
|
+
// The PUBLIC list (task 1004437): the same rows the authed /leaderboard
|
|
338
|
+
// carries plus each builder's roles, and the one the status page reads, so
|
|
339
|
+
// the hall and the public page cannot rank a role differently.
|
|
340
|
+
fetch('/api/bongos/public/leaderboard', { headers: { Accept: 'application/json' } })
|
|
241
341
|
.then(function (r) {
|
|
242
342
|
if (!r.ok) throw new Error('http ' + r.status);
|
|
243
343
|
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,17 +1076,26 @@ function updateHeadlineShippedFromItems() {
|
|
|
1076
1076
|
|
|
1077
1077
|
// ---------- leaderboard ----------
|
|
1078
1078
|
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
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 the
|
|
1081
|
+
// emoji map here is cheaper than another API round-trip per page load. Mirrors
|
|
1082
|
+
// modules/hall-ui/public/roster.js ACH_EMOJI — tests/craft_achievements.mjs
|
|
1083
|
+
// holds both maps to the migration's ids and icons.
|
|
1083
1084
|
const ACH_EMOJI = {
|
|
1084
1085
|
'first-ship': '🚢',
|
|
1085
1086
|
'first-claim': '🪙',
|
|
1086
1087
|
'first-parallel-claim': '⚓',
|
|
1087
1088
|
'streak-3': '🔥',
|
|
1088
1089
|
'streak-7': '🌋',
|
|
1089
|
-
|
|
1090
|
+
// task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
|
|
1091
|
+
'art-pipeline-shipper': '🛠️',
|
|
1092
|
+
'first-thought-ignited': '💡',
|
|
1093
|
+
'first-full-idea': '📜',
|
|
1094
|
+
'first-board-ratification': '🏛️',
|
|
1095
|
+
'thought-built-ten': '🌟',
|
|
1096
|
+
'first-page-approved': '🎨',
|
|
1097
|
+
'ten-pages-approved': '🖼️',
|
|
1098
|
+
'whole-surface-tweaked': '🗺️',
|
|
1090
1099
|
'gds-shipper': '⚙️',
|
|
1091
1100
|
'breaking-the-100c-bar': '💯',
|
|
1092
1101
|
};
|
|
@@ -1101,14 +1110,35 @@ function renderAchievementRow(ids) {
|
|
|
1101
1110
|
if (!emoji) continue;
|
|
1102
1111
|
const span = document.createElement('span');
|
|
1103
1112
|
span.className = 'leader__ach';
|
|
1104
|
-
|
|
1105
|
-
|
|
1113
|
+
// the name as words ("First page approved"), not the id slug (task 1004438)
|
|
1114
|
+
const words = String(id).replace(/-/g, ' ');
|
|
1115
|
+
span.title = words.charAt(0).toUpperCase() + words.slice(1);
|
|
1116
|
+
span.setAttribute('aria-label', span.title);
|
|
1106
1117
|
span.textContent = emoji;
|
|
1107
1118
|
row.appendChild(span);
|
|
1108
1119
|
}
|
|
1109
1120
|
return row.children.length > 0 ? row : null;
|
|
1110
1121
|
}
|
|
1111
1122
|
|
|
1123
|
+
// task 1004437 (WA6.RP07): each row names its roles, main first — the crafts
|
|
1124
|
+
// the builder has EARNED in, from the row's own `roles.shown`, not only what
|
|
1125
|
+
// they declared. The same words the hall's roster uses. Text only, never markup.
|
|
1126
|
+
const ROLE_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
|
|
1127
|
+
function renderRoles(roles) {
|
|
1128
|
+
const shown = roles && Array.isArray(roles.shown) ? roles.shown.filter((c) => ROLE_WORD[c]) : [];
|
|
1129
|
+
if (!shown.length) return null;
|
|
1130
|
+
const row = document.createElement('span');
|
|
1131
|
+
row.className = 'leader__roles';
|
|
1132
|
+
row.setAttribute('aria-label', 'roles');
|
|
1133
|
+
shown.forEach((c, i) => {
|
|
1134
|
+
const chip = document.createElement('span');
|
|
1135
|
+
chip.className = i === 0 ? 'leader__role leader__role--lead' : 'leader__role';
|
|
1136
|
+
chip.textContent = ROLE_WORD[c];
|
|
1137
|
+
row.appendChild(chip);
|
|
1138
|
+
});
|
|
1139
|
+
return row;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1112
1142
|
async function loadLeaderboard() {
|
|
1113
1143
|
const data = await fetchJson('/api/bongos/public/leaderboard');
|
|
1114
1144
|
const rows = data.leaderboard || [];
|
|
@@ -1138,6 +1168,8 @@ async function loadLeaderboard() {
|
|
|
1138
1168
|
login.className = 'leader__login';
|
|
1139
1169
|
login.textContent = `@${b.github_login}`;
|
|
1140
1170
|
name.appendChild(login);
|
|
1171
|
+
const roles = renderRoles(b.roles);
|
|
1172
|
+
if (roles) name.appendChild(roles);
|
|
1141
1173
|
const badges = renderAchievementRow(b.achievements);
|
|
1142
1174
|
if (badges) name.appendChild(badges);
|
|
1143
1175
|
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 */
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.2",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.2",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8662,6 +8662,10 @@
|
|
|
8662
8662
|
],
|
|
8663
8663
|
"1.21.0": [],
|
|
8664
8664
|
"1.21.1": [
|
|
8665
|
+
{
|
|
8666
|
+
"id": "1004437",
|
|
8667
|
+
"text": "The builders list now shows each person's roles (Engineer, Artist, Ideator, main one first), counting a role once the person has earned credits in it. Buttons above it (Everyone, Engineers, Artists, Ideators) rank people by th"
|
|
8668
|
+
},
|
|
8665
8669
|
{
|
|
8666
8670
|
"id": "1004522",
|
|
8667
8671
|
"text": "The Governor City design now lives in the project itself, not only in a private design canvas. This is the design where a governor's home is a small animated city and each part of the government is a building. All six design f"
|
|
@@ -8694,5 +8698,11 @@
|
|
|
8694
8698
|
"id": "1004505",
|
|
8695
8699
|
"text": "A new project's address now works the first time you open it. The platform creates the address before anything else, and the hub only links it once the platform has seen it answer. Before, an early click could leave your home"
|
|
8696
8700
|
}
|
|
8701
|
+
],
|
|
8702
|
+
"1.21.2": [
|
|
8703
|
+
{
|
|
8704
|
+
"id": "1004438",
|
|
8705
|
+
"text": "Artists and ideators now earn achievements for their own work, not only for shipping code. Ideators: First Thought Ignited, First Full Idea, Ratified by the Board, and A Thought That Built Ten. Artists: First Page Approved, Te"
|
|
8706
|
+
}
|
|
8697
8707
|
]
|
|
8698
8708
|
}
|