@bongos/core 1.25.1 → 1.25.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,7 @@
28
28
  var DAY_CHOICES = [1, 2, 3, 4, 5, 7, 10, 14, 21, 30];
29
29
  var REFUSED = {
30
30
  stage_tasks_unfinished: 'This stage still has unfinished tasks.',
31
- not_current_stage: 'That stage is not the one in progress.',
31
+ not_current_stage: 'That stage is already behind you.',
32
32
  legislation_not_passed: 'Legislation has to pass the Board Room first.',
33
33
  not_deciding: 'The plan is already settled.',
34
34
  };
@@ -159,9 +159,15 @@
159
159
  }).join('') + '</ol>';
160
160
  }
161
161
 
162
+ // task 1005199: a later stage can already be in the Board Room, or passed and
163
+ // waiting for the stages before it, so its line says so instead of just "later".
162
164
  function stateText(s) {
163
165
  if (s.state === 'done') return 'done';
164
- if (s.state === 'later') return 'later';
166
+ var b = s.board && s.board.state;
167
+ if (b === 'sitting') return 'in the Board Room';
168
+ if (b === 'passed') return s.state === 'later' ? 'passed, waits its turn' : 'passed';
169
+ if (b === 'returned') return 'sent back';
170
+ if (s.state === 'later') return s.total && s.finished === s.total ? 'ready for the board' : 'later';
165
171
  return s.total ? s.finished + ' of ' + s.total + ' done' : 'in progress';
166
172
  }
167
173
 
@@ -175,7 +181,9 @@
175
181
  return '<li class="genesis__stage is-' + s.state + '">'
176
182
  + '<span class="genesis__stage-n">0' + s.number + '</span>'
177
183
  + '<span class="genesis__stage-t"><b>' + esc(s.label) + '</b>'
178
- + '<span>' + esc(wakes.length ? 'Wakes ' + wakes.join(', ') : '') + esc(due) + '</span></span>'
184
+ + '<span>' + esc(wakes.length ? 'Wakes ' + wakes.join(', ') : '') + esc(due) + '</span>'
185
+ + (s.state === 'later' && s.can_close ? '<span><button type="button" class="btn-ghost" data-genesis-close="' + esc(s.key) + '">Put to the board</button></span>' : '')
186
+ + '</span>'
179
187
  + '<span class="genesis__stage-st">' + esc(stateText(s)) + '</span></li>';
180
188
  }).join('') + '</ol>';
181
189
  }
@@ -222,6 +230,37 @@
222
230
  return '<p class="genesis__note">Finish every task above, then put this stage to the board.</p>';
223
231
  }
224
232
 
