@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.
@@ -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.1",
3
+ "version": "1.25.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.1",
9
+ "version": "1.25.3",
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.1",
3
+ "version": "1.25.3",
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",
@@ -11700,5 +11700,17 @@
11700
11700
  "id": "1005040",
11701
11701
  "text": "Added the Stripe Connect test-mode payment adapter (economy.processor port). Every payout, cash-out, balance and ledger-check call now has a processor to talk to once a test key is set; nothing is live."
11702
11702
  }
11703
+ ],
11704
+ "1.25.2": [
11705
+ {
11706
+ "id": "1005248",
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
+ }
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
+ }
11703
11715
  ]
11704
11716
  }
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.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.3'; // 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');
@@ -77,6 +77,64 @@ test("a place's own key: the visor binds it only when the scene asks, and the ch
77
77
  assert.equal(V.keyFor('artisan', ev('c')).id, 'claim', "the character's key wins a clash");
78
78
  });
79
79
 
80
+ // task 1005248 (owner, 2026-11-12): a key the visor shows is a recommendation, and no two
81
+ // things on the glass or in the sheet ever share one. Every key comes from resolveKeys.
82
+ function shownKeys(r) {
83
+ const out = [['a', 'Actions'], ['esc', 'Close']];
84
+ r.actions.forEach((a) => out.push([a.key, a.id]));
85
+ r.place.forEach((k, i) => out.push([k, 'place ' + i]));
86
+ r.lines.forEach((k, i) => out.push([k, 'line ' + i]));
87
+ out.push([r.options.key, 'options']);
88
+ return out.filter(([k]) => k);
89
+ }
90
+ function assertNoClash(V, who) {
91
+ for (const c of ['artisan', 'artist', 'patron']) {
92
+ const keys = shownKeys(V.resolveKeys(c)).map(([k]) => k);
93
+ assert.equal(new Set(keys).size, keys.length, `${who} / ${c}: one owner per key (${keys.join(' ')})`);
94
+ }
95
+ }
96
+ const SKY = { name: 'sky', place: '', actions: [{ label: 'Write a thought', key: '/', lead: true, run() {} }, { label: 'Jump to a goal or a thought', key: 'j', bind: true, run() {} }, { label: 'List', key: 'L', run() {} }, { label: 'Tour', key: '?', run() {} }, { label: 'Fit', key: '0', run() {} }, { label: 'Clock · off', key: '', run() {} }], keys: [{ label: 'Look around', key: 'drag' }, { label: 'Zoom', key: '+ −' }] };
97
+ const CITY = { name: 'city', place: 'Governor city', actions: [{ label: 'decide 37 in a row', key: 'd', bind: true, lead: true, run() {} }, { label: 'Archived: Street', run() {} }], keys: [{ label: 'On a card: answer', key: '1–9' }, { label: 'On a card: next', key: '← →' }] };
98
+
99
+ test('no key ever means two things: every character over every real scene, and Options never on Escape', () => {
100
+ const V = loadPure();
101
+ assertNoClash(V, 'no scene');
102
+ for (const sc of [SKY, CITY, { name: 'home', place: 'Home' }]) { V.scene(sc); assertNoClash(V, sc.name); }
103
+ for (const c of ['artisan', 'artist', 'patron']) {
104
+ const r = V.resolveKeys(c);
105
+ assert.notEqual(r.options.key, 'esc', `${c}: Options is not on Escape; Escape is Close`);
106
+ assert.ok(r.options.key, `${c}: Options has a key of its own`);
107
+ }
108
+ assert.equal(V.resolveKeys('artist').options.key, 'o', 'O is Options where nothing else holds it');
109
+ assert.equal(V.resolveKeys('patron').options.key, 'p', "the Patron's O is the Board room, so Options moves to its next letter");
110
+ });
111
+
112
+ test('a clash moves the later claimant to a free letter of its own name, and its key follows', () => {
113
+ const V = loadPure();
114
+ const ev = (key) => ({ key, defaultPrevented: false, ctrlKey: false, metaKey: false, altKey: false });
115
+ const run = () => {};
116
+ // the worst a scene can register: its own keys on A, Escape, a character key, and twice on one key
117
+ V.scene({ name: 'x', place: 'X', actions: [
118
+ { label: 'Grab', key: 'a', run }, { label: 'Exit', key: 'Esc', run }, { label: 'Go', key: 'g', run }, { label: 'Again', key: 'g', run },
119
+ { label: 'Claim here', key: 'c', bind: true, run }, { label: 'Thing', key: 't', bind: true, run }, { label: 'Open', key: 'o', bind: true, run },
120
+ ], keys: [{ label: 'Same as Go', key: 'G' }] });
121
+ assertNoClash(V, 'hostile scene');
122
+ const r = V.resolveKeys('artisan');
123
+ const plain = (x) => JSON.parse(JSON.stringify(x));
124
+ assert.deepEqual(plain(r.place.slice(0, 4)), ['', '', 'g', ''], "A and Escape are the visor's; the scene keeps G once, and a second claim shows no key");
125
+ assert.equal(r.lines[0], '', 'a key line on a taken key shows no key');
126
+ assert.equal(r.actions.find((a) => a.id === 'sky').key, 'o', 'Goal sky lost G to the scene, so it moves to the next letter of its name, O');
127
+ assert.equal(V.keyFor('artisan', ev('o')).id, 'sky', 'and O runs it');
128
+ assert.equal(V.keyFor('artisan', ev('g')), null, "G is the scene's own now, so the visor does not take it");
129
+ assert.ok(!['c', 'l', 'a'].includes(r.place[4]), 'Claim here: C is taken, L and A are never handed out');
130
+ assert.equal(V.keyFor('artisan', ev(r.place[4])).label, 'Claim here');
131
+ assert.equal(V.keyFor('artisan', ev('c')).id, 'claim', "the character's key still wins");
132
+ assert.ok(r.place[6] && r.place[6] !== 'o', "Open asked for O after Goal sky took it, so it moves");
133
+ assert.equal(V.keyFor('artisan', ev(r.place[6])).label, 'Open');
134
+ assert.equal(V.keyFor('artisan', ev(r.options.key)).id, 'options', 'and Options takes the next free letter');
135
+ assert.equal(V.keyFor('artisan', ev('Escape')), null, 'the ambient glass never takes Escape');
136
+ });
137
+
80
138
  // task 1005203: the four segments are the version the project is building, in quarters.
