@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
|
@@ -76,6 +76,8 @@ const SNAPSHOTS = [
|
|
|
76
76
|
['public/grades?days=7', 'public__grades'],
|
|
77
77
|
['public/repo-health?days=30', 'public__repo-health'],
|
|
78
78
|
['public/bounty-table', 'public__bounty-table'],
|
|
79
|
+
// the roster and the Standing card read the public list, with its roles (task 1004437)
|
|
80
|
+
['public/leaderboard', 'public__leaderboard'],
|
|
79
81
|
['public/cost-summary', 'public__cost-summary'],
|
|
80
82
|
['public/progress', 'public__progress'],
|
|
81
83
|
['public/recent-shipped', 'public__recent-shipped'],
|
package/src/bongos/db.js
CHANGED
|
@@ -207,6 +207,18 @@ async function getBuilderRoleChoice(builderId) {
|
|
|
207
207
|
return rows[0] ?? null;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// The same two facts for a whole list of builders, in ONE statement (task
|
|
211
|
+
// 1004437): the public leaderboard names every row's roles. Map<idString, row>.
|
|
212
|
+
async function getBuilderRoleChoices(builderIds) {
|
|
213
|
+
const ids = (builderIds || []).map(String).filter((v) => /^[1-9]\d*$/.test(v));
|
|
214
|
+
if (!ids.length) return new Map();
|
|
215
|
+
const { rows } = await pool.query(
|
|
216
|
+
`SELECT id, preferred_disciplines, main_discipline FROM builders WHERE id = ANY($1::bigint[])`,
|
|
217
|
+
[ids]
|
|
218
|
+
);
|
|
219
|
+
return new Map(rows.map((r) => [String(r.id), r]));
|
|
220
|
+
}
|
|
221
|
+
|
|
210
222
|
// The builder-PREFS db slice — the five per-builder preference jsonb columns on
|
|
211
223
|
// `builders` (skill_model_prefs / sound_prefs / behavior_prefs / event_sound_prefs)
|
|
212
224
|
// — was carved into modules/builder-settings/db.js (BV1.R80 / ADR 0093 §1). Core
|
|
@@ -1103,6 +1115,7 @@ module.exports = {
|
|
|
1103
1115
|
updateBuilderDisciplines,
|
|
1104
1116
|
UPDATE_BUILDER_DISCIPLINES_SQL,
|
|
1105
1117
|
getBuilderRoleChoice,
|
|
1118
|
+
getBuilderRoleChoices,
|
|
1106
1119
|
RANKS,
|
|
1107
1120
|
setBuilderRank,
|
|
1108
1121
|
getLatestRankChangeForBuilder,
|
package/src/bongos/role-stats.js
CHANGED
|
@@ -149,7 +149,41 @@ async function readRoleStats(builderId, { shippedCount = 0, getRoleChoice } = {}
|
|
|
149
149
|
return composeRoleStats({ choice, byCraft, shippedCount, artist, ideator });
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
//
|
|
152
|
+
// readRolesForBuilders — the same main/shown rules for a WHOLE LIST at once
|
|
153
|
+
// (task 1004437 / WA6.RP07): the public leaderboard names each builder's roles
|
|
154
|
+
// and ranks a role by the credits earned in it, so every row needs its `shown`
|
|
155
|
+
// crafts and its ledger split. Two reads for the whole page of rows, never one
|
|
156
|
+
// per row: the role choices (`getRoleChoices`, one statement) and economy's
|
|
157
|
+
// `creditsByCraft`, which already batches. Returns Map<idString, { main,
|
|
158
|
+
// main_basis, shown, credits_by_craft }>, every id asked for present. Counts and
|
|
159
|
+
// sums only, as the profile's tally is: nothing here is behind a sign-in.
|
|
160
|
+
async function readRolesForBuilders(builderIds, { getRoleChoices } = {}) {
|
|
161
|
+
const ids = [...new Set((builderIds || []).map(String).filter((v) => /^[1-9]\d*$/.test(v)))];
|
|
162
|
+
const out = new Map();
|
|
163
|
+
if (!ids.length) return out;
|
|
164
|
+
const reward = seams.resolveOptional('reward');
|
|
165
|
+
const [choices, byCraftMap] = await Promise.all([
|
|
166
|
+
getRoleChoices ? getRoleChoices(ids) : new Map(),
|
|
167
|
+
reward && typeof reward.creditsByCraft === 'function' ? reward.creditsByCraft(ids) : null,
|
|
168
|
+
]);
|
|
169
|
+
for (const id of ids) {
|
|
170
|
+
const choice = (choices && choices.get(id)) || null;
|
|
171
|
+
const byCraft = byCraftMap ? (byCraftMap.get(id) || null) : null;
|
|
172
|
+
const preferred = (choice && choice.preferred_disciplines) || [];
|
|
173
|
+
const { main, basis } = resolveMainRole({ mainDiscipline: choice && choice.main_discipline, preferred, byCraft });
|
|
174
|
+
out.set(id, {
|
|
175
|
+
main,
|
|
176
|
+
main_basis: basis,
|
|
177
|
+
shown: shownCrafts({ main, preferred, byCraft }),
|
|
178
|
+
credits_by_craft: byCraft
|
|
179
|
+
? { engineer: byCraft.engineer, artist: byCraft.artist, ideator: byCraft.ideator, other: byCraft.other, total: byCraft.total }
|
|
180
|
+
: null,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Only the readers are exported: the pure halves above are reached through them
|
|
153
187
|
// (tests/profile_role_stats.mjs drives them via the real route), and an export
|
|
154
188
|
// nothing imports is dead surface to the knip ratchet.
|
|
155
|
-
module.exports = { readRoleStats };
|
|
189
|
+
module.exports = { readRoleStats, readRolesForBuilders };
|
|
@@ -37,6 +37,7 @@ const { clientBranding, branding } = require('../../branding');
|
|
|
37
37
|
const { clientModules } = require('../../modules');
|
|
38
38
|
const { publicReadRateLimitMiddleware } = require('../middleware/rate-limit');
|
|
39
39
|
const { parseId, corsPublicGet, parsePagination, pageMeta } = require('./_helpers');
|
|
40
|
+
const { readRolesForBuilders } = require('../role-stats');
|
|
40
41
|
// task 1003208: structured logging — was console.*. A core file logs through
|
|
41
42
|
// the kernel logger directly; only MODULES go via api.logger (the doorway).
|
|
42
43
|
const log = require('../logger').logger.child({ src: 'public' });
|
|
@@ -235,9 +236,19 @@ module.exports = function buildPublicRouter() {
|
|
|
235
236
|
// 100 preserves the prior ceiling. `page` is additive (readers of .leaderboard
|
|
236
237
|
// unchanged). Mirrors the authed /leaderboard.
|
|
237
238
|
const { limit, offset } = parsePagination(req, { defaultLimit: 100, maxLimit: 200 });
|
|
238
|
-
const rows = await seams.resolveOptional('reward').getLeaderboardWithAchievements(offset + limit, 5);
|
|
239
|
+
const rows = (await seams.resolveOptional('reward').getLeaderboardWithAchievements(offset + limit, 5)).slice(offset, offset + limit);
|
|
240
|
+
// task 1004437 (WA6.RP07): each row names its roles, main first, and carries
|
|
241
|
+
// the credits each craft earned, so a role filter ranks by a figure the row
|
|
242
|
+
// itself publishes (ADR 0255). Two reads for the page, not one per row. A
|
|
243
|
+
// failure costs the roles, never the board: the rows still go out.
|
|
244
|
+
try {
|
|
245
|
+
const roles = await readRolesForBuilders(rows.map((r) => r.id), { getRoleChoices: db.getBuilderRoleChoices });
|
|
246
|
+
for (const r of rows) r.roles = roles.get(String(r.id)) || null;
|
|
247
|
+
} catch (err) {
|
|
248
|
+
log.error({ err }, 'leaderboard roles failed');
|
|
249
|
+
}
|
|
239
250
|
res.set('Cache-Control', 'public, max-age=60');
|
|
240
|
-
res.json({ leaderboard: rows
|
|
251
|
+
res.json({ leaderboard: rows, page: pageMeta(limit, offset) });
|
|
241
252
|
});
|
|
242
253
|
|
|
243
254
|
// rank: public — est-vs-actual aggregate.
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.2'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -100,6 +100,19 @@ function fakeClient({ eligible = new Set(), unlocked = new Set(), credits = 0 })
|
|
|
100
100
|
if (s.includes("reason = 'ship.net_negative'")) {
|
|
101
101
|
return { rows: [{ n: eligible.has('subtractor') ? 3 : 0 }], rowCount: 1 };
|
|
102
102
|
}
|
|
103
|
+
// the craft achievements (task 1004438): ONE facts read answers all seven
|
|
104
|
+
if (s.startsWith('select bool_or(reason like $2) as ignited')) {
|
|
105
|
+
return {
|
|
106
|
+
rows: [{
|
|
107
|
+
ignited: eligible.has('first-thought-ignited'),
|
|
108
|
+
full_idea: eligible.has('first-full-idea'),
|
|
109
|
+
ratified: eligible.has('first-board-ratification'),
|
|
110
|
+
pages: eligible.has('ten-pages-approved') ? 10 : eligible.has('first-page-approved') ? 1 : 0,
|
|
111
|
+
best_thought_tasks: eligible.has('thought-built-ten') ? 10 : 0,
|
|
112
|
+
}],
|
|
113
|
+
rowCount: 1,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
103
116
|
return no;
|
|
104
117
|
});
|
|
105
118
|
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// tests/craft_achievements.mjs — achievements for artist and ideator moments
|
|
2
|
+
// (task 1004438 / WA6.RP08, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: "fixtures earn each new achievement exactly once, and existing
|
|
5
|
+
// artists and ideators get theirs on backfill". Run for real:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE CATALOG. migrations/core_270 seeds seven craft achievements, renames the
|
|
8
|
+
// misleading art-pipeline-shipper, and its LIKE literals are economy's own
|
|
9
|
+
// reason prefixes (a drift there awards nothing and errors nowhere).
|
|
10
|
+
// 2. THE RECONCILE. The REAL reconcileBuilderAchievements over a stateful fake
|
|
11
|
+
// ledger: an ideator and an artist earn each badge at its threshold, and a
|
|
12
|
+
// second and third reconcile unlock nothing more.
|
|
13
|
+
// 3. THE WRITERS. The REAL awardIdeaFullPassCredit and awardArtistPageCredit pay
|
|
14
|
+
// the payee AND put the moment on their shelf — the payment happens inside
|
|
15
|
+
// someone else's ship, so the shipper's reconcile would never reach them —
|
|
16
|
+
// and a reconcile that throws never fails the payment.
|
|
17
|
+
// 4. THE BACKFILL. The migration's backfill statements key on the same facts and
|
|
18
|
+
// stamp the FIRST qualifying credit, idempotently.
|
|
19
|
+
// 5. THE PICTURES. The roster's and the status page's emoji maps carry every id
|
|
20
|
+
// with the migration's own icon.
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { makeRunner } from './helpers.mjs';
|
|
28
|
+
|
|
29
|
+
process.env.NODE_ENV = 'test';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8').replace(/\r\n/g, '\n');
|
|
34
|
+
const MIGRATION = read('migrations/core_270_craft_achievements.sql');
|
|
35
|
+
const seams = require('../src/module-seams.js');
|
|
36
|
+
const achievements = require('../modules/economy/achievements.js');
|
|
37
|
+
const credits = require('../modules/economy/credits.js');
|
|
38
|
+
|
|
39
|
+
const CRAFT = ['first-thought-ignited', 'first-full-idea', 'first-board-ratification', 'thought-built-ten',
|
|
40
|
+
'first-page-approved', 'ten-pages-approved', 'whole-surface-tweaked'];
|
|
41
|
+
|
|
42
|
+
// the catalog rows, read out of the migration's VALUES list
|
|
43
|
+
function catalog() {
|
|
44
|
+
const out = new Map();
|
|
45
|
+
for (const m of MIGRATION.matchAll(/\('([a-z0-9-]+)', '([^']+)',\s*'([^']*)',\s*'([^']+)', (\d+), (\d+)\)/g)) {
|
|
46
|
+
out.set(m[1], { id: m[1], name: m[2], description: m[3], icon: m[4], credit_bonus: Number(m[5]), sort_order: Number(m[6]) });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
const CATALOG = catalog();
|
|
51
|
+
|
|
52
|
+
// ---- a stateful fake ledger ---------------------------------------------------------
|
|
53
|
+
// Understands exactly the statements the reconcile and the two writers send; every
|
|
54
|
+
// other read answers "nothing" (the engineer evaluators find no claims, no streak).
|
|
55
|
+
const likeRe = (pat) => new RegExp('^' + pat.replace(/[.*+?^${}()|[\]]/g, (c) => `\\${c}`).replace(/\\\\_/g, '_').replace(/%/g, '.*') + '$');
|
|
56
|
+
function fakeLedger({ failReconcile = false } = {}) {
|
|
57
|
+
const ledger = [];
|
|
58
|
+
const unlocked = [];
|
|
59
|
+
const sql = [];
|
|
60
|
+
let nextId = 1;
|
|
61
|
+
const rowsFor = (builderId, pat) => ledger.filter((r) => String(r.builder_id) === String(builderId) && likeRe(pat).test(r.reason) && r.delta > 0);
|
|
62
|
+
const client = {
|
|
63
|
+
ledger, unlocked, sql,
|
|
64
|
+
async query(text, params = []) {
|
|
65
|
+
const q = text.replace(/\s+/g, ' ').trim();
|
|
66
|
+
sql.push(q);
|
|
67
|
+
if (/^(SAVEPOINT|RELEASE SAVEPOINT|ROLLBACK TO SAVEPOINT)/.test(q)) return { rows: [], rowCount: 0 };
|
|
68
|
+
if (q.startsWith('SELECT 1 FROM builder_achievements WHERE builder_id=$1 AND achievement_id=$2')) {
|
|
69
|
+
if (failReconcile) throw new Error('reconcile blew up');
|
|
70
|
+
const hit = unlocked.some((u) => String(u.builder_id) === String(params[0]) && u.achievement_id === params[1]);
|
|
71
|
+
return { rows: hit ? [{}] : [], rowCount: hit ? 1 : 0 };
|
|
72
|
+
}
|
|
73
|
+
if (q.startsWith('SELECT id, name, credit_bonus FROM achievements WHERE id=$1')) {
|
|
74
|
+
const a = CATALOG.get(params[0]) || (params[0] === 'first-ship' ? null : null);
|
|
75
|
+
return a ? { rows: [a], rowCount: 1 } : { rows: [], rowCount: 0 };
|
|
76
|
+
}
|
|
77
|
+
if (q.startsWith('INSERT INTO builder_achievements')) {
|
|
78
|
+
if (!unlocked.some((u) => String(u.builder_id) === String(params[0]) && u.achievement_id === params[1])) {
|
|
79
|
+
unlocked.push({ builder_id: String(params[0]), achievement_id: params[1], task: params[2] });
|
|
80
|
+
}
|
|
81
|
+
return { rows: [], rowCount: 1 };
|
|
82
|
+
}
|
|
83
|
+
if (q.startsWith('INSERT INTO credit_log')) {
|
|
84
|
+
// the writers' INSERT ... SELECT ... WHERE NOT EXISTS (per builder+reason, or task+reason)
|
|
85
|
+
const [builderId, taskId, delta, reason, description] = params;
|
|
86
|
+
const exists = q.includes('WHERE task_id = $2 AND reason = $4')
|
|
87
|
+
? ledger.some((r) => String(r.task_id) === String(taskId) && r.reason === reason)
|
|
88
|
+
: ledger.some((r) => String(r.builder_id) === String(builderId) && r.reason === reason);
|
|
89
|
+
if (exists) return { rows: [], rowCount: 0 };
|
|
90
|
+
ledger.push({ id: nextId++, builder_id: String(builderId), task_id: taskId, delta, reason, description: description || '' });
|
|
91
|
+
return { rows: [], rowCount: 1 };
|
|
92
|
+
}
|
|
93
|
+
if (q.startsWith('SELECT achievement_id FROM builder_achievements WHERE builder_id = $1 AND achievement_id = ANY($2::text[])')) {
|
|
94
|
+
if (failReconcile) throw new Error('reconcile blew up');
|
|
95
|
+
return { rows: unlocked.filter((u) => u.builder_id === String(params[0]) && params[1].includes(u.achievement_id)).map((u) => ({ achievement_id: u.achievement_id })) };
|
|
96
|
+
}
|
|
97
|
+
if (q.startsWith('SELECT bool_or(reason LIKE $2) AS ignited')) {
|
|
98
|
+
// the craft facts, computed the way the statement says: one row of aggregates
|
|
99
|
+
const [b, ship, full, board, page] = params;
|
|
100
|
+
const byIdea = new Map();
|
|
101
|
+
for (const r of rowsFor(b, ship)) {
|
|
102
|
+
const [, idea, task] = r.reason.split(':');
|
|
103
|
+
if (!byIdea.has(idea)) byIdea.set(idea, new Set());
|
|
104
|
+
byIdea.get(idea).add(task);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
rows: [{
|
|
108
|
+
ignited: rowsFor(b, ship).length > 0 ? true : null,
|
|
109
|
+
full_idea: rowsFor(b, full).length > 0 ? true : null,
|
|
110
|
+
ratified: rowsFor(b, board).length > 0 ? true : null,
|
|
111
|
+
pages: rowsFor(b, page).length,
|
|
112
|
+
best_thought_tasks: Math.max(0, ...[...byIdea.values()].map((s) => s.size)),
|
|
113
|
+
}],
|
|
114
|
+
rowCount: 1,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (q.startsWith('SELECT DISTINCT builder_id FROM credit_log')) {
|
|
118
|
+
const pats = params.slice(0, 4).map(likeRe);
|
|
119
|
+
const ids = [...new Set(ledger.filter((r) => r.delta > 0 && pats.some((re) => re.test(r.reason))).map((r) => r.builder_id))].sort();
|
|
120
|
+
return { rows: ids.map((builder_id) => ({ builder_id })) };
|
|
121
|
+
}
|
|
122
|
+
if (q.startsWith('SELECT COUNT(*)::int AS n FROM credit_log')) return { rows: [{ n: 0 }], rowCount: 1 };
|
|
123
|
+
if (q.startsWith('SELECT total_credits FROM builders')) return { rows: [{ total_credits: 0 }], rowCount: 1 };
|
|
124
|
+
// a page-tweak task read by awardArtistPageCredit
|
|
125
|
+
if (q.includes('FROM tasks t') && q.includes('artist_id')) {
|
|
126
|
+
return { rows: [{ source: 'page-tweak', status: 'shipped', credits_reward: 30, kind: 'feature', artist_id: '2001' }], rowCount: 1 };
|
|
127
|
+
}
|
|
128
|
+
return { rows: [], rowCount: 0 };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
return client;
|
|
132
|
+
}
|
|
133
|
+
const pay = (client, builderId, reason, delta = 5, task = null) => client.ledger.push({ id: client.ledger.length + 1000, builder_id: String(builderId), task_id: task, delta, reason, description: '' });
|
|
134
|
+
const shelf = (client, builderId) => client.unlocked.filter((u) => u.builder_id === String(builderId)).map((u) => u.achievement_id).sort();
|
|
135
|
+
const craftShelf = (client, builderId) => shelf(client, builderId).filter((id) => CRAFT.includes(id));
|
|
136
|
+
|
|
137
|
+
// ---- 1. the catalog -------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
await test('the migration seeds seven craft achievements, paying no bonus, and renames the art-pipeline badge', () => {
|
|
140
|
+
assert.deepEqual([...CATALOG.keys()].sort(), [...CRAFT].sort());
|
|
141
|
+
for (const a of CATALOG.values()) assert.equal(a.credit_bonus, 0, `${a.id}: recognition only, no minted credits on backfill`);
|
|
142
|
+
assert.match(MIGRATION, /UPDATE achievements\s+SET name = 'Art Pipeline Engineer'[\s\S]*icon = '🛠️'\s+WHERE id = 'art-pipeline-shipper'/);
|
|
143
|
+
assert.equal(CATALOG.get('first-page-approved').icon, '🎨', 'the palette is the artist\'s now');
|
|
144
|
+
const ids = achievements.STATE_EVALUATORS ? achievements.STATE_EVALUATORS.map((e) => e.id) : null;
|
|
145
|
+
if (ids) for (const id of CRAFT) assert.ok(ids.includes(id), `${id} has an evaluator`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('the migration\'s LIKE literals are economy\'s own reason prefixes, escaped', () => {
|
|
149
|
+
const lit = (p) => p.replace(/_/g, '\\_') + '%';
|
|
150
|
+
for (const p of [credits.IDEA_STREAM_REASON_PREFIXES.task_ship, credits.IDEA_STREAM_REASON_PREFIXES.full_pass,
|
|
151
|
+
credits.IDEA_STREAM_REASON_PREFIXES.governance_pass, credits.ARTIST_PAGE_REASON_PREFIX]) {
|
|
152
|
+
assert.ok(MIGRATION.includes(`LIKE '${lit(p)}'`), `the backfill spells ${p}`);
|
|
153
|
+
assert.ok(read('modules/economy/achievements.js').includes(lit(p).replace(/\\/g, '\\\\')), `the evaluator spells ${p}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---- 2. the reconcile -----------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
await test('an ideator earns each idea badge at its threshold, exactly once', async () => {
|
|
160
|
+
seams._reset();
|
|
161
|
+
const c = fakeLedger();
|
|
162
|
+
const IDE = '2002';
|
|
163
|
+
await achievements.reconcileBuilderAchievements(c, IDE);
|
|
164
|
+
assert.deepEqual(craftShelf(c, IDE), [], 'nothing for a builder the ledger has paid nothing');
|
|
165
|
+
pay(c, IDE, 'idea.credit.task_ship:7:101');
|
|
166
|
+
pay(c, IDE, 'idea.credit.full_pass:7');
|
|
167
|
+
pay(c, IDE, 'idea.credit.governance_pass:7');
|
|
168
|
+
for (let t = 1; t <= 9; t++) pay(c, IDE, `idea.credit.task_ship:8:${200 + t}`);
|
|
169
|
+
await achievements.reconcileBuilderAchievements(c, IDE);
|
|
170
|
+
assert.deepEqual(craftShelf(c, IDE), ['first-board-ratification', 'first-full-idea', 'first-thought-ignited'],
|
|
171
|
+
'nine tasks under one thought (plus one under another) is not ten from one');
|
|
172
|
+
pay(c, IDE, 'idea.credit.task_ship:8:210');
|
|
173
|
+
pay(c, IDE, 'idea.credit.task_ship:8:210', 3); // a second payee row for the same task is not a second task
|
|
174
|
+
await achievements.reconcileBuilderAchievements(c, IDE);
|
|
175
|
+
await achievements.reconcileBuilderAchievements(c, IDE);
|
|
176
|
+
assert.deepEqual(craftShelf(c, IDE), ['first-board-ratification', 'first-full-idea', 'first-thought-ignited', 'thought-built-ten']);
|
|
177
|
+
assert.equal(c.unlocked.length, new Set(c.unlocked.map((u) => u.achievement_id)).size, 'each exactly once');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('an artist earns First Page, Ten Pages and A Whole Surface, exactly once', async () => {
|
|
181
|
+
seams._reset();
|
|
182
|
+
let surfaces = [{ key: 'landing', tweaked: 5, total: 6 }];
|
|
183
|
+
seams.registerProvider('copy-desk.artistStats', async () => ({ surfaces }));
|
|
184
|
+
const c = fakeLedger();
|
|
185
|
+
const ART = '2001';
|
|
186
|
+
pay(c, ART, 'tweak.page_approved:301', 30, 301);
|
|
187
|
+
await achievements.reconcileBuilderAchievements(c, ART);
|
|
188
|
+
assert.deepEqual(craftShelf(c, ART), ['first-page-approved']);
|
|
189
|
+
for (let t = 2; t <= 9; t++) pay(c, ART, `tweak.page_approved:${300 + t}`, 30, 300 + t);
|
|
190
|
+
await achievements.reconcileBuilderAchievements(c, ART);
|
|
191
|
+
assert.deepEqual(craftShelf(c, ART), ['first-page-approved'], 'nine pages is not ten');
|
|
192
|
+
pay(c, ART, 'tweak.page_approved:310', 30, 310);
|
|
193
|
+
surfaces = [{ key: 'landing', tweaked: 6, total: 6 }];
|
|
194
|
+
await achievements.reconcileBuilderAchievements(c, ART);
|
|
195
|
+
await achievements.reconcileBuilderAchievements(c, ART);
|
|
196
|
+
assert.deepEqual(craftShelf(c, ART), ['first-page-approved', 'ten-pages-approved', 'whole-surface-tweaked']);
|
|
197
|
+
assert.equal(c.unlocked.length, 3, 'each exactly once');
|
|
198
|
+
seams._reset();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('A Whole Surface without the studio\'s port is "could not tell", never a badge', async () => {
|
|
202
|
+
seams._reset();
|
|
203
|
+
const c = fakeLedger();
|
|
204
|
+
pay(c, '2001', 'tweak.page_approved:301', 30, 301);
|
|
205
|
+
await achievements.reconcileBuilderAchievements(c, '2001');
|
|
206
|
+
assert.ok(!shelf(c, '2001').includes('whole-surface-tweaked'));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---- 3. the writers -------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
await test('a Full Idea payment puts First Full Idea on the ideator\'s shelf, in the same call', async () => {
|
|
212
|
+
seams._reset();
|
|
213
|
+
const c = fakeLedger();
|
|
214
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 7, authorId: '2002', completenessScore: 90, config: { ideatorCredit: true } });
|
|
215
|
+
assert.ok(r.credited > 0, 'paid');
|
|
216
|
+
assert.deepEqual(craftShelf(c, '2002'), ['first-full-idea']);
|
|
217
|
+
assert.ok(c.sql.includes('SAVEPOINT ach_craft_credit'), 'under a savepoint, so a gamification error cannot fail the payment');
|
|
218
|
+
const again = await credits.awardIdeaFullPassCredit(c, { ideaId: 7, authorId: '2002', completenessScore: 90, config: { ideatorCredit: true } });
|
|
219
|
+
assert.equal(again.credited, 0, 'the second call pays nothing');
|
|
220
|
+
assert.equal(c.unlocked.length, 1, 'and unlocks nothing more');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('an approved page puts First Page Approved on the artist\'s shelf', async () => {
|
|
224
|
+
seams._reset();
|
|
225
|
+
const c = fakeLedger();
|
|
226
|
+
const r = await credits.awardArtistPageCredit(c, 301);
|
|
227
|
+
assert.equal(r.reason, 'paid');
|
|
228
|
+
assert.deepEqual(craftShelf(c, '2001'), ['first-page-approved']);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('a reconcile that throws never fails the payment', async () => {
|
|
232
|
+
seams._reset();
|
|
233
|
+
const c = fakeLedger({ failReconcile: true });
|
|
234
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 9, authorId: '2002', completenessScore: 90, config: { ideatorCredit: true } });
|
|
235
|
+
assert.ok(r.credited > 0, 'still paid');
|
|
236
|
+
assert.equal(c.ledger.length, 1);
|
|
237
|
+
assert.ok(c.sql.includes('ROLLBACK TO SAVEPOINT ach_craft_credit'), 'rolled back to its own savepoint only');
|
|
238
|
+
// on a POOL (no transaction) SAVEPOINT itself fails; the reconcile still runs
|
|
239
|
+
const pool = fakeLedger();
|
|
240
|
+
const q = pool.query.bind(pool);
|
|
241
|
+
pool.query = (text, params) => (/^SAVEPOINT/.test(text) ? Promise.reject(new Error('SAVEPOINT can only be used in transaction blocks')) : q(text, params));
|
|
242
|
+
await achievements.reconcileAfterCraftCredit(pool, '2002');
|
|
243
|
+
pay(pool, '2002', 'idea.credit.full_pass:11');
|
|
244
|
+
const got = await achievements.reconcileAfterCraftCredit(pool, '2002');
|
|
245
|
+
assert.deepEqual(got.map((a) => a.id), ['first-full-idea'], 'a pool payer still reaches the shelf');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('a payment runs only the craft rules, in two reads, and asks the studio only about an artist', async () => {
|
|
249
|
+
seams._reset();
|
|
250
|
+
let portCalls = 0;
|
|
251
|
+
seams.registerProvider('copy-desk.artistStats', async () => { portCalls++; return { surfaces: [] }; });
|
|
252
|
+
const c = fakeLedger();
|
|
253
|
+
await credits.awardIdeaFullPassCredit(c, { ideaId: 7, authorId: '2002', completenessScore: 90, config: { ideatorCredit: true } });
|
|
254
|
+
const after = c.sql.slice(c.sql.findIndex((q) => q.startsWith('SAVEPOINT ach_craft_credit')));
|
|
255
|
+
assert.ok(!after.some((q) => /FROM claims|session_records|total_credits FROM builders|ship\.net_negative/.test(q)),
|
|
256
|
+
'no claim, streak, diff or credit-total check on an idea payment');
|
|
257
|
+
assert.equal(after.filter((q) => q.startsWith('SELECT achievement_id FROM builder_achievements')).length, 1, 'one read of what is held');
|
|
258
|
+
assert.equal(after.filter((q) => q.startsWith('SELECT bool_or(reason LIKE $2) AS ignited')).length, 1, 'one read of the craft facts');
|
|
259
|
+
assert.equal(portCalls, 0, 'an ideator with no approved page is never asked about surfaces');
|
|
260
|
+
await credits.awardArtistPageCredit(c, 301);
|
|
261
|
+
assert.equal(portCalls, 1, 'an artist with an approved page is');
|
|
262
|
+
seams._reset();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await test('a builder holding every craft badge costs one read and nothing more', async () => {
|
|
266
|
+
seams._reset();
|
|
267
|
+
const c = fakeLedger();
|
|
268
|
+
for (const id of CRAFT) c.unlocked.push({ builder_id: '2002', achievement_id: id });
|
|
269
|
+
const before = c.sql.length;
|
|
270
|
+
await achievements.reconcileAfterCraftCredit(c, '2002');
|
|
271
|
+
const sent = c.sql.slice(before).filter((q) => !/SAVEPOINT/.test(q));
|
|
272
|
+
assert.deepEqual(sent.map((q) => q.slice(0, 40)), ['SELECT achievement_id FROM builder_achie']);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ---- 4. the backfill ------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
await test('A Whole Surface is backfilled by the once-per-start sweep, for the artists the ledger has paid', async () => {
|
|
278
|
+
seams._reset();
|
|
279
|
+
let asked = [];
|
|
280
|
+
seams.registerProvider('copy-desk.artistStats', async (id) => { asked.push(String(id)); return { surfaces: [{ tweaked: 4, total: 4 }] }; });
|
|
281
|
+
const c = fakeLedger();
|
|
282
|
+
pay(c, '2001', 'tweak.page_approved:301', 30, 301);
|
|
283
|
+
pay(c, '2002', 'idea.credit.task_ship:7:101');
|
|
284
|
+
pay(c, '95', 'session.token_reward', 40); // an engineer the craft ledger never paid
|
|
285
|
+
const out = await achievements.backfillCraftAchievements({ client: c });
|
|
286
|
+
assert.equal(out.checked, 2, 'the artist and the ideator, not the engineer');
|
|
287
|
+
assert.ok(shelf(c, '2001').includes('whole-surface-tweaked'));
|
|
288
|
+
assert.ok(!shelf(c, '95').length, 'the engineer is untouched');
|
|
289
|
+
assert.deepEqual(asked, ['2001'], 'the studio is asked about the artist only');
|
|
290
|
+
const again = await achievements.backfillCraftAchievements({ client: c });
|
|
291
|
+
assert.equal(again.unlocked, 0, 'a second run unlocks nothing');
|
|
292
|
+
|
|
293
|
+
const reconciler = require('../modules/lifecycle/publish-reconciler.js');
|
|
294
|
+
const calls = [];
|
|
295
|
+
const reward = { backfillCraftAchievements: async () => { calls.push(1); return { checked: 1, unlocked: 1 }; } };
|
|
296
|
+
const quiet = { logFn: () => {}, errFn: () => {} };
|
|
297
|
+
await reconciler.runCraftAchievementBackfill({ reward, ...quiet, forceCraftBackfill: true });
|
|
298
|
+
await reconciler.runCraftAchievementBackfill({ reward, ...quiet });
|
|
299
|
+
assert.equal(calls.length, 1, 'once per process start, not on every tick');
|
|
300
|
+
const failing = await reconciler.runCraftAchievementBackfill({ reward: { backfillCraftAchievements: async () => { throw new Error('db down'); } }, ...quiet, forceCraftBackfill: true });
|
|
301
|
+
assert.equal(failing.skipped, 'error', 'a failure is logged and swallowed');
|
|
302
|
+
assert.match(read('modules/lifecycle/publish-reconciler.js'), /runCraftAchievementBackfill\(deps\)\.catch\(crashHandler\('craft achievement backfill'\)\)/, 'the tick runs it');
|
|
303
|
+
seams._reset();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await test('the backfill keys each badge on the same ledger fact and stamps the first qualifying credit, idempotently', () => {
|
|
307
|
+
const blocks = MIGRATION.split('INSERT INTO builder_achievements').slice(1);
|
|
308
|
+
const ids = blocks.map((b) => (b.match(/'([a-z0-9-]+)'/) || [])[1]);
|
|
309
|
+
assert.deepEqual(ids.sort(), CRAFT.filter((id) => id !== 'whole-surface-tweaked').sort(),
|
|
310
|
+
'six backfilled from the ledger; A Whole Surface needs the page inventory (a file), so the reconcile awards it');
|
|
311
|
+
for (const b of blocks) {
|
|
312
|
+
assert.match(b, /ON CONFLICT DO NOTHING/, 'a re-run converges');
|
|
313
|
+
assert.match(b, /delta > 0/, 'a zero or negative row is no moment');
|
|
314
|
+
}
|
|
315
|
+
assert.match(blocks.find((b) => b.includes("'ten-pages-approved'")), /WHERE nth = 10/, 'stamped at the tenth page');
|
|
316
|
+
assert.match(blocks.find((b) => b.includes("'thought-built-ten'")), /PARTITION BY builder_id, split_part\(reason, ':', 2\)/, 'counted per thought');
|
|
317
|
+
assert.match(MIGRATION, /diagnose-achievements\.js --apply/, 'the migration names how A Whole Surface is backfilled');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---- 5. the pictures ------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
await test('the roster and the status page draw every craft badge with the migration\'s icon', () => {
|
|
323
|
+
const roster = read('modules/hall-ui/public/roster.js');
|
|
324
|
+
const status = read('modules/status-ui/public/status.js');
|
|
325
|
+
const unescape = (s) => s.replace(/\\u\{([0-9A-F]+)\}/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)));
|
|
326
|
+
for (const a of CATALOG.values()) {
|
|
327
|
+
const r = roster.match(new RegExp(`'${a.id}': '([^']+)'`));
|
|
328
|
+
const s = status.match(new RegExp(`'${a.id}': '([^']+)'`));
|
|
329
|
+
assert.ok(r && unescape(r[1]) === a.icon, `roster draws ${a.id} as ${a.icon}`);
|
|
330
|
+
assert.ok(s && s[1] === a.icon, `status draws ${a.id} as ${a.icon}`);
|
|
331
|
+
}
|
|
332
|
+
assert.ok(unescape(roster.match(/'art-pipeline-shipper': '([^']+)'/)[1]) === '🛠️');
|
|
333
|
+
assert.equal(status.match(/'art-pipeline-shipper': '([^']+)'/)[1], '🛠️');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await test('a badge is named in words for a hover and a screen reader, never its id slug', () => {
|
|
337
|
+
const roster = read('modules/hall-ui/public/roster.js');
|
|
338
|
+
const fn = roster.slice(roster.indexOf('function achWords'), roster.indexOf('function achRowHtml'));
|
|
339
|
+
const achWords = new Function(`${fn}; return achWords;`)();
|
|
340
|
+
assert.equal(achWords('first-page-approved'), 'First page approved');
|
|
341
|
+
assert.equal(achWords('thought-built-ten'), 'Thought built ten');
|
|
342
|
+
assert.match(roster, /title="' \+ esc\(words\) \+ '" aria-label="' \+ esc\(words\) \+ '"/);
|
|
343
|
+
assert.doesNotMatch(roster, /title="' \+ esc\(id\)/);
|
|
344
|
+
const status = read('modules/status-ui/public/status.js');
|
|
345
|
+
assert.doesNotMatch(status, /span\.title = id;/);
|
|
346
|
+
assert.match(status, /span\.setAttribute\('aria-label', span\.title\)/);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
summary();
|
|
@@ -192,7 +192,8 @@ async function rosterRows(marks) {
|
|
|
192
192
|
makeLedger: (cfg) => { rowFn = cfg.rowHtml; return { render() {} }; },
|
|
193
193
|
},
|
|
194
194
|
});
|
|
195
|
-
|
|
195
|
+
// addEventListener/querySelectorAll: the role toggle wires its chips (task 1004437).
|
|
196
|
+
sandbox.document = { getElementById: () => ({ innerHTML: '', className: '', textContent: '', hidden: false, addEventListener() {}, querySelectorAll: () => [] }) };
|
|
196
197
|
if (!marks) delete sandbox.window.OTBFounding; // a page without the script at all
|
|
197
198
|
vm.runInContext(read('roster.js'), sandbox, { filename: 'roster.js' });
|
|
198
199
|
await new Promise((r) => setImmediate(r));
|
|
@@ -174,7 +174,8 @@ test('A: the quiet fill is a defined variant, not an inline colour', () => {
|
|
|
174
174
|
// capture the rowHtml it hands makeLedger, and call the real function.
|
|
175
175
|
async function rosterRowHtml(rows) {
|
|
176
176
|
let rowFn = null;
|
|
177
|
-
|
|
177
|
+
// addEventListener/querySelectorAll: the role toggle wires its chips (task 1004437).
|
|
178
|
+
const elStub = { innerHTML: '', className: '', textContent: '', hidden: false, addEventListener() {}, querySelectorAll: () => [] };
|
|
178
179
|
const sandbox = {
|
|
179
180
|
window: {
|
|
180
181
|
OTB: { fmtInt: (n) => String(n == null ? 0 : n), rankLabel: (r) => r },
|