233
+ // ── every stage vote waiting on the viewer, in one place (task 1005199) ──────
234
+ // Each finished stage sits in the Board Room at once; this lists the ones still
235
+ // waiting on THIS viewer's vote, in stage order, and one button votes yes on all of
236
+ // them in that order. They still pass in order: the project only moves past a
237
+ // stage whose own sitting passed.
238
+ function votesWaiting(g, viewerId) {
239
+ return g.stages.filter(function (s) {
240
+ var b = s.board;
241
+ return b && b.state === 'sitting' && b.item_id && (b.waiting_on || []).some(function (m) {
242
+ return String(m.builder_id) === String(viewerId);
243
+ });
244
+ });
245
+ }
246
+
247
+ function votesHtml(g, viewerId) {
248
+ var list = votesWaiting(g, viewerId);
249
+ // The current stage's own Confirm button already covers a lone vote on it.
250
+ if (!list.length || (list.length === 1 && list[0].state === 'now')) return '';
251
+ var many = list.length > 1;
252
+ return '<section class="genesis__now" aria-labelledby="genesis-votes-h">'
253
+ + '<h3 id="genesis-votes-h" class="genesis__now-h">Waiting for your vote · ' + list.length + (many ? ' stages' : ' stage') + '</h3>'
254
+ + '<ul class="genesis__tasks">' + list.map(function (s) {
255
+ return '<li class="genesis__task"><span class="genesis__task-t">0' + s.number + ' ' + esc(s.label) + '</span></li>';
256
+ }).join('') + '</ul>'
257
+ // The rule is said once: every stage put up under one constitution reads the same.
258
+ + '<p class="genesis__note">' + esc(list[0].board.needs ? list[0].board.needs + ' ' : '')
259
+ + 'They pass in order: a later stage waits for the ones before it.</p>'
260
+ + '<div class="genesis__doors"><button type="button" class="btn-accent" data-genesis-vote-all>' + (many ? 'Vote yes on all ' + list.length : 'Vote yes') + '</button>'
261
+ + '<a class="btn-ghost" href="/board-room">Open the Board Room</a></div></section>';
262
+ }
263
+
225
264
  function aliveHtml() {
226
265
  var name = projectName();
227
266
  return '<div class="genesis__alive">'
@@ -254,7 +293,7 @@
254
293
  if (!g) { host.hidden = true; return; }
255
294
  host.innerHTML = headHtml(g) + (g.deciding
256
295
  ? decideHtml(g, state.gov)
257
- : demoHtml(g) + lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
296
+ : demoHtml(g) + lifeHtml(g) + votesHtml(g, state.viewer) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
258
297
  host.hidden = false;
259
298
  }
260
299
 
@@ -345,6 +384,17 @@
345
384
  } else if (t.hasAttribute('data-genesis-confirm')) {
346
385
  act(send('POST', '/government/board/items/' + encodeURIComponent(t.getAttribute('data-genesis-confirm')) + '/votes', { direction: 'yes' })
347
386
  .then(function () { return load(host, state); }));
387
+ } else if (t.hasAttribute('data-genesis-vote-all')) {
388
+ // One yes per stage, earliest first, each after the last has landed (task
389
+ // 1005199). A refusal on one stage stops the run and says why; the rest stay.
390
+ var ids = votesWaiting(state.genesis, state.viewer).map(function (s) { return s.board.item_id; });
391
+ act(ids.reduce(function (p, id) {
392
+ return p.then(function () {
393
+ return send('POST', '/government/board/items/' + encodeURIComponent(id) + '/votes', { direction: 'yes' });
394
+ });
395
+ }, Promise.resolve()).then(function () { return load(host, state); }, function (err) {
396
+ return load(host, state).then(function () { throw err; });
397
+ }));
348
398
  } else if (t.hasAttribute('data-genesis-alive')) {
349
399
  act(send('POST', '/founding/alive', {}).then(function () { host.innerHTML = aliveHtml(); }));
350
400
  } else if (t.hasAttribute('data-genesis-enter')) {
@@ -0,0 +1,61 @@
1
+ {
2
+ "_": "Several genesis stages sitting in the Board Room at once, with the vote-on-all panel (task 1005199). Same shell as index-genesis.states.json; /founding/genesis answers founding-genesis-votes.json, built from the real server shaper.",
3
+ "page": "/builders/",
4
+ "_storage": "The hall asks a first-visit question (the session mode picker, craft-mode.js) over every page; seeding \"already asked\" renders the page a returning builder sees (task 1005080).",
5
+ "storage": {
6
+ "session": {
7
+ "hall-craft-asked": "1"
8
+ }
9
+ },
10
+ "surface": "hall-ui",
11
+ "stub": {
12
+ "prefix": "/builders/",
13
+ "fixtures": {
14
+ "me": "me-founding",
15
+ "founding-genesis": "founding-genesis-votes"
16
+ }
17
+ },
18
+ "modeQuery": false,
19
+ "ignoreRequests": [
20
+ "/api/(gds|bongos)/me$",
21
+ "/api/(gds|bongos)/me/",
22
+ "/api/(gds|bongos)/tasks",
23
+ "/api/(gds|bongos)/claims",
24
+ "/api/(gds|bongos)/goals",
25
+ "/api/(gds|bongos)/inbox",
26
+ "/api/(gds|bongos)/live",
27
+ "/api/(gds|bongos)/analytics/",
28
+ "/api/(gds|bongos)/achievements",
29
+ "/api/(gds|bongos)/versions",
30
+ "/api/(gds|bongos)/leaderboard",
31
+ "/api/(gds|bongos)/removal-proposals",
32
+ "/api/(gds|bongos)/blockers"
33
+ ],
34
+ "_ignored": "removal-proposals and blockers are fixtures the kit stub does not carry; index.states.json's home render reports the same two 404s.",
35
+ "ignoreConsole": [
36
+ "/api/(gds|bongos)/",
37
+ "\\b401\\b",
38
+ "\\b404\\b",
39
+ "Unauthorized",
40
+ "Not Found",
41
+ "load failed"
42
+ ],
43
+ "states": {
44
+ "votes": {
45
+ "auth": true,
46
+ "actions": [
47
+ [
48
+ "wait",
49
+ 800
50
+ ]
51
+ ],
52
+ "expect": {
53
+ "visible": [
54
+ "#genesis-home",
55
+ "#founding-band",
56
+ "#genesis-votes-h"
57
+ ]
58
+ }
59
+ }
60
+ }
61
+ }
@@ -11,9 +11,10 @@
11
11
  // the character's colour; task 1005218), your character's name and your credits
12
12
  // (builders.total_credits — credits only, never a payout; the spec's "does not exist").
13
13
  // Centre: your character's main action, the one accent button, with its key. Right: the
14
- // quiet key hints, no boxes: your other actions, "Actions" (key A), which opens the
15
- // sheet (your character's actions, then the keys of the place you are in, then Options
16
- // on Escape), and Options itself.
14
+ // quiet key hints, no boxes: your other actions and "Actions" (key A), which opens the
15
+ // sheet (your character's actions, then the keys of the place you are in, then Options on
16
+ // a key of its own; Escape closes it). Options is only in the sheet (task 1005248).
17
+ // - every key comes from resolveKeys: one owner per key, never two (task 1005248).
17
18
  //
18
19
  // The rules it keeps (ADR 0399):
19
20
  // - one overlay, #visor, with pointer-events: none; only its controls take a pointer, so a
@@ -59,6 +60,11 @@
59
60
  ],