81
139
  test('progress: the starred version while open, else the one building, else none', () => {
82
140
  const V = loadPure();
@@ -238,7 +296,8 @@ test('in game mode the glass shows the place, the main action, the key hints, th
238
296
  assert.equal(g.credits, '56,021 credits');
239
297
  assert.deepEqual([g.num, g.unit], ['56,021', 'credits'], 'the count and its word are two spans of the same text');
240
298
  // task 1005195: every other control is a quiet key hint. Actions kept its key and its sheet; nothing was dropped.
241
- assert.deepEqual(g.hints, ['TYour thinking', 'AActions', 'EscOptions']);
299
+ // task 1005248: Options is only in the sheet, so Escape is on the glass nowhere.
300
+ assert.deepEqual(g.hints, ['TYour thinking', 'AActions']);
242
301
  assert.equal(g.arcs, 2, 'two arcs frame the glass');
243
302
  assert.equal(g.sprite, '/builders/characters/artisan-visor.png', 'your picture on the glass is you wearing the visor (task 1005218)');
244
303
  assert.deepEqual(errors, []);
@@ -295,7 +354,7 @@ test('a lead scene action is the first key hint, and its click and its key run i
295
354
  const { ctx, page, errors } = await open();
296
355
  await page.evaluate(() => OTBVisor.scene({ name: 'test', place: 'Test city', actions: [{ label: 'Decide 2 in a row', key: 'd', bind: true, lead: true, run() { window.decided = (window.decided || 0) + 1; } }] }));
297
356
  const hints = await page.$$eval('.visor__hints .visor__hint', (h) => h.map((x) => x.textContent));
298
- assert.deepEqual(hints, ['DDecide 2 in a row', 'TYour thinking', 'AActions', 'EscOptions']);
357
+ assert.deepEqual(hints, ['DDecide 2 in a row', 'TYour thinking', 'AActions']);
299
358
  await page.click('.visor__hint--place');
300
359
  await page.keyboard.press('d');
301
360
  assert.equal(await page.evaluate(() => window.decided), 2, 'the hint and D both press it');
@@ -311,7 +370,8 @@ test('the sheet lists the character\'s actions, then this place, and runs a scen
311
370
  await page.click('[data-visor-sheet]');
312
371
  const rows = await page.$$eval('.visor__sheet .visor__row', (r) => r.map((x) => [...x.children].map((c) => c.textContent).join(' ')));
313
372
  // task 1005200: Claim a task and Blockers open classic pages, so they are off the glass.
314
- assert.deepEqual(rows, ['Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Options Esc']);
373
+ assert.deepEqual(rows, ['Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Options O']);
374
+ assert.equal(await page.$eval('.visor__sheet [data-visor-close]', (b) => b.textContent), 'Esc', 'Escape is the Close of the sheet, and only that');
315
375
  await page.click('[data-visor-scene="0"]');
316
376
  assert.deepEqual(await page.evaluate(() => ({ centred: window.centred, open: OTBVisor.isOpen() })), { centred: true, open: false });
317
377
  await ctx.close();