@bongos/core 1.25.2 → 1.25.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.bongos-core.json +55 -40
  2. package/docs/copy-inventory.md +30 -29
  3. package/docs/copy-registry.json +78 -58
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +5 -3
  6. package/docs/page-readings.json +44 -44
  7. package/modules/hall-ui/public/builders.js +3 -27
  8. package/modules/hall-ui/public/genesis-home.js +54 -4
  9. package/modules/hall-ui/public/hall-render.js +5 -119
  10. package/modules/hall-ui/public/hall-widgets.js +3 -3
  11. package/modules/hall-ui/public/index-genesis-votes.states.json +61 -0
  12. package/modules/hall-ui/public/index.html +0 -7
  13. package/modules/hall-ui/public/shell-game.js +22 -4
  14. package/modules/hall-ui/public/style.css +3 -73
  15. package/modules/hall-ui/public/visor.css +31 -1
  16. package/modules/hall-ui/public/visor.js +53 -3
  17. package/modules/hall-ui/records/home-reshuffle.md +1 -1
  18. package/modules/hall-ui/records/visor.md +10 -0
  19. package/modules/onboarding/founding-mode.js +4 -1
  20. package/modules/onboarding/genesis-home.js +57 -2
  21. package/modules/onboarding/routes/onboarding.js +2 -0
  22. package/modules/ui-design/kit/fixtures/founding-genesis-votes.json +193 -0
  23. package/modules/ui-design/kit/fixtures/me-founding-demo.json +0 -1
  24. package/modules/ui-design/kit/fixtures/me-founding.json +0 -1
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +12 -0
  28. package/src/module-api.js +1 -1
  29. package/tests/founding_mode.mjs +13 -3
  30. package/tests/genesis_home.mjs +92 -4
  31. package/tests/hall_board_world.mjs +7 -35
  32. package/tests/hall_home_ships_bounded.mjs +11 -27
  33. package/tests/hall_landing_boot.mjs +7 -8
  34. package/tests/hall_nav.mjs +2 -2
  35. package/tests/hall_visor.mjs +9 -2
  36. package/tests/hall_visor_profile.mjs +150 -0
  37. package/tests/leaderboard_roles.mjs +3 -17
@@ -156,6 +156,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
156
156
  const idx = fm.STAGES.indexOf(key);
157
157
  const list = (tasks[key] || []).map((t) => ({ ...t, finished: FINISHED.includes(t.status) }));
158
158
  const b = board[key] || null;
159
+ const allFinished = list.length > 0 && list.every((t) => t.finished);
160
+ const onBoard = !!(b && (b.state === 'sitting' || b.state === 'passed'));
159
161
  const days = plan && plan.days && plan.days[key] ? plan.days[key] : info.days;
160
162
  // A demo's stage is minutes long (task 1004506); its due time is counted in them.
161
163
  const minutes = plan && plan.minutes && plan.minutes[key] ? plan.minutes[key] : null;
@@ -177,6 +179,9 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
177
179
  tasks: list,
178
180
  finished: list.filter((t) => t.finished).length,
179
181
  total: list.length,