60
61
  };
61
62
  var SHEET_KEY = 'a';
63
+ var CLOSE_KEY = 'esc'; // shown once: the sheet's Close (Escape)
64
+ var OPTIONS = { id: 'options', label: 'Options', key: 'o', href: '/settings' };
65
+ // Keys a scene binds on its own page (the sky's L 0 / ?, the gallery's F, the palette's K).
66
+ // A moved key never lands on one of them; a scene that declares one keeps it.
67
+ var AVOID = ['l', '0', '/', '?', 'f', 'k', '+', '-', '='];
62
68
  var NAMES = { artisan: 'Artisan', artist: 'Artist', patron: 'Patron' };
63
69
 
64
70
  function esc(v) { return window.OTB && window.OTB.escapeHtml ? window.OTB.escapeHtml(v) : String(v == null ? '' : v).replace(/[^\w .,:'-]/g, ''); }
@@ -151,6 +157,42 @@
151
157
  render();
152
158
  }
153
159
 
160
+ // ---------- the keys: one resolver, so no key ever means two things (task 1005248) ----------
161
+ // Every key the visor shows or binds comes from here, and here hands each key out once.
162
+ // A key in ACTIONS or a scene's registration is a RECOMMENDATION: when it is already taken
163
+ // the action moves to the next free letter of its own name, then of the alphabet, and with
164
+ // none free it shows and binds no key at all. Never two owners. In order of who keeps a key:
165
+ // 1. the visor's own: A (Actions) and Escape (Close, while the sheet is open);
166
+ // 2. the keys a scene binds itself (an action without `bind`, and the `keys` lines): the
167
+ // visor cannot move a key it does not bind, so a second claim on one shows no key;
168
+ // 3. the character's actions, in order; 4. the scene's `bind` actions; 5. Options.
169
+ function norm(k) { return k ? String(k).toLowerCase() : ''; }
170
+ function resolveKeys(id) {
171
+ var taken = {};
172
+ function claim(k, who) { if (!k || taken[k]) return ''; taken[k] = who; return k; }
173
+ function free(pref, label, who) {
174
+ var got = claim(norm(pref), who);
175
+ if (got || !pref) return got;
176
+ var pool = String(label || '').toLowerCase().replace(/[^a-z]/g, '') + 'abcdefghijklmnopqrstuvwxyz';
177
+ for (var i = 0; i < pool.length; i++) if (AVOID.indexOf(pool[i]) < 0 && claim(pool[i], who)) return pool[i];
178
+ return '';
179
+ }
180
+ claim(SHEET_KEY, 'sheet');
181
+ claim(CLOSE_KEY, 'close');
182
+ var sa = scene ? scene.actions : [], sk = scene ? scene.keys : [];
183
+ var place = sa.map(function () { return ''; });
184
+ sa.forEach(function (a, i) { if (!a.bind) place[i] = claim(norm(a.key), 'place:' + i); });
185
+ var lines = sk.map(function (k, i) { return claim(norm(k.key), 'line:' + i); });
186
+ var acts = actionsFor(id).map(function (a) {
187
+ var c = {}; for (var p in a) c[p] = a[p];
188
+ c.key = free(a.key, a.label, a.id);
189
+ return c;
190
+ });
191
+ sa.forEach(function (a, i) { if (a.bind) place[i] = free(a.key, a.label, 'place:' + i); });
192
+ var opts = { id: OPTIONS.id, label: OPTIONS.label, href: OPTIONS.href, key: free(OPTIONS.key, OPTIONS.label, OPTIONS.id) };
193
+ return { actions: acts, place: place, lines: lines, options: opts, taken: taken };
194
+ }
195
+
154
196
  // ---------- what the scene has picked (task 1005243) ----------
155
197
  // A scene that picks something (Home's tower) hands the visor a small card about it:
156
198
  // a title, one status, a few lines, and at most one action. The action becomes the
@@ -227,7 +269,7 @@
227
269
  svgOpen('visor__notch', '0 0 12 9') + twice('<path class="visor__line" d="M0 0l6 9l6 -9"/>') + '</svg>' +
228
270
  svgOpen('visor__sight', '0 0 24 24') + twice('<circle class="visor__line" cx="12" cy="12" r="11"/>') + '<circle class="visor__dot" cx="12" cy="12" r="2"/></svg>';
229
271
  }
230
- function keyHtml(k) { k = String(k); return '<kbd class="visor__key">' + esc(k.length === 1 ? k.toUpperCase() : k) + '</kbd>'; }
272
+ function keyHtml(k) { k = String(k || ''); if (!k) return ''; if (k === CLOSE_KEY) k = 'Esc'; return '<kbd class="visor__key">' + esc(k.length === 1 ? k.toUpperCase() : k) + '</kbd>'; }
231
273
  // The count and its unit are two spans, so the number can be white and the word ice; the
232
274
  // text between them is still creditsText's, word for word.
233
275
  function creditsHtml(n) {
@@ -240,14 +282,14 @@
240
282
  return '<a class="visor__hint" data-visor-act="' + esc(a.id) + '" ' + actionAttrs(a) + '>' + keyHtml(a.key) + '<span>' + esc(a.label) + '</span></a>';
241
283
  }
242
284
  // The scene's lead action, as the first quiet hint (task 1005209).
243
- function placeHintHtml(a, i) {
244
- return '<button class="visor__hint visor__hint--place" type="button" data-visor-scene="' + i + '">' + (a.key ? keyHtml(a.key) : '') + '<span>' + esc(a.label) + '</span></button>';
285
+ function placeHintHtml(a, i, key) {
286
+ return '<button class="visor__hint visor__hint--place" type="button" data-visor-scene="' + i + '">' + keyHtml(key) + '<span>' + esc(a.label) + '</span></button>';
245
287
  }
246
288
  function actionAttrs(a) {
247
289
  return a.href ? 'href="' + esc(hallHref(a.href)) + '"' : 'href="#" role="button"';
248
290
  }
249
291
  function glassHtml(id) {
250
- var acts = actionsFor(id), main = acts[0], C = craft();
292
+ var keys = resolveKeys(id), acts = keys.actions, main = acts[0], C = craft();
251
293
  // Your picture on the glass is you wearing the visor (task 1005218); an older craft-mode
252
294
  // without that picture still hands over the whole figure.
253
295
  var sprite = C && typeof C.portraitSrc === 'function' ? C.portraitSrc(id) : (C && typeof C.spriteSrc === 'function' ? C.spriteSrc(id) : '');
@@ -263,10 +305,9 @@
263
305
  '<span class="visor__credits" data-visor-credits>' + creditsHtml(n) + '</span></span>' +
264
306
  '</div>' +
265
307
  mainHtml(main) +
266
- '<div class="visor__hints">' + scene.actions.map(function (a, i) { return a.lead ? placeHintHtml(a, i) : ''; }).join('') + acts.slice(1).map(hintHtml).join('') +
308
+ '<div class="visor__hints">' + scene.actions.map(function (a, i) { return a.lead ? placeHintHtml(a, i, keys.place[i]) : ''; }).join('') + acts.slice(1).map(hintHtml).join('') +
267
309
  '<button class="visor__hint visor__hint--sheet" type="button" data-visor-sheet aria-expanded="false" aria-controls="visor-sheet">' +
268
310
  '<span class="visor__dots" aria-hidden="true"></span>' + keyHtml(SHEET_KEY) + '<span>Actions</span></button>' +
269
- '<a class="visor__hint visor__hint--options" href="' + esc(hallHref('/settings')) + '">' + keyHtml('Esc') + '<span>Options</span></a>' +
270
311
  '</div>' +
271
312
  '</div>';
272
313
  }
@@ -280,20 +321,22 @@
280
321
  '<p class="visor__progress-words"><span class="sr-only">' + esc(said) + '</span>' +
281
322
  '<span aria-hidden="true">' + esc(progress.id) + ' · ' + progress.pct + '%</span></p></div>';
282
323
  }
324
+ // Options lives only in here (the owner, 2026-11-12), on a key of its own: Escape is Close.
283
325
  function sheetHtml(id) {
284
- var rows = actionsFor(id).map(function (a) {
326
+ var keys = resolveKeys(id);
327
+ var rows = keys.actions.map(function (a) {
285
328
  return '<li><a class="visor__row" data-visor-act="' + esc(a.id) + '" ' + actionAttrs(a) + '><span>' + esc(a.label) + '</span>' + keyHtml(a.key) + '</a></li>';
286
329
  }).join('');
287
330
  var place = scene.actions.map(function (a, i) {
288
- return '<li><button class="visor__row" type="button" data-visor-scene="' + i + '"><span>' + esc(a.label) + '</span>' + (a.key ? keyHtml(a.key) : '') + '</button></li>';
289
- }).concat(scene.keys.map(function (k) {
290
- return '<li><span class="visor__row visor__row--hint"><span>' + esc(k.label) + '</span>' + keyHtml(k.key) + '</span></li>';
331
+ return '<li><button class="visor__row" type="button" data-visor-scene="' + i + '"><span>' + esc(a.label) + '</span>' + keyHtml(keys.place[i]) + '</button></li>';
332
+ }).concat(scene.keys.map(function (k, i) {
333
+ return '<li><span class="visor__row visor__row--hint"><span>' + esc(k.label) + '</span>' + keyHtml(keys.lines[i]) + '</span></li>';
291
334
  })).join('');
292
335
  return '<div class="visor__sheet-head"><h2 class="visor__sheet-title" id="visor-sheet-title">Actions</h2>' +
293
- '<button class="visor__close" type="button" data-visor-close aria-label="Close actions">' + keyHtml('Esc') + '</button></div>' +
336
+ '<button class="visor__close" type="button" data-visor-close aria-label="Close actions">' + keyHtml(CLOSE_KEY) + '</button></div>' +
294
337
  '<p class="visor__cap">' + esc(NAMES[id]) + '</p><ul class="visor__rows">' + rows + '</ul>' +
295
338
  (place ? '<p class="visor__cap">This place</p><ul class="visor__rows">' + place + '</ul>' : '') +
296
- '<ul class="visor__rows"><li><a class="visor__row" href="' + esc(hallHref('/settings')) + '"><span>Options</span>' + keyHtml('Esc') + '</a></li></ul>';
339
+ '<ul class="visor__rows"><li><a class="visor__row" data-visor-act="options" href="' + esc(hallHref(keys.options.href)) + '"><span>' + esc(keys.options.label) + '</span>' + keyHtml(keys.options.key) + '</a></li></ul>';
297
340
  }
298
341
  function shown() { return !!(root && !root.hidden); }
299
342
  function render() {
@@ -375,10 +418,12 @@
375
418
  if (C && typeof C.openTool === 'function') C.openTool(a.tool);
376
419
  return;
377
420
  }
421
+ // Options opens the game's options window over the place when it can (shell-game.js)
422
+ if (a.id === OPTIONS.id && window.OTBOptionsOverlay && typeof window.OTBOptionsOverlay.open === 'function') { window.OTBOptionsOverlay.open(); return; }
378
423
  if (a.href) window.location.assign(hallHref(a.href));
379
424
  }
380
425
  function find(id) {
381
- var acts = actionsFor(character());
426
+ var acts = resolveKeys(character()).actions;
382
427
  for (var i = 0; i < acts.length; i++) if (acts[i].id === id) return acts[i];
383
428
  return null;
384
429
  }
@@ -415,11 +460,12 @@
415
460
  if (!e || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || typeof e.key !== 'string' || e.key.length !== 1) return null;
416
461
  var k = e.key.toLowerCase();
417
462
  if (k === SHEET_KEY) return 'sheet';
418
- var acts = actionsFor(id);
419
- for (var i = 0; i < acts.length; i++) if (acts[i].key === k) return acts[i];
420
- // then the keys the visor binds for the place (task 1005209); the character's win a clash
463
+ // the resolver gave each key one owner, so the first match is the only one
464
+ var keys = resolveKeys(id);
465
+ for (var i = 0; i < keys.actions.length; i++) if (keys.actions[i].key === k) return keys.actions[i];
421
466
  var own = scene ? scene.actions : [];
422
- for (var j = 0; j < own.length; j++) if (own[j].bind && own[j].key === k) return own[j];
467
+ for (var j = 0; j < own.length; j++) if (own[j].bind && keys.place[j] === k) return own[j];
468
+ if (keys.options.key === k) return keys.options;
423
469
  return null;
424
470
  }
425
471
  // Bubble, on window: a scene or pop-up that used the key has called preventDefault by now.
@@ -460,6 +506,7 @@
460
506
  ACTIONS: ACTIONS,
461
507
  actionsFor: actionsFor,
462
508
  keyFor: keyFor,
509
+ resolveKeys: resolveKeys,
463
510
  creditsText: creditsText,
464
511
  character: character,
465
512
  pickVersion: pickVersion,
@@ -6,6 +6,13 @@ Covers: `visor.js` + `visor.css`, loaded on Home, `/city`, `/goal-map` and `/thi
6
6
 
7
7
  The visor is the control and the scene is the view: every main call to action in game mode is on the glass (the accent button, the key hints, the Actions sheet), and a scene carries none of its own. A scene that has something to do registers it with `OTBVisor.scene({ actions, keys })`. ADR 0403 §3 lists the scenes that do not keep it yet; the Governor City keeps it since task 1005209 the sky since task 1005210, and Home's gallery since task 1005211 (below).
8
8
 
9
+ ## One owner per key (task 1005248, owner 2026-11-12)
10
+
11
+ - **The bug.** The Artist saw Esc twice: "Options" on the glass, then in the Actions sheet both Close and the Options row said Esc.
12
+ - **The rule.** A key in `ACTIONS` or a scene's registration is a recommendation. `resolveKeys(id)` in `visor.js` hands every key out once, and the glass, the sheet and `keyFor` all read it; nothing else names a key. Who keeps a key, in order: the visor's own (A Actions, Escape Close); the keys a scene binds itself (an action without `bind`, the `keys` lines), because the visor cannot move a key it does not bind, so a second claim on one shows no key; the character's actions; the scene's `bind` actions; Options. A later claimant moves to the next free letter of its own name, then of the alphabet, never onto a key a scene binds on its page (`AVOID`: L 0 / ? F K + - =); with none free it has no key.
13
+ - **Options is only in the sheet** (the owner likes it there), on O, or its next free letter (the Patron's O is the Board room, so P). Pressing it opens the options window over the place, like a click. Escape on the glass is the sheet's Close only. The shell's "last Escape opens the options menu" (`shell-game.js`, ADR 0399 §2.4) is not a visor key and is unchanged.
14
+ - **Tests:** `tests/hall_visor.mjs` proves no shared key for every character over the sky, the city and Home, and over a hostile scene that claims A, Escape, a character key and one key twice.
15
+
9
16
  ## A place's own buttons on the glass (task 1005209, the Governor City first)
10
17
 
11
18
  - **The scene contract grew two flags.** A scene action's `key` is still a key the scene binds itself, shown beside it in the sheet. With `bind: true` the visor binds that key instead, after the character's keys (a clash goes to the character), for a scene button that had no key of its own. With `lead: true` the action is also the first quiet key hint on the glass (`.visor__hint--place`). Every scene action is still a row under "This place" in the sheet.
@@ -37,7 +37,10 @@ const WAKES = Object.freeze({
37
37
  // task 1005051: Roadmap folded into Goals, so scope wakes Goals and its map.
38
38
  scope: Object.freeze(['goals', 'goal-map']),
39
39
  strategize: Object.freeze(['work']),
40
- charter: Object.freeze(['board-room']),
40
+ // task 1005199: charter woke the Board Room, but scope and strategize are already
41
+ // closed by a vote there, so the room read as locked while the founder had a ballot
42
+ // in it. The Board Room is awake for the whole of genesis; charter wakes no room.
43
+ charter: Object.freeze([]),
41
44
  // task 1005046: legalize woke Economy and legislation woke Ranks. Both pages
42
45
  // are Primer chapters now (one reading room), and the Primer never sleeps —
43
46
  // it is the newcomer's first read — so these two stages wake no sidebar room.
@@ -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({