@bongos/core 1.20.83 → 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 +186 -81
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +153 -4
- package/docs/api-reference.md +5 -3
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +60 -59
- package/docs/copy-registry.json +72 -63
- package/docs/design/project-startup-direction.md +63 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +8 -0
- package/docs/page-inventory.json +9 -3
- package/docs/page-readings.json +427 -410
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_269_session_transcripts.sql +35 -0
- 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/genesis-home.js +17 -3
- package/modules/hall-ui/public/hall-render.js +24 -2
- package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
- 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/lifecycle/workflow-dispatch.js +21 -11
- package/modules/npm-release/release.js +10 -1
- package/modules/onboarding/founding-birth.js +116 -0
- package/modules/onboarding/genesis-home.js +72 -5
- package/modules/onboarding/port.js +9 -2
- package/modules/onboarding/routes/onboarding.js +8 -1
- package/modules/provisioning/birth.js +64 -0
- package/modules/provisioning/dns-resolves.js +54 -0
- package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
- package/modules/provisioning/pollers/liveness-sweep.js +5 -0
- package/modules/provisioning/provisioning.js +7 -6
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/tests/liveness.mjs +48 -0
- package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
- package/modules/public-landing/public/projects.html +40 -15
- package/modules/sessions/db.js +58 -0
- package/modules/sessions/routes/sessions.js +97 -0
- package/modules/status-ui/public/status.js +39 -7
- package/modules/status-ui/public/style.css +24 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
- package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +45 -0
- package/scripts/gds/provision.js +77 -53
- package/scripts/gds/session-digest.js +11 -3
- package/scripts/gds/session-transcript-build.js +155 -0
- package/scripts/gds/session-transcript-upload.js +29 -0
- package/scripts/gds/ship-session-upload.js +6 -1
- package/scripts/gds/upgrade-commit-identity.js +32 -0
- package/scripts/gds/upgrade.js +1 -1
- 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/bongos/routes.js +3 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/achievements_reconcile.mjs +13 -0
- package/tests/craft_achievements.mjs +349 -0
- package/tests/founding_birth.mjs +200 -0
- package/tests/founding_builders.mjs +2 -1
- package/tests/founding_mode.mjs +3 -1
- package/tests/genesis_home.mjs +71 -2
- package/tests/hall_builders_page_pass.mjs +2 -1
- package/tests/hub_map_orb_states.mjs +5 -1
- package/tests/leaderboard_roles.mjs +272 -0
- package/tests/npm_release_release.mjs +37 -2
- package/tests/projects_hub.mjs +5 -3
- package/tests/projects_hub_dns_ready.mjs +153 -0
- package/tests/projects_hub_pre_uat.mjs +9 -5
- package/tests/provision_dns_first.mjs +171 -0
- package/tests/provision_settings_apply.mjs +6 -1
- package/tests/provisioning_settings_env.mjs +49 -0
- package/tests/session_transcript.mjs +273 -0
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/upgrade_commit_identity.mjs +89 -0
- package/tests/wizard_demo.mjs +29 -3
|
@@ -57,7 +57,7 @@ npm publish ./clients/bongos-client # real (publishConfig makes it
|
|
|
57
57
|
|
|
58
58
|
**Publishing is automatic on merge** as of 2026-08-08: the `publish` workflow rides behind the required `unit` check and waits for the `integration` lane's verdict on the commit it releases (task 1004290), auto-bumps a patch when nobody bumped, builds through the fail-closed no-leak gate, publishes, and tags — see the workflow header (`.github/workflows/publish.yml`) and ADR 0161. It ships inert until the owner arms it: repo variable `PUBLISH_ON_MERGE=1`, a **trusted publisher** registered on npmjs.com for `@bongos/core` naming this repo + `publish.yml`, and the secret `RELEASE_PUSH_TOKEN` (contents:write, for the bump commit). There is deliberately **no** `NPM_TOKEN` — the lane authenticates by OIDC, and the workflow sets neither `NPM_TOKEN` nor `NODE_AUTH_TOKEN` because either one would send npm down the legacy token path and skip the OIDC exchange entirely. A **minor/major bump stays a human in-task edit**. The update subscription ([ADR 0136](../adr/0136-update-channel-subscription-policy.md)) consumes whatever is published, on its next sweep.
|
|
59
59
|
|
|
60
|
-
**Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub
|
|
60
|
+
**Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub credential needs **Actions: write** to start the job: the `GITHUB_PUSH_TOKEN` access token (what cloudbongos.com uses), or the GitHub App where one is configured. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
|
|
61
61
|
|
|
62
62
|
The steps below remain as the **manual fallback** (lane disarmed, npm outage, or a deliberate out-of-band release):
|
|
63
63
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- core_269_session_transcripts.sql — the FULL, secret-scrubbed transcript of every
|
|
2
|
+
-- session (ADR 0051, task 1001004).
|
|
3
|
+
--
|
|
4
|
+
-- WHY A SEPARATE TABLE and not a widened session_records.detail / a new jsonb
|
|
5
|
+
-- column on it: session_records is the BFG's hot, searched, paged table
|
|
6
|
+
-- (searchSessions, summarizeSessions, /mine all SELECT from it, and its rows are
|
|
7
|
+
-- KB by design — ADR 0027). A transcript is MB. Putting it on the same row would
|
|
8
|
+
-- make every list query drag (or have to remember to exclude) a fat jsonb, and
|
|
9
|
+
-- would make a TOASTed-column slip on one query a performance incident. One row
|
|
10
|
+
-- per session in each table, joined on session_id, keeps the evaluator's read
|
|
11
|
+
-- path exactly as cheap as it is today. ADR 0051 §1 allowed either shape
|
|
12
|
+
-- ("new transcript jsonb column, or a widened detail"); this is the third
|
|
13
|
+
-- option that satisfies its intent without the cost.
|
|
14
|
+
--
|
|
15
|
+
-- The content is scrubbed ON THE BUILDER'S BOX before upload (modules/security/
|
|
16
|
+
-- secret-scrub.js, the task-1003 scrubber); this table never holds raw text.
|
|
17
|
+
--
|
|
18
|
+
-- builder_id is denormalised from session_records ON PURPOSE: the read route's
|
|
19
|
+
-- ownership check (own transcript vs Archon-only cross-builder) must not depend
|
|
20
|
+
-- on a join succeeding, and the upload's ownership guard needs it at write time.
|
|
21
|
+
--
|
|
22
|
+
-- Retention: keep forever (ADR 0051 §4, Lars 2026-06-13). Storage-cost work is a
|
|
23
|
+
-- deliberate future task, not designed in here.
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS session_transcripts (
|
|
26
|
+
session_id text PRIMARY KEY, -- Claude Code session id, == session_records.session_id
|
|
27
|
+
builder_id bigint NOT NULL REFERENCES builders(id),
|
|
28
|
+
turn_count integer NOT NULL DEFAULT 0,
|
|
29
|
+
byte_size integer NOT NULL DEFAULT 0, -- serialized size of `turns`, for corpus accounting
|
|
30
|
+
truncated boolean NOT NULL DEFAULT false, -- true when the uploader hit its own size ceiling
|
|
31
|
+
turns jsonb NOT NULL DEFAULT '[]'::jsonb, -- per-turn scrubbed content (prompts, assistant text, tool calls + results)
|
|
32
|
+
uploaded_at timestamptz NOT NULL DEFAULT now()
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_session_transcripts_builder ON session_transcripts (builder_id);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
-- core_270_craft_achievements.sql — achievements for artist and ideator moments
|
|
2
|
+
-- (task 1004438 / WA6.RP08, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. Every achievement was a claim or ship event, and the one art badge
|
|
5
|
+
-- ("art-pipeline-shipper") fires when CODE touches art/ — it rewards engineering,
|
|
6
|
+
-- not art. An artist who rewrites pages in the studio and an ideator whose thoughts
|
|
7
|
+
-- become work earned nothing on the shelf. These seven are each keyed to a LEDGER
|
|
8
|
+
-- fact the economy already books (credit_log), so they are awarded once, by the
|
|
9
|
+
-- same reconcile every other achievement uses (modules/economy/achievements.js
|
|
10
|
+
-- STATE_EVALUATORS), and backfilled below for work that already happened.
|
|
11
|
+
--
|
|
12
|
+
-- credit_bonus is 0 on all seven: they recognise work the ledger has already
|
|
13
|
+
-- paid for, and a backfill that minted bonus credits for past work is a money
|
|
14
|
+
-- decision this migration does not make.
|
|
15
|
+
--
|
|
16
|
+
-- A core migration, not an economy_* one: `achievements` and
|
|
17
|
+
-- `builder_achievements` are core tables (modules/economy/CLAUDE.md, Tables).
|
|
18
|
+
|
|
19
|
+
INSERT INTO achievements (id, name, description, icon, credit_bonus, sort_order) VALUES
|
|
20
|
+
('first-thought-ignited', 'First Thought Ignited',
|
|
21
|
+
'A thought of yours became shipped work.',
|
|
22
|
+
'💡', 0, 90),
|
|
23
|
+
('first-full-idea', 'First Full Idea',
|
|
24
|
+
'A Full Idea of yours passed the completeness bar.',
|
|
25
|
+
'📜', 0, 100),
|
|
26
|
+
('first-board-ratification', 'Ratified by the Board',
|
|
27
|
+
'The board ratified an idea of yours.',
|
|
28
|
+
'🏛️', 0, 110),
|
|
29
|
+
('thought-built-ten', 'A Thought That Built Ten',
|
|
30
|
+
'One thought of yours led to ten or more shipped tasks.',
|
|
31
|
+
'🌟', 0, 120),
|
|
32
|
+
('first-page-approved', 'First Page Approved',
|
|
33
|
+
'A page you rewrote in Tweak Mode was approved and shipped.',
|
|
34
|
+
'🎨', 0, 130),
|
|
35
|
+
('ten-pages-approved', 'Ten Pages Approved',
|
|
36
|
+
'Ten pages you rewrote in Tweak Mode were approved and shipped.',
|
|
37
|
+
'🖼️', 0, 140),
|
|
38
|
+
('whole-surface-tweaked', 'A Whole Surface',
|
|
39
|
+
'Every page of one surface was rewritten by you and approved.',
|
|
40
|
+
'🗺️', 0, 150)
|
|
41
|
+
ON CONFLICT (id) DO UPDATE
|
|
42
|
+
SET name = EXCLUDED.name,
|
|
43
|
+
description = EXCLUDED.description,
|
|
44
|
+
icon = EXCLUDED.icon,
|
|
45
|
+
credit_bonus = EXCLUDED.credit_bonus,
|
|
46
|
+
sort_order = EXCLUDED.sort_order;
|
|
47
|
+
|
|
48
|
+
-- The misleading one, renamed rather than retired: builders who earned it keep it,
|
|
49
|
+
-- and it now says what it is — engineering work on the art pipeline. The palette
|
|
50
|
+
-- icon moves to First Page Approved, the artist's own badge.
|
|
51
|
+
UPDATE achievements
|
|
52
|
+
SET name = 'Art Pipeline Engineer',
|
|
53
|
+
description = 'Ship engineering work on the art/ pipeline (code, not art; an artist earns First Page Approved).',
|
|
54
|
+
icon = '🛠️'
|
|
55
|
+
WHERE id = 'art-pipeline-shipper';
|
|
56
|
+
|
|
57
|
+
-- ---- Backfill: everyone who already earned one, from the ledger ---------------
|
|
58
|
+
-- Each unlock is stamped with the FIRST qualifying credit's time and task, so the
|
|
59
|
+
-- shelf dates the moment, not this migration. ON CONFLICT DO NOTHING: re-running
|
|
60
|
+
-- converges, and an unlock the live reconcile already wrote is left alone.
|
|
61
|
+
-- "A Whole Surface" needs the page inventory, which is a file, not a table, so
|
|
62
|
+
-- SQL cannot award it. It is backfilled by the lifecycle's periodic sweep, which
|
|
63
|
+
-- runs economy's backfillCraftAchievements ONCE at every server start (so the
|
|
64
|
+
-- deploy that applies this migration awards it within the first sweep), and by
|
|
65
|
+
-- the reconcile on the artist's next approved page; by hand,
|
|
66
|
+
-- `node scripts/gds/diagnose-achievements.js --apply`.
|
|
67
|
+
|
|
68
|
+
-- the idea-stream and artist-lane prefixes, spelled as LIKE literals with the
|
|
69
|
+
-- underscore escaped (modules/economy/credits.js IDEA_STREAM_REASON_PREFIXES,
|
|
70
|
+
-- ARTIST_PAGE_REASON_PREFIX; tests/craft_achievements.mjs holds them together)
|
|
71
|
+
INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
|
|
72
|
+
SELECT DISTINCT ON (builder_id) builder_id, 'first-thought-ignited', recorded_at, task_id
|
|
73
|
+
FROM credit_log
|
|
74
|
+
WHERE reason LIKE 'idea.credit.task\_ship:%' AND delta > 0
|
|
75
|
+
ORDER BY builder_id, recorded_at, id
|
|
76
|
+
ON CONFLICT DO NOTHING;
|
|
77
|
+
|
|
78
|
+
INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
|
|
79
|
+
SELECT DISTINCT ON (builder_id) builder_id, 'first-full-idea', recorded_at, task_id
|
|
80
|
+
FROM credit_log
|
|
81
|
+
WHERE reason LIKE 'idea.credit.full\_pass:%' AND delta > 0
|
|
82
|
+
ORDER BY builder_id, recorded_at, id
|
|
83
|
+
ON CONFLICT DO NOTHING;
|
|
84
|
+
|
|
85
|
+
INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
|
|
86
|
+
SELECT DISTINCT ON (builder_id) builder_id, 'first-board-ratification', recorded_at, task_id
|
|
87
|
+
FROM credit_log
|
|
88
|
+
WHERE reason LIKE 'idea.credit.governance\_pass:%' AND delta > 0
|
|
89
|
+
ORDER BY builder_id, recorded_at, id
|
|
90
|
+
ON CONFLICT DO NOTHING;
|
|
91
|
+
|
|
92
|
+
-- ten distinct shipped tasks paid under ONE idea (reason idea.credit.task_ship:<idea>:<task>);
|
|
93
|
+
-- stamped at the tenth task's payment
|
|
94
|
+
INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
|
|
95
|
+
SELECT DISTINCT ON (builder_id) builder_id, 'thought-built-ten', recorded_at, task_id
|
|
96
|
+
FROM (
|
|
97
|
+
SELECT builder_id, recorded_at, task_id, id,
|
|
98
|
+
DENSE_RANK() OVER (PARTITION BY builder_id, split_part(reason, ':', 2)
|
|
99
|
+
ORDER BY first_paid) AS nth
|
|
100
|
+
FROM (
|
|
101
|
+
SELECT builder_id, recorded_at, task_id, id, reason,
|
|
102
|
+
MIN(recorded_at) OVER (PARTITION BY builder_id, split_part(reason, ':', 2), split_part(reason, ':', 3)) AS first_paid
|
|
103
|
+
FROM credit_log
|
|
104
|
+
WHERE reason LIKE 'idea.credit.task\_ship:%' AND delta > 0
|
|
105
|
+
) per_task
|
|
106
|
+
) ranked
|
|
107
|
+
WHERE nth >= 10
|
|
108
|
+
ORDER BY builder_id, recorded_at, id
|
|
109
|
+
ON CONFLICT DO NOTHING;
|
|
110
|
+
|
|
111
|
+
INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
|
|
112
|
+
SELECT DISTINCT ON (builder_id) builder_id, 'first-page-approved', recorded_at, task_id
|
|
113
|
+
FROM credit_log
|
|
114
|
+
WHERE reason LIKE 'tweak.page\_approved:%' AND delta > 0
|
|
115
|
+
ORDER BY builder_id, recorded_at, id
|
|
116
|
+
ON CONFLICT DO NOTHING;
|
|
117
|
+
|
|
118
|
+
-- the tenth approved page (one row per round, keyed on its task)
|
|
119
|
+
INSERT INTO builder_achievements (builder_id, achievement_id, unlocked_at, unlocked_by_task_id)
|
|
120
|
+
SELECT builder_id, 'ten-pages-approved', recorded_at, task_id
|
|
121
|
+
FROM (
|
|
122
|
+
SELECT builder_id, recorded_at, task_id,
|
|
123
|
+
ROW_NUMBER() OVER (PARTITION BY builder_id ORDER BY recorded_at, id) AS nth
|
|
124
|
+
FROM credit_log
|
|
125
|
+
WHERE reason LIKE 'tweak.page\_approved:%' AND delta > 0
|
|
126
|
+
) pages
|
|
127
|
+
WHERE nth = 10
|
|
128
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -41,7 +41,7 @@ const streak = require('./streak');
|
|
|
41
41
|
// getLockedAchievementsProgress/renderProgressText), moved from src/bongos/db.js,
|
|
42
42
|
// reads the shared pool through the published doorway — a module never requires a
|
|
43
43
|
// core internal. The evaluators above take a tx `client` from the caller.
|
|
44
|
-
const { pool } = require('../../src/module-api');
|
|
44
|
+
const { pool, resolveOptional } = require('../../src/module-api');
|
|
45
45
|
|
|
46
46
|
async function alreadyUnlocked(client, builderId, achievementId) {
|
|
47
47
|
const r = await client.query(
|
|
@@ -367,6 +367,88 @@ async function eligible_streak7(client, builderId) {
|
|
|
367
367
|
return s.longest_streak >= 7;
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
// ---- the craft achievements (task 1004438 / WA6.RP08) --------------------------
|
|
371
|
+
// An artist and an ideator earn on the LEDGER, not on a claim: each predicate
|
|
372
|
+
// below is a fact credit_log already holds (the reason prefixes are economy's own,
|
|
373
|
+
// the same strings the writers book and migrations/core_270 backfills from). None
|
|
374
|
+
// needs a ship of the builder's own; reconcileAfterCraftCredit (below) runs them
|
|
375
|
+
// the moment such a credit is booked.
|
|
376
|
+
const IDEA_SHIP_LIKE = 'idea.credit.task\\_ship:%';
|
|
377
|
+
const IDEA_FULL_LIKE = 'idea.credit.full\\_pass:%';
|
|
378
|
+
const IDEA_BOARD_LIKE = 'idea.credit.governance\\_pass:%';
|
|
379
|
+
const PAGE_LIKE = 'tweak.page\\_approved:%';
|
|
380
|
+
const THOUGHT_TASKS = 10;
|
|
381
|
+
const PAGES_MANY = 10;
|
|
382
|
+
|
|
383
|
+
//
|
|
384
|
+
// ONE READ FOR ALL SEVEN. craftFacts asks the builder's craft ledger once —
|
|
385
|
+
// served by idx_credit_log_builder (builder_id, recorded_at), so it is that
|
|
386
|
+
// person's rows, never the whole ledger — and every craft rule is a pure test of
|
|
387
|
+
// its answer. The full reconcile and the craft-only one read the SAME facts
|
|
388
|
+
// through the SAME rules (CRAFT_RULES), so the two paths cannot disagree about
|
|
389
|
+
// who earned what. Within one reconcile the facts are read once and shared.
|
|
390
|
+
const CRAFT_FACTS_SQL = `
|
|
391
|
+
SELECT bool_or(reason LIKE $2) AS ignited,
|
|
392
|
+
bool_or(reason LIKE $3) AS full_idea,
|
|
393
|
+
bool_or(reason LIKE $4) AS ratified,
|
|
394
|
+
COUNT(*) FILTER (WHERE reason LIKE $5)::int AS pages,
|
|
395
|
+
(SELECT COALESCE(MAX(n), 0)::int FROM (
|
|
396
|
+
SELECT COUNT(DISTINCT split_part(reason, ':', 3)) AS n
|
|
397
|
+
FROM credit_log
|
|
398
|
+
WHERE builder_id = $1 AND reason LIKE $2 AND delta > 0
|
|
399
|
+
GROUP BY split_part(reason, ':', 2)) per_thought) AS best_thought_tasks
|
|
400
|
+
FROM credit_log
|
|
401
|
+
WHERE builder_id = $1 AND delta > 0`;
|
|
402
|
+
|
|
403
|
+
async function craftFacts(client, builderId) {
|
|
404
|
+
const r = await client.query(CRAFT_FACTS_SQL, [builderId, IDEA_SHIP_LIKE, IDEA_FULL_LIKE, IDEA_BOARD_LIKE, PAGE_LIKE]);
|
|
405
|
+
const row = r.rows[0] || {};
|
|
406
|
+
return {
|
|
407
|
+
ignited: row.ignited === true,
|
|
408
|
+
fullIdea: row.full_idea === true,
|
|
409
|
+
ratified: row.ratified === true,
|
|
410
|
+
pages: Number(row.pages) || 0,
|
|
411
|
+
bestThoughtTasks: Number(row.best_thought_tasks) || 0,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Every page of one surface rewritten by this artist and approved: the studio's
|
|
416
|
+
// own "N of M tweaked" per surface (copy-desk's `copy-desk.artistStats` port, the
|
|
417
|
+
// one the profile header reads), so the inventory has one reader. Asked ONLY of a
|
|
418
|
+
// builder with an approved page — anyone else structurally cannot earn it, and
|
|
419
|
+
// the port is not free. A port that is absent or fails is "could not tell", never
|
|
420
|
+
// a badge.
|
|
421
|
+
async function wholeSurfaceTweaked(builderId, facts) {
|
|
422
|
+
if (!(facts.pages > 0)) return false;
|
|
423
|
+
const port = resolveOptional('copy-desk.artistStats');
|
|
424
|
+
if (typeof port !== 'function') return false;
|
|
425
|
+
const stats = await port(builderId);
|
|
426
|
+
const surfaces = (stats && Array.isArray(stats.surfaces)) ? stats.surfaces : [];
|
|
427
|
+
return surfaces.some((s) => Number(s.total) > 0 && Number(s.tweaked) >= Number(s.total));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// id → (facts, builderId) → boolean | Promise<boolean>, in sort order.
|
|
431
|
+
const CRAFT_RULES = [
|
|
432
|
+
['first-thought-ignited', (f) => f.ignited],
|
|
433
|
+
['first-full-idea', (f) => f.fullIdea],
|
|
434
|
+
['first-board-ratification', (f) => f.ratified],
|
|
435
|
+
// one thought, ten shipped tasks: stream 1's reason is idea.credit.task_ship:<idea>:<task>
|
|
436
|
+
['thought-built-ten', (f) => f.bestThoughtTasks >= THOUGHT_TASKS],
|
|
437
|
+
['first-page-approved', (f) => f.pages > 0],
|
|
438
|
+
['ten-pages-approved', (f) => f.pages >= PAGES_MANY],
|
|
439
|
+
['whole-surface-tweaked', (f, builderId) => wholeSurfaceTweaked(builderId, f)],
|
|
440
|
+
];
|
|
441
|
+
const CRAFT_IDS = CRAFT_RULES.map(([id]) => id);
|
|
442
|
+
|
|
443
|
+
// The STATE_EVALUATORS form of a rule: read the facts once per reconcile (the
|
|
444
|
+
// shared `ctx`), then test. diagnose-achievements.js reaches the rules this way.
|
|
445
|
+
function craftEvaluator(rule) {
|
|
446
|
+
return async (client, builderId, ctx = {}) => {
|
|
447
|
+
if (!ctx.craftFacts) ctx.craftFacts = craftFacts(client, builderId);
|
|
448
|
+
return rule(await ctx.craftFacts, builderId);
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
370
452
|
// Ordered by achievements.sort_order so banners/backfill output read naturally.
|
|
371
453
|
const STATE_EVALUATORS = [
|
|
372
454
|
{ id: 'first-ship', fn: eligible_firstShip },
|
|
@@ -378,6 +460,7 @@ const STATE_EVALUATORS = [
|
|
|
378
460
|
{ id: 'subtractor', fn: eligible_subtractor },
|
|
379
461
|
{ id: 'gds-shipper', fn: eligible_gdsShipper },
|
|
380
462
|
{ id: 'breaking-the-100c-bar', fn: eligible_breaking100c },
|
|
463
|
+
...CRAFT_RULES.map(([id, rule]) => ({ id, fn: craftEvaluator(rule) })),
|
|
381
464
|
];
|
|
382
465
|
|
|
383
466
|
// Unlock every achievement the builder currently qualifies for but hasn't yet.
|
|
@@ -387,9 +470,10 @@ const STATE_EVALUATORS = [
|
|
|
387
470
|
async function reconcileBuilderAchievements(client, builderId, opts = {}) {
|
|
388
471
|
const { taskId = null } = opts;
|
|
389
472
|
const results = [];
|
|
473
|
+
const ctx = {}; // shared by the craft rules: their facts are read once per reconcile
|
|
390
474
|
for (const ev of STATE_EVALUATORS) {
|
|
391
475
|
if (await alreadyUnlocked(client, builderId, ev.id)) continue;
|
|
392
|
-
if (await ev.fn(client, builderId)) {
|
|
476
|
+
if (await ev.fn(client, builderId, ctx)) {
|
|
393
477
|
const unlocked = await unlockAchievement(client, builderId, ev.id, taskId);
|
|
394
478
|
if (unlocked) results.push(unlocked);
|
|
395
479
|
}
|
|
@@ -397,6 +481,83 @@ async function reconcileBuilderAchievements(client, builderId, opts = {}) {
|
|
|
397
481
|
return results;
|
|
398
482
|
}
|
|
399
483
|
|
|
484
|
+
// reconcileCraftAchievements — ONLY the seven craft rules, in two reads: what the
|
|
485
|
+
// builder already holds of them, then (only if anything is left) their craft
|
|
486
|
+
// facts. The payout hook below runs this, not the full sixteen-evaluator
|
|
487
|
+
// reconcile: an idea credit or an approved page cannot change a streak, a claim
|
|
488
|
+
// count or a shipped diff, so asking about those on every payment is waste.
|
|
489
|
+
async function reconcileCraftAchievements(client, builderId, opts = {}) {
|
|
490
|
+
const { taskId = null } = opts;
|
|
491
|
+
const held = await client.query(
|
|
492
|
+
'SELECT achievement_id FROM builder_achievements WHERE builder_id = $1 AND achievement_id = ANY($2::text[])',
|
|
493
|
+
[builderId, CRAFT_IDS]
|
|
494
|
+
);
|
|
495
|
+
const have = new Set((held.rows || []).map((r) => r.achievement_id));
|
|
496
|
+
const open = CRAFT_RULES.filter(([id]) => !have.has(id));
|
|
497
|
+
if (!open.length) return [];
|
|
498
|
+
const facts = await craftFacts(client, builderId);
|
|
499
|
+
const results = [];
|
|
500
|
+
for (const [id, rule] of open) {
|
|
501
|
+
if (await rule(facts, builderId)) {
|
|
502
|
+
const unlocked = await unlockAchievement(client, builderId, id, taskId);
|
|
503
|
+
if (unlocked) results.push(unlocked);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return results;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// backfillCraftAchievements — the part of the backfill a migration cannot do.
|
|
510
|
+
// migrations/core_270 backfills six badges from the ledger in SQL, but "A Whole
|
|
511
|
+
// Surface" needs the page inventory, which is a file the database never sees. So
|
|
512
|
+
// the lifecycle's periodic sweep (publish-reconciler.js) runs this ONCE per
|
|
513
|
+
// process start: every builder the craft ledger has paid, through the craft-only
|
|
514
|
+
// reconcile, in id order, bounded. Idempotent, so a restart that runs it again
|
|
515
|
+
// unlocks nothing twice. Returns { checked, unlocked }.
|
|
516
|
+
async function backfillCraftAchievements({ client = null, limit = 500 } = {}) {
|
|
517
|
+
const q = client || pool;
|
|
518
|
+
const { rows } = await q.query(
|
|
519
|
+
`SELECT DISTINCT builder_id FROM credit_log
|
|
520
|
+
WHERE delta > 0 AND (reason LIKE $1 OR reason LIKE $2 OR reason LIKE $3 OR reason LIKE $4)
|
|
521
|
+
ORDER BY builder_id
|
|
522
|
+
LIMIT $5`,
|
|
523
|
+
[IDEA_SHIP_LIKE, IDEA_FULL_LIKE, IDEA_BOARD_LIKE, PAGE_LIKE, limit]
|
|
524
|
+
);
|
|
525
|
+
let unlocked = 0;
|
|
526
|
+
for (const r of rows) {
|
|
527
|
+
const got = await reconcileAfterCraftCredit(q, r.builder_id);
|
|
528
|
+
unlocked += got.length;
|
|
529
|
+
}
|
|
530
|
+
return { checked: rows.length, unlocked };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// reconcileAfterCraftCredit — the craft writers' hook (task 1004438). An idea
|
|
534
|
+
// stream or an approved page pays a builder inside SOMEONE ELSE'S ship (or an
|
|
535
|
+
// idea route's own read), so the ship-time reconcile — which runs for the shipper
|
|
536
|
+
// — never reaches the ideator or the artist. credits.js calls this for each payee
|
|
537
|
+
// it just booked. It can never fail the payment: on a transaction client it runs
|
|
538
|
+
// under a SAVEPOINT and rolls back to it on any error; on a pool (an idea route
|
|
539
|
+
// pays through the pool, one idempotent statement) SAVEPOINT itself fails, nothing
|
|
540
|
+
// is open to poison, and the reconcile runs plainly inside a try. Returns the
|
|
541
|
+
// just-unlocked list, or [] on any failure.
|
|
542
|
+
async function reconcileAfterCraftCredit(client, builderId, opts = {}) {
|
|
543
|
+
if (builderId == null || !client || typeof client.query !== 'function') return [];
|
|
544
|
+
let saved = false;
|
|
545
|
+
try {
|
|
546
|
+
await client.query('SAVEPOINT ach_craft_credit');
|
|
547
|
+
saved = true;
|
|
548
|
+
} catch (_) { /* not in a transaction: a pool */ }
|
|
549
|
+
try {
|
|
550
|
+
const out = await reconcileCraftAchievements(client, builderId, opts);
|
|
551
|
+
if (saved) await client.query('RELEASE SAVEPOINT ach_craft_credit');
|
|
552
|
+
return out;
|
|
553
|
+
} catch (_) {
|
|
554
|
+
if (saved) {
|
|
555
|
+
try { await client.query('ROLLBACK TO SAVEPOINT ach_craft_credit'); } catch (_e) { /* the caller's tx decides */ }
|
|
556
|
+
}
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
400
561
|
// -------------------------------------------------------------------------
|
|
401
562
|
// Achievements — minimal getters added by migration 006_pms_v2.
|
|
402
563
|
// Full evaluator + unlock writes live in src/bongos/achievements.js (task K1).
|
|
@@ -436,6 +597,10 @@ async function getLeaderboardWithAchievements(limit = 10, perBuilderCap = 5) {
|
|
|
436
597
|
b.avatar_url,
|
|
437
598
|
b.total_credits,
|
|
438
599
|
b.rank,
|
|
600
|
+
-- task 1004437: karma too, as credits.leaderboard carries it, so the
|
|
601
|
+
-- hall's roster can read this one public list (with its roles) and
|
|
602
|
+
-- keep its karma column and lens. The profile already publishes it.
|
|
603
|
+
b.karma,
|
|
439
604
|
COALESCE((
|
|
440
605
|
SELECT array_agg(t.achievement_id ORDER BY t.sort_order)
|
|
441
606
|
FROM (
|
|
@@ -504,7 +669,14 @@ async function getLockedAchievementsProgress(builderId) {
|
|
|
504
669
|
AND a.outcome = 'shipped' AND a.released_at IS NOT NULL
|
|
505
670
|
AND b2.outcome = 'shipped' AND b2.released_at IS NOT NULL
|
|
506
671
|
AND tstzrange(a.claimed_at, a.released_at)
|
|
507
|
-
&& tstzrange(b2.claimed_at, b2.released_at)) AS overlap_pairs
|
|
672
|
+
&& tstzrange(b2.claimed_at, b2.released_at)) AS overlap_pairs,
|
|
673
|
+
-- task 1004438: the craft counters, from the ledger
|
|
674
|
+
(SELECT COUNT(*) FROM credit_log
|
|
675
|
+
WHERE builder_id = $1 AND reason LIKE 'tweak.page\\_approved:%' AND delta > 0) AS pages_approved,
|
|
676
|
+
(SELECT COALESCE(MAX(n), 0) FROM (
|
|
677
|
+
SELECT COUNT(DISTINCT split_part(reason, ':', 3)) AS n FROM credit_log
|
|
678
|
+
WHERE builder_id = $1 AND reason LIKE 'idea.credit.task\\_ship:%' AND delta > 0
|
|
679
|
+
GROUP BY split_part(reason, ':', 2)) t) AS best_thought_tasks
|
|
508
680
|
)
|
|
509
681
|
SELECT a.id AS achievement_id,
|
|
510
682
|
a.name,
|
|
@@ -516,7 +688,9 @@ async function getLockedAchievementsProgress(builderId) {
|
|
|
516
688
|
c.art_ships,
|
|
517
689
|
c.pms_ships,
|
|
518
690
|
c.total_credits,
|
|
519
|
-
c.overlap_pairs
|
|
691
|
+
c.overlap_pairs,
|
|
692
|
+
c.pages_approved,
|
|
693
|
+
c.best_thought_tasks
|
|
520
694
|
FROM achievements a
|
|
521
695
|
CROSS JOIN counters c
|
|
522
696
|
WHERE NOT EXISTS (
|
|
@@ -594,6 +768,25 @@ function renderProgressText(row) {
|
|
|
594
768
|
return artShips === 0
|
|
595
769
|
? 'needs one shipped task touching art/'
|
|
596
770
|
: 'keep building';
|
|
771
|
+
// task 1004438: the craft achievements
|
|
772
|
+
case 'first-thought-ignited':
|
|
773
|
+
return 'needs one thought of yours to become shipped work';
|
|
774
|
+
case 'first-full-idea':
|
|
775
|
+
return 'needs one Full Idea to pass the completeness bar';
|
|
776
|
+
case 'first-board-ratification':
|
|
777
|
+
return 'needs the board to ratify one idea of yours';
|
|
778
|
+
case 'thought-built-ten': {
|
|
779
|
+
const best = Math.max(0, Math.min(Number(row.best_thought_tasks || 0), 10));
|
|
780
|
+
return `${best} of 10 shipped tasks from one thought`;
|
|
781
|
+
}
|
|
782
|
+
case 'first-page-approved':
|
|
783
|
+
return 'needs one page you rewrote to be approved';
|
|
784
|
+
case 'ten-pages-approved': {
|
|
785
|
+
const pages = Math.max(0, Math.min(Number(row.pages_approved || 0), 10));
|
|
786
|
+
return `${pages} of 10 pages approved`;
|
|
787
|
+
}
|
|
788
|
+
case 'whole-surface-tweaked':
|
|
789
|
+
return 'needs every page of one surface rewritten by you and approved';
|
|
597
790
|
case 'gds-shipper':
|
|
598
791
|
return pmsShips === 0
|
|
599
792
|
? 'needs one shipped task touching src/bongos/ or scripts/gds/'
|
|
@@ -612,6 +805,8 @@ module.exports = {
|
|
|
612
805
|
evaluateAfterShip,
|
|
613
806
|
evaluateAfterCreditAward,
|
|
614
807
|
reconcileBuilderAchievements,
|
|
808
|
+
reconcileAfterCraftCredit,
|
|
809
|
+
backfillCraftAchievements,
|
|
615
810
|
STATE_EVALUATORS,
|
|
616
811
|
alreadyUnlocked,
|
|
617
812
|
unlockAchievement,
|
|
@@ -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
|
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
var now = g.stages.find(function (s) { return s.state === 'now'; });
|
|
80
80
|
var sub = g.deciding
|
|
81
81
|
? 'Deciding the plan' + (g.day ? ' · day ' + g.day : '')
|
|
82
|
-
: 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.day ? ' · day ' + g.day : '');
|
|
82
|
+
: 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.demo ? ' · demo' : g.day ? ' · day ' + g.day : '');
|
|
83
83
|
return '<div class="genesis__head"><h2 id="genesis-h" class="scroll__h">Genesis</h2>'
|
|
84
84
|
+ '<p class="genesis__sub">' + esc(sub) + '</p></div>' + stepperHtml(g);
|
|
85
85
|
}
|
|
@@ -157,7 +157,10 @@
|
|
|
157
157
|
function stagesHtml(g) {
|
|
158
158
|
return '<ol class="genesis__stages">' + g.stages.map(function (s) {
|
|
159
159
|
var wakes = (s.wakes || []).map(function (id) { return ROOM[id] || id; });
|
|
160
|
-
|
|
160
|
+
// A demo's stage is minutes long (task 1004506): say how long, and due at a time.
|
|
161
|
+
var due = s.minutes
|
|
162
|
+
? ' · ' + s.minutes + ' min' + (s.state === 'now' && s.due ? ', due ' + new Date(s.due).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : '')
|
|
163
|
+
: s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
|
|
161
164
|
return '<li class="genesis__stage is-' + s.state + '">'
|
|
162
165
|
+ '<span class="genesis__stage-n">0' + s.number + '</span>'
|
|
163
166
|
+ '<span class="genesis__stage-t"><b>' + esc(s.label) + '</b>'
|
|
@@ -225,12 +228,23 @@
|
|
|
225
228
|
return document.documentElement.getAttribute('data-founding') === 'on';
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
// A demo's genesis is expedited (task 1004506): its plan was settled from the demo's
|
|
232
|
+
// own picks before anyone saw it, so it never shows the decide form, only this line.
|
|
233
|
+
function demoHtml(g) {
|
|
234
|
+
var d = g.demo;
|
|
235
|
+
if (!d) return '';
|
|
236
|
+
var total = g.stages.reduce(function (n, s) { return n + (s.minutes || 0); }, 0);
|
|
237
|
+
return '<p class="scroll__lede">A demo’s genesis is short: about ' + esc(String(total)) + ' minutes, then the rest of your time is for building. '
|
|
238
|
+
+ 'The plan is already set from your demo: Monarchy / BDFL, ' + esc(String(d.people)) + (d.people === 1 ? ' person, ' : ' people, ')
|
|
239
|
+
+ esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it to move on.</p>';
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
function render(host, state) {
|
|
229
243
|
var g = state.genesis;
|
|
230
244
|
if (!g) { host.hidden = true; return; }
|
|
231
245
|
host.innerHTML = headHtml(g) + (g.deciding
|
|
232
246
|
? decideHtml(g, state.gov)
|
|
233
|
-
: lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
|
|
247
|
+
: demoHtml(g) + lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
|
|
234
248
|
host.hidden = false;
|
|
235
249
|
}
|
|
236
250
|
|
|
@@ -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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The genesis home of a DEMO, born into genesis (task 1004506): 3 people, 4 hours each. The plan was settled at birth from the demo's picks, so there is no decide form: one line names the plan, and each stage reads in minutes (scope in progress, due at a time). /me says founding at scope (kit fixture me-founding-demo.json) and /founding/genesis answers founding-genesis-demo.json, built from the real server shaper. A real project's genesis is index-genesis.states.json and index-genesis-decide.states.json; a hall that is not founding is index.states.json.",
|
|
3
|
+
"page": "/builders/",
|
|
4
|
+
"surface": "hall-ui",
|
|
5
|
+
"stub": { "prefix": "/builders/", "fixtures": { "me": "me-founding-demo", "founding-genesis": "founding-genesis-demo" } },
|
|
6
|
+
"modeQuery": false,
|
|
7
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard", "/api/(gds|bongos)/removal-proposals", "/api/(gds|bongos)/blockers"],
|
|
8
|
+
"_ignored": "removal-proposals and blockers are fixtures the kit stub does not carry; index.states.json's home render reports the same two 404s.",
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
10
|
+
"states": {
|
|
11
|
+
"demo": { "auth": true, "actions": [["wait", 800]], "expect": { "visible": ["#genesis-home", "#founding-band"] } }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -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. */
|