182
+ // task 1005199: this stage (the current one or any later one) can be put to
183
+ // the Board Room now — its tasks are all finished and it is not already there.
184
+ can_close: at >= 1 && idx >= at && allFinished && !onBoard,
180
185
  board: b ? {
181
186
  state: b.state, item_id: b.item_id || null, needs: b.needs || null,
182
187
  one_vote_decides: !!b.one_vote_decides, waiting_on: b.waiting_on || [],
@@ -349,7 +354,15 @@ async function closeStage(stageKey, builderId, deps = {}) {
349
354
  const s = seams(deps);
350
355
  const mode = await reconcile(s);
351
356
  if (!mode) return { ok: false, reason: 'not_founding' };
352
- if (mode.stage !== stageKey) return { ok: false, reason: 'not_current_stage', stage: mode.stage };
357
+ // task 1005199: any stage at or AFTER the current one may be put up, so every
358
+ // finished stage can sit in the Board Room at once. Order is kept by reconcile, not
359
+ // here: the project only moves past a stage whose own sitting passed, so a later
360
+ // stage's pass waits for the earlier ones. A stage already behind, or the decide
361
+ // pre-stage, is still refused.
362
+ if (!PROPER.includes(stageKey)) return { ok: false, reason: 'unknown_stage' };
363
+ if (!PROPER.includes(mode.stage) || fm.STAGES.indexOf(stageKey) < fm.STAGES.indexOf(mode.stage)) {
364
+ return { ok: false, reason: 'not_current_stage', stage: mode.stage };
365
+ }
353
366
  const all = s.lifecycle && typeof s.lifecycle.genesisTasks === 'function' ? await s.lifecycle.genesisTasks() : {};
354
367
  const list = all[stageKey] || [];
355
368
  const taskIds = list.map((t) => t.id);
@@ -357,9 +370,13 @@ async function closeStage(stageKey, builderId, deps = {}) {
357
370
  const nextOf = (key) => (key === 'legislation' ? null : fm.STAGES[fm.STAGES.indexOf(key) + 1]);
358
371
  const closedAnswer = async (itemId, confirm) => {
359
372
  const after = await reconcile(s);
373
+ const at = after && after.stage;
360
374
  return {
361
375
  ok: true, closed: true, stage: stageKey,
362
- next_stage: stageKey === 'legislation' ? null : (after && after.stage) || nextOf(stageKey),
376
+ next_stage: stageKey === 'legislation' && at === 'legislation' ? null : at || nextOf(stageKey),
377
+ // task 1005199: a later stage passed while an earlier one is still open, so
378
+ // the project stays at the earlier stage until that one passes too.
379
+ waits_for_earlier: !!(at && fm.STAGES.indexOf(at) < fm.STAGES.indexOf(stageKey)),
363
380
  item_id: itemId, confirm,
364
381
  };
365
382
  };
@@ -379,6 +396,9 @@ async function closeStage(stageKey, builderId, deps = {}) {
379
396
  return sittingOpen(stageKey, r.item ? String(r.item.id) : null, !r.opened, board[stageKey] || null, builderId);
380
397
  }
381
398
  if (!list.length || !list.every((t) => FINISHED.includes(t.status))) return { ok: false, reason: 'stage_tasks_unfinished' };
399
+ // With no Board Room there is no sitting to hold a later stage, so only the
400
+ // current one can be closed here; a later one waits its turn.
401
+ if (stageKey !== mode.stage) return { ok: false, reason: 'not_current_stage', stage: mode.stage };
382
402
  if (stageKey !== 'legislation') {
383
403
  await fm.setFoundingMode({ stage: nextOf(stageKey) }, builderId, modeDeps(s));
384
404
  }
@@ -452,9 +472,44 @@ async function onStagePassed(deps = {}) {
452
472
  try { await reconcile(seams(deps)); } catch (_) { /* recovered on the next read */ }
453
473
  }
454
474
 
475
+ // putFinishedStages — shipping a stage puts it in the Board Room by itself (task
476
+ // 1005199). Every stage at or after the current one whose tasks are all finished and
477
+ // that has NEVER been put to the board is opened as a sitting, with nobody's vote cast:
478
+ // the founder still says yes, but no longer has to press "close" first. A stage the
479
+ // board sent back is left for the founder to put up again on purpose. Opening is the
480
+ // government port's own gate (finished tasks, one sitting per stage), so a race with a
481
+ // manual close opens one sitting, not two. Returns the stage keys it put up.
482
+ async function putFinishedStages(deps = {}) {
483
+ const s = seams(deps);
484
+ const gov = s.government;
485
+ if (!gov || typeof gov.openGenesisStageClose !== 'function') return [];
486
+ const mode = await reconcile(s);
487
+ if (!mode || !PROPER.includes(mode.stage)) return [];
488
+ const all = s.lifecycle && typeof s.lifecycle.genesisTasks === 'function' ? await s.lifecycle.genesisTasks() : {};
489
+ const board = await boardByStage(gov);
490
+ const put = [];
491
+ for (const key of PROPER.slice(PROPER.indexOf(mode.stage))) {
492
+ const list = all[key] || [];
493
+ if (!list.length || !list.every((t) => FINISHED.includes(t.status))) continue;
494
+ const st = board[key] && board[key].state;
495
+ if (st && st !== 'not_put') continue;
496
+ const r = await gov.openGenesisStageClose({ stageKey: key, taskIds: list.map((t) => t.id), openedBy: null });
497
+ if (r && r.opened) put.push(key);
498
+ }
499
+ return put;
500
+ }
501
+
502
+ // onTaskShipped — the `task.shipped` listener. Any ship may be the last task of a
503
+ // stage; on a project that is not founding this costs the one genesis.enabled read.
504
+ // Best-effort: the founder's own close button is still there if this misses.
505
+ async function onTaskShipped(deps = {}) {
506
+ try { return await putFinishedStages(deps); } catch (_) { return []; }
507
+ }
508
+
455
509
  module.exports = {
456
510
  PLAN_KEY, STAGE_INFO, PROPER, DEMO_PEOPLE, DEMO_HOURS, intIn, parsePlan, shapeGenesis, demoMinutes, demoPlan,
457
511
  readGenesis, startFounding, savePlan, applyGovernmentChoice, closeStage, comeAlive, skipGenesis, onStagePassed,
512
+ putFinishedStages, onTaskShipped,
458
513
  // the founding-mode deps this file reads the mode with, for founding-birth.js (task 1004506)
459
514
  modeDepsFor: (deps) => modeDeps(seams(deps)),
460
515
  };
@@ -134,6 +134,8 @@ function mountGenesisRoutes(router) {
134
134
  if (!genesisListening && typeof api.on === 'function') {
135
135
  genesisListening = true;
136
136
  api.on('board.genesis_stage.passed', () => genesis.onStagePassed());
137
+ // task 1005199: a ship that finishes a stage puts that stage in the Board Room.
138
+ api.on('task.shipped', () => genesis.onTaskShipped());
137
139
  }
138
140
  const owns = api.requirePermission('project.settings.manage');
139
141
  const refuse = (res, r) => res.status(GENESIS_REFUSALS[r.reason] || 409).json({
@@ -0,0 +1,193 @@
1
+ {
2
+ "_": "Genesis with several stages in the Board Room at once (task 1005199): scope, strategize and charter sitting on the founder, legalize finished and not yet put up, legislation in progress.",
3
+ "genesis": {
4
+ "stage": "scope",
5
+ "stage_number": 1,
6
+ "stage_count": 5,
7
+ "day": 2,
8
+ "deciding": false,
9
+ "plan": {
10
+ "days": {
11
+ "scope": 7,
12
+ "strategize": 7,
13
+ "charter": 4,
14
+ "legalize": 7,
15
+ "legislation": 3
16
+ },
17
+ "passes": 1,
18
+ "advisors": "none",
19
+ "government": "monarchy",
20
+ "expected_builders": 1,
21
+ "existing": false
22
+ },
23
+ "demo": null,
24
+ "stages": [
25
+ {
26
+ "key": "scope",
27
+ "number": 1,
28
+ "label": "Scope",
29
+ "life": "primordial sea",
30
+ "plate": "r5-life-1-primordial-sea",
31
+ "done_when": "The scope is written: what you are making, for whom, and what is left out.",
32
+ "wakes": [
33
+ "goals",
34
+ "goal-map"
35
+ ],
36
+ "days": 7,
37
+ "minutes": null,
38
+ "due": "2026-11-17T09:00:00.000Z",
39
+ "state": "now",
40
+ "tasks": [
41
+ {
42
+ "id": "2001",
43
+ "title": "Scope: write it down",
44
+ "status": "shipped",
45
+ "finished": true
46
+ }
47
+ ],
48
+ "finished": 1,
49
+ "total": 1,
50
+ "can_close": false,
51
+ "board": {
52
+ "state": "sitting",
53
+ "item_id": "41",
54
+ "needs": "One yes decides: the first member to say yes closes it.",
55
+ "one_vote_decides": true,
56
+ "waiting_on": [
57
+ {
58
+ "builder_id": "1",
59
+ "display_name": "Founder"
60
+ }
61
+ ]
62
+ }
63
+ },
64
+ {
65
+ "key": "strategize",
66
+ "number": 2,
67
+ "label": "Strategize",
68
+ "life": "first cells",
69
+ "plate": "r5-life-2-first-cells",
70
+ "done_when": "The strategy is written out, or the one you have is linked.",
71
+ "wakes": [
72
+ "work"
73
+ ],
74
+ "days": 7,
75
+ "minutes": null,
76
+ "due": null,
77
+ "state": "later",
78
+ "tasks": [
79
+ {
80
+ "id": "2002",
81
+ "title": "Strategize: write it down",
82
+ "status": "shipped",
83
+ "finished": true
84
+ }
85
+ ],
86
+ "finished": 1,
87
+ "total": 1,
88
+ "can_close": false,
89
+ "board": {
90
+ "state": "sitting",
91
+ "item_id": "42",
92
+ "needs": "One yes decides: the first member to say yes closes it.",
93
+ "one_vote_decides": true,
94
+ "waiting_on": [
95
+ {
96
+ "builder_id": "1",
97
+ "display_name": "Founder"
98
+ }
99
+ ]
100
+ }
101
+ },
102
+ {
103
+ "key": "charter",
104
+ "number": 3,
105
+ "label": "Charter",
106
+ "life": "the genome",
107
+ "plate": "r5-life-3-genome",
108
+ "done_when": "The starting government is chosen and the bylaws are written.",
109
+ "wakes": [],
110
+ "days": 4,
111
+ "minutes": null,
112
+ "due": null,
113
+ "state": "later",
114
+ "tasks": [
115
+ {
116
+ "id": "2003",
117
+ "title": "Charter: write it down",
118
+ "status": "shipped",
119
+ "finished": true
120
+ }
121
+ ],
122
+ "finished": 1,
123
+ "total": 1,
124
+ "can_close": false,
125
+ "board": {
126
+ "state": "sitting",
127
+ "item_id": "43",
128
+ "needs": "One yes decides: the first member to say yes closes it.",
129
+ "one_vote_decides": true,
130
+ "waiting_on": [
131
+ {
132
+ "builder_id": "1",
133
+ "display_name": "Founder"
134
+ }
135
+ ]
136
+ }
137
+ },
138
+ {
139
+ "key": "legalize",
140
+ "number": 4,
141
+ "label": "Legalize",
142
+ "life": "the membrane",
143
+ "plate": "r5-life-4-membrane",
144
+ "done_when": "Rewards are decided, and who can be paid.",
145
+ "wakes": [],
146
+ "days": 7,
147
+ "minutes": null,
148
+ "due": null,
149
+ "state": "later",
150
+ "tasks": [
151
+ {
152
+ "id": "2004",
153
+ "title": "Legalize: write it down",
154
+ "status": "shipped",
155
+ "finished": true
156
+ }
157
+ ],
158
+ "finished": 1,
159
+ "total": 1,
160
+ "can_close": true,
161
+ "board": null
162
+ },
163
+ {
164
+ "key": "legislation",
165
+ "number": 5,
166
+ "label": "Legislation",
167
+ "life": "the nervous system",
168
+ "plate": "r5-life-5-nervous-system",
169
+ "done_when": "The voting rules are set: who votes, and how many must agree.",
170
+ "wakes": [],
171
+ "days": 3,
172
+ "minutes": null,
173
+ "due": null,
174
+ "state": "later",
175
+ "tasks": [
176
+ {
177
+ "id": "2005",
178
+ "title": "Legislation: write it down",
179
+ "status": "active",
180
+ "finished": false
181
+ }
182
+ ],
183
+ "finished": 0,
184
+ "total": 1,
185
+ "can_close": false,
186
+ "board": null
187
+ }
188
+ ],
189
+ "can_close": false,
190
+ "can_come_alive": false
191
+ },
192
+ "viewer_id": "1"
193
+ }
@@ -17,7 +17,6 @@
17
17
  "goals": "scope",
18
18
  "goal-map": "scope",
19
19
  "work": "strategize",
20
- "board-room": "charter",
21
20
  "economy": "legalize",
22
21
  "ranks": "legislation",
23
22
  "collab": "alive",
@@ -14,7 +14,6 @@
14
14
  "day": 9,
15
15
  "asleep": {
16
16
  "work": "strategize",
17
- "board-room": "charter",
18
17
  "economy": "legalize",
19
18
  "ranks": "legislation",
20
19
  "collab": "alive",
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.2",
3
+ "version": "1.25.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.2",
9
+ "version": "1.25.4",
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.25.2",
3
+ "version": "1.25.4",
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",
@@ -11706,5 +11706,17 @@
11706
11706
  "id": "1005248",
11707
11707
  "text": "In game mode, no two buttons on the visor share a hotkey any more, and Options now sits only inside the Actions menu with its own key."
11708
11708
  }
11709
+ ],
11710
+ "1.25.3": [
11711
+ {
11712
+ "id": "1005199",
11713
+ "text": "Founding a project is faster: each finished stage goes to the Board Room by itself, every stage can be voted on at once with one \"vote yes on all\" button, and the Board Room no longer looks locked during genesis."
11714
+ }
11715
+ ],
11716
+ "1.25.4": [
11717
+ {
11718
+ "id": "1005249",
11719
+ "text": "Your details now sit on your character at the bottom left of the game: hover to see them, click to open your full profile without leaving the scene. The old profile card at the top right is gone."
11720
+ }
11709
11721
  ]
11710
11722
  }
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.2'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.4'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -38,22 +38,32 @@ test('an unknown stage fails CLOSED — OFF, never a hall stuck half-asleep', ()
38
38
  assert.equal(fm.shapeFoundingMode({ active: 'true', stage: 'launch' }), null);
39
39
  });
40
40
 
41
- test('active with no stage yet is the decide pre-stage, and every room sleeps', () => {
41
+ test('active with no stage yet is the decide pre-stage, and every room but the Board Room sleeps', () => {
42
42
  const f = fm.shapeFoundingMode({ active: 'true' });
43
43
  assert.equal(f.stage, 'decide');
44
44
  assert.equal(f.stage_number, 0);
45
45
  assert.equal(f.stage_count, 5);
46
46
  // task 1005051: Roadmap folded into Goals; scope wakes Goals and its map.
47
- for (const id of ['goals', 'goal-map', 'work', 'board-room', 'collab', 'roster']) {
47
+ for (const id of ['goals', 'goal-map', 'work', 'collab', 'roster']) {
48
48
  assert.ok(id in f.asleep, `${id} sleeps while deciding`);
49
49
  }
50
50
  });
51
51
 
52
+ // task 1005199: every stage, from scope on, is closed by a vote in the Board Room, so
53
+ // the room must never read as locked while genesis has a ballot in it.
54
+ test('the Board Room is awake at every stage of genesis', () => {
55
+ for (const stage of fm.STAGES) {
56
+ const f = fm.shapeFoundingMode({ active: 'true', stage });
57
+ assert.ok(!('board-room' in f.asleep), `board-room is awake at ${stage}`);
58
+ }
59
+ assert.ok(!Object.values(fm.WAKES).some((ids) => ids.includes('board-room')), 'no stage wakes it, because it never sleeps');
60
+ });
61
+
52
62
  test('a room wakes once the stage that wakes it is behind', () => {
53
63
  const f = fm.shapeFoundingMode({ active: 'true', stage: 'charter' });
54
64
  for (const id of ['goals', 'goal-map', 'work']) assert.ok(!(id in f.asleep), `${id} woke`);
55
65
  assert.ok(!('roadmap' in f.asleep), 'the folded Roadmap is no room to wake');
56
- assert.equal(f.asleep['board-room'], 'charter');
66
+ assert.ok(!('board-room' in f.asleep), 'the Board Room never sleeps (task 1005199)');
57
67
  // task 1005046: Economy and Ranks were the rooms legalize and legislation woke;
58
68
  // they are Primer chapters now, and the Primer never sleeps.
59
69
  assert.ok(!('economy' in f.asleep) && !('ranks' in f.asleep), 'no retired Docs page is put to sleep');
@@ -249,13 +249,65 @@ test('the owner can skip genesis from any stage: no sitting, hall opens, recorde
249
249
  assert.equal(await gh.readGenesis(mid.deps()), null);
250
250
  });
251
251
 
252
- test('only the stage in progress can be put to the board', async () => {
252
+ test('a later stage can be put to the board once finished; a stage behind cannot (task 1005199)', async () => {
253
253
  const w = world();
254
254
  await gh.startFounding(1, w.deps());
255
255
  await gh.savePlan({}, 1, w.deps());
256
+ assert.equal((await gh.closeStage('charter', 1, w.deps())).reason, 'stage_tasks_unfinished', 'the finished-tasks gate still holds');
257
+ w.finish('charter');
256
258
  const r = await gh.closeStage('charter', 1, w.deps());
257
- assert.equal(r.reason, 'not_current_stage');
258
- assert.equal(w.opened.length, 0);
259
+ assert.equal(r.reason, 'sitting_open', 'charter sits while the project is still at scope');
260
+ assert.deepEqual(w.opened.at(-1).stageKey, 'charter');
261
+ assert.equal((await gh.readGenesis(w.deps())).stage, 'scope');
262
+ w.finish('scope'); w.pass('scope'); await gh.onStagePassed(w.deps());
263
+ assert.equal((await gh.closeStage('scope', 1, w.deps())).reason, 'not_current_stage', 'a stage behind is refused');
264
+ assert.equal((await gh.closeStage('decide', 1, w.deps())).reason, 'unknown_stage');
265
+ });
266
+
267
+ test('every finished stage sits at once, and they pass in order (task 1005199)', async () => {
268
+ const w = world();
269
+ await gh.startFounding(1, w.deps());
270
+ await gh.savePlan({}, 1, w.deps());
271
+ for (const s of STAGES5) w.finish(s);
272
+ let g = await gh.readGenesis(w.deps());
273
+ assert.deepEqual(g.stages.map((s) => s.can_close), [true, true, true, true, true], 'every finished stage can be put up');
274
+ for (const s of STAGES5) await gh.closeStage(s, 1, w.deps());
275
+ g = await gh.readGenesis(w.deps());
276
+ assert.deepEqual(g.stages.map((s) => s.board && s.board.state), Array(5).fill('sitting'));
277
+ assert.deepEqual(g.stages.map((s) => s.can_close), [false, false, false, false, false], 'a sitting stage is not put up twice');
278
+ // A later pass does not move the project past an earlier stage still sitting.
279
+ w.pass('charter'); w.pass('legalize');
280
+ await gh.onStagePassed(w.deps());
281
+ assert.equal((await gh.readGenesis(w.deps())).stage, 'scope', 'scope still sits: the project waits for it');
282
+ w.pass('scope'); await gh.onStagePassed(w.deps());
283
+ assert.equal((await gh.readGenesis(w.deps())).stage, 'strategize', 'scope passed; strategize still sits, so the project stops there');
284
+ w.pass('strategize'); await gh.onStagePassed(w.deps());
285
+ assert.equal((await gh.readGenesis(w.deps())).stage, 'legislation', 'strategize passed: charter and legalize had passed already');
286
+ w.pass('legislation'); await gh.onStagePassed(w.deps());
287
+ g = await gh.readGenesis(w.deps());
288
+ assert.equal(g.can_come_alive, true, 'one click left: bring it to life');
289
+ assert.equal((await gh.comeAlive(1, w.deps())).ok, true);
290
+ });
291
+
292
+ test('shipping the last task of a stage puts it in the Board Room by itself (task 1005199)', async () => {
293
+ const w = world();
294
+ assert.deepEqual(await gh.onTaskShipped(w.deps()), [], 'not founding: nothing is put up');
295
+ await gh.startFounding(1, w.deps());
296
+ await gh.savePlan({}, 1, w.deps());
297
+ assert.deepEqual(await gh.onTaskShipped(w.deps()), [], 'no stage is finished yet');
298
+ w.finish('scope'); w.finish('legalize');
299
+ assert.deepEqual(await gh.onTaskShipped(w.deps()), ['scope', 'legalize'], 'every finished stage, current and later');
300
+ assert.equal(w.opened.length, 2);
301
+ assert.deepEqual(await gh.onTaskShipped(w.deps()), [], 'a stage already sitting is not put up again');
302
+ w.board.strategize = { state: 'returned', waiting_on: [] };
303
+ w.finish('strategize');
304
+ assert.deepEqual(await gh.onTaskShipped(w.deps()), [], 'a stage the board sent back waits for the founder');
305
+ assert.deepEqual(await gh.onTaskShipped({ ...w.deps(), government: { genesisStageStatus: async () => { throw new Error('down'); }, openGenesisStageClose: async () => ({}) } }), [], 'a failure never throws into the ship');
306
+ });
307
+
308
+ test('the route factory listens for ships (task 1005199)', () => {
309
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'onboarding', 'routes', 'onboarding.js'), 'utf8');
310
+ assert.match(src, /api\.on\('task\.shipped', \(\) => genesis\.onTaskShipped\(\)\)/);
259
311
  });
260
312
 
261
313
  test('a returned stage stays put and can be put up again', async () => {
@@ -316,7 +368,7 @@ function bootCard({ founding, data = { genesis: null } }) {
316
368
  const attrs = founding ? { 'data-founding': 'on' } : {};
317
369
  const sandbox = {
318
370
  window: {
319
- OTB: { escapeHtml: (s) => String(s), contributeHallWidget: (w) => { widget = w; } },
371
+ OTB: { escapeHtml: (s) => String(s), statusPill: (s) => `<span>${s}</span>`, contributeHallWidget: (w) => { widget = w; } },
320
372
  BongosClient: { createClient: () => ({ request: async (m, p) => { calls.push(`${m} ${p}`); return { ok: true, status: 200, data }; } }) },
321
373
  },
322
374
  document: { documentElement: { getAttribute: (k) => attrs[k] || null } },
@@ -371,6 +423,42 @@ test('a demo\'s genesis card: no decide form, the settled plan in one line, each
371
423
  assert.doesNotMatch(host.innerHTML, /data-genesis-plan/, 'a demo never shows the decide form');
372
424
  });
373
425
 
426
+ test('the card lists every stage vote waiting on the viewer, and votes yes on all in order (task 1005199)', async () => {
427
+ const me = [{ builder_id: '1', display_name: 'Founder' }];
428
+ const genesis = gh.shapeGenesis({
429
+ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 },
430
+ plan: gh.parsePlan({}).plan,
431
+ tasks: Object.fromEntries(STAGES5.map((s, i) => [s, [{ id: String(100 + i), title: s, status: i < 4 ? 'shipped' : 'ready' }]])),
432
+ board: {
433
+ scope: { state: 'sitting', item_id: '11', waiting_on: me, needs: 'One yes decides.' },
434
+ strategize: { state: 'sitting', item_id: '12', waiting_on: me },
435
+ charter: { state: 'passed', item_id: '13', waiting_on: [] },
436
+ },
437
+ });
438
+ assert.deepEqual(genesis.stages.map((s) => s.can_close), [false, false, false, true, false], 'only legalize is finished and not yet on the board');
439
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
440
+ let onClick = null;
441
+ b.host.addEventListener = (type, fn) => { if (type === 'click') onClick = fn; };
442
+ b.host.contains = () => true;
443
+ b.widget.mount(b.host); // re-mount so the click handler is captured
444
+ await new Promise((r) => setImmediate(r));
445
+ await new Promise((r) => setImmediate(r));
446
+ const html = b.host.innerHTML;
447
+ assert.match(html, /Waiting for your vote · 2 stages/);
448
+ assert.match(html, /data-genesis-vote-all>Vote yes on all 2</);
449
+ assert.match(html, /passed, waits its turn/, 'a later stage that passed says it waits for the earlier ones');
450
+ assert.match(html, /data-genesis-close="legalize">Put to the board</, 'a finished later stage can be put up from its row');
451
+ assert.doesNotMatch(html, /data-genesis-close="legislation"/, 'an unfinished stage cannot');
452
+ b.calls.length = 0;
453
+ const btn = { hasAttribute: (k) => k === 'data-genesis-vote-all' };
454
+ onClick({ target: { closest: () => btn } });
455
+ for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
456
+ assert.deepEqual(b.calls.slice(0, 2), [
457
+ 'POST /api/bongos/government/board/items/11/votes',
458
+ 'POST /api/bongos/government/board/items/12/votes',
459
+ ], 'one yes per stage, earliest first');
460
+ });
461
+
374
462
  test('the hall carries the five life plates byte-identical to the world\'s', () => {
375
463
  const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
376
464
  const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
@@ -270,23 +270,6 @@ test('the band ends level: no align-items on the grid, so the two cards stretch'
270
270
  'and the single-column fallback still exists below 768px (the task text said 860; the sheet says 768)');
271
271
  });
272
272
 
273
- test('the Standing card is the mock\'s: an identity line and a three-row fact ledger, no banner', () => {
274
- const block = hallRenderJs.match(/const homeStanding = document\.getElementById\('home-standing-body'\);[\s\S]*?renderSystemNotices\(/);
275
- assert.ok(block, 'the Home card render is where it was');
276
- assert.match(block[0], /class="home-who"/, 'the identity line');
277
- assert.match(block[0], /<dt>\$\{escapeHtml\(curLabel\(true\)\)\}<\/dt>/,
278
- 'the currency row takes its label from the branding contract at render time — a data-currency-label attribute is rebranded once at load, before this row exists');
279
- for (const label of ['Leaderboard place', 'Preferred work']) {
280
- assert.match(block[0], new RegExp(`<dt>${label}</dt>`), `the ledger row: ${label}`);
281
- }
282
- assert.match(block[0], /: PREFS_EMPTY\}/, 'the empty-preference copy is the one string both Standing surfaces share');
283
- assert.doesNotMatch(block[0], /Banner\}/, 'no banner is stacked into the card any more');
284
- for (const sel of ['.home-who', '.home-facts', '.home-facts dt', '.home-facts dd']) {
285
- assert.ok(rule(styleCss, sel), `style.css styles ${sel}`);
286
- }
287
- assert.match(rule(styleCss, '.home-who__avatar') || '', /var\(--skeleton\)/, 'the avatar placeholder is the skeleton tone, not --bg-deep (pure black in dark)');
288
- });
289
-
290
273
  test('the system notices are inbox LINES, first in the card, and counted by its refresh()', () => {
291
274
  // Anchored on the statement AFTER the markup, so a ';' inside the markup or its comments cannot cut it short.
292
275
  const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);\n host\.addEventListener/);
@@ -322,7 +305,7 @@ test('the greeting reads /me only; the version tab and its progress read left Ho
322
305
 
323
306
  // Boot hall-render.js against a stub DOM and a ctx of getters (the way
324
307
  // builders.js builds it) and read what the greeting and the card actually say.
325
- function bootHallRender({ me, progress = null, leaderboard = [], ctxExtra = {} }) {
308
+ function bootHallRender({ me, progress = null, ctxExtra = {} }) {
326
309
  const els = new Map();
327
310
  const makeEl = () => ({
328
311
  dataset: {}, style: {}, textContent: '', hidden: false, _html: '', innerHTML: '',
@@ -364,7 +347,6 @@ function bootHallRender({ me, progress = null, leaderboard = [], ctxExtra = {} }
364
347
  ...ctxExtra,
365
348
  };
366
349
  const api = windowObj.OTBHallRender(ctx);
367
- api.lbState.rows = leaderboard;
368
350
  return { api, el: (id) => documentObj.getElementById(id) };
369
351
  }
370
352
 
@@ -374,7 +356,6 @@ const ME = {
374
356
  cost: { level: 'warn', spend_mtd_usd: 80, budget_mtd_usd: 100, pct_used: 80 },
375
357
  needs_rebase_tasks: [{ id: 1003460 }],
376
358
  };
377
- const LEADERBOARD = [{ github_login: 'a', total_credits: 20000 }, { github_login: 'percy', total_credits: 14441 }, { github_login: 'b', total_credits: 3 }, { github_login: 'c', total_credits: 1 }];
378
359
  const PROGRESS = { progress: [{ version_id: 'BONGOS-V1', status: 'building', total_count: 605, percent_complete: 75 }, { version_id: 'V2', status: 'planning', total_count: 9000, percent_complete: 0 }] };
379
360
 
380
361
  test('RUN: the greeting names the builder, and writes nothing else', () => {
@@ -386,22 +367,13 @@ test('RUN: the greeting names the builder, and writes nothing else', () => {
386
367
  assert.equal(el('home-version').textContent || '', '', 'no version tab is drawn');
387
368
  });
388
369
 
389
- test('RUN: the Standing card carries the three facts, the place as "2nd of 4", and no banner', () => {
390
- const { api, el } = bootHallRender({ me: ME, leaderboard: LEADERBOARD });
370
+ test('RUN: Home draws no Standing chip, your details are on the visor now (task 1005249)', () => {
371
+ const { api, el } = bootHallRender({ me: ME });
372
+ assert.equal(api.lbState, undefined, 'no leaderboard rows are kept for a place nobody shows');
373
+ assert.equal(api.setRecentShips, undefined, 'no shipped count is kept for a chip that is gone');
391
374
  api.renderProfile(ME.builder);
392
- const html = el('home-standing-body').innerHTML;
393
- assert.match(html, /<p class="home-who__name">Percy Jackson<\/p>/);
394
- assert.match(html, /<p class="home-who__handle">@percy<\/p>/);
395
- // written from curLabel(true), not a data-currency-label attribute: brand-apply
396
- // rebrands those once at load, and this row is stamped after /me answers
397
- assert.match(html, /<dt>Credits<\/dt><dd>14,441<\/dd>/);
398
- assert.match(html, /<dt>Leaderboard place<\/dt><dd>2nd of 4<\/dd>/);
399
- assert.match(html, /<dt>Preferred work<\/dt><dd>Not set yet<\/dd>/, 'the mock\'s copy for an empty preference');
400
- assert.doesNotMatch(html, /profile__(needs|budget|rebase)/, 'the banners are not in the card');
401
- const withPrefs = bootHallRender({ me: ME, leaderboard: [] });
402
- withPrefs.api.renderProfile({ ...ME.builder, preferred_disciplines: ['artisan', 'ui'] });
403
- assert.match(withPrefs.el('home-standing-body').innerHTML, /<dd>artisan, ui<\/dd>/);
404
- assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>Leaderboard place<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
375
+ // the stub document hands back a blank element for any id, so "drew nothing" is an empty one
376
+ assert.equal(el('home-standing-body').innerHTML || '', '', 'no Standing card is written');
405
377
  });
406
378
 
407
379
  // task 1005046: "Where you stand" left the retired Ranks page for Accomplishments,