@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.
- package/.bongos-core.json +37 -27
- package/docs/copy-inventory.md +18 -16
- package/docs/copy-registry.json +46 -27
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +5 -3
- package/docs/page-readings.json +7 -7
- package/modules/hall-ui/public/genesis-home.js +54 -4
- package/modules/hall-ui/public/index-genesis-votes.states.json +61 -0
- package/modules/hall-ui/public/visor.js +67 -20
- package/modules/hall-ui/records/visor.md +7 -0
- package/modules/onboarding/founding-mode.js +4 -1
- package/modules/onboarding/genesis-home.js +57 -2
- package/modules/onboarding/routes/onboarding.js +2 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-votes.json +193 -0
- package/modules/ui-design/kit/fixtures/me-founding-demo.json +0 -1
- package/modules/ui-design/kit/fixtures/me-founding.json +0 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/founding_mode.mjs +13 -3
- package/tests/genesis_home.mjs +92 -4
- package/tests/hall_visor.mjs +63 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
15
|
-
// sheet (your character's actions, then the keys of the place you are in, then Options
|
|
16
|
-
//
|
|
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 + '">' +
|
|
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
|
|
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
|
|
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>' +
|
|
289
|
-
}).concat(scene.keys.map(function (k) {
|
|
290
|
-
return '<li><span class="visor__row visor__row--hint"><span>' + esc(k.label) + '</span>' + keyHtml(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
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({
|