@bongos/core 1.24.72 → 1.24.74

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 (76) hide show
  1. package/.bongos-core.json +107 -77
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +36 -2
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +5 -5
  10. package/docs/copy-registry.json +5 -5
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +33 -0
  13. package/docs/page-readings.json +33 -33
  14. package/modules/government/board.js +19 -7
  15. package/modules/government/config.js +21 -9
  16. package/modules/government/constitution.js +224 -0
  17. package/modules/government/db.js +28 -0
  18. package/modules/government/migrations/government_037_constitution.sql +33 -0
  19. package/modules/government/module.json +2 -1
  20. package/modules/government/pollers/constitution-restore.js +45 -0
  21. package/modules/government/routes/government.js +7 -7
  22. package/modules/hall-ui/public/ai-studio.html +2 -1
  23. package/modules/hall-ui/public/atlas.html +2 -1
  24. package/modules/hall-ui/public/blockers.html +2 -1
  25. package/modules/hall-ui/public/board-room.html +2 -1
  26. package/modules/hall-ui/public/board-room.js +7 -3
  27. package/modules/hall-ui/public/city.html +2 -1
  28. package/modules/hall-ui/public/collab.html +2 -1
  29. package/modules/hall-ui/public/commands.html +2 -1
  30. package/modules/hall-ui/public/copy-desk.html +2 -1
  31. package/modules/hall-ui/public/deploy.html +2 -1
  32. package/modules/hall-ui/public/goal-map.html +2 -1
  33. package/modules/hall-ui/public/goals.html +2 -1
  34. package/modules/hall-ui/public/government.html +2 -1
  35. package/modules/hall-ui/public/idea.html +2 -1
  36. package/modules/hall-ui/public/ideas.html +2 -1
  37. package/modules/hall-ui/public/index.html +2 -1
  38. package/modules/hall-ui/public/market-watch-item.html +2 -1
  39. package/modules/hall-ui/public/market-watch-rules.html +2 -1
  40. package/modules/hall-ui/public/market-watch.html +2 -1
  41. package/modules/hall-ui/public/modules.html +2 -1
  42. package/modules/hall-ui/public/money.html +2 -1
  43. package/modules/hall-ui/public/my-agents.html +2 -1
  44. package/modules/hall-ui/public/primer.html +2 -1
  45. package/modules/hall-ui/public/profile.html +2 -1
  46. package/modules/hall-ui/public/project-settings.html +2 -1
  47. package/modules/hall-ui/public/roster.html +2 -1
  48. package/modules/hall-ui/public/settings.html +2 -1
  49. package/modules/hall-ui/public/shell-game.js +50 -0
  50. package/modules/hall-ui/public/shell.js +11 -11
  51. package/modules/hall-ui/public/street-card.js +2 -0
  52. package/modules/hall-ui/public/street.html +2 -1
  53. package/modules/hall-ui/public/studio.html +2 -1
  54. package/modules/hall-ui/public/task.html +2 -1
  55. package/modules/hall-ui/public/thinking.html +2 -1
  56. package/modules/hall-ui/public/tweak-editor.html +2 -1
  57. package/modules/hall-ui/public/watch.html +2 -1
  58. package/modules/hall-ui/public/work.html +2 -1
  59. package/modules/hall-ui/records/game-mode.md +20 -0
  60. package/package-lock.json +2 -2
  61. package/package.json +1 -1
  62. package/release-notes.json +12 -0
  63. package/scripts/bongos/government-walk.js +7 -0
  64. package/src/bongos/route-rank-check.js +4 -0
  65. package/src/module-api.js +1 -1
  66. package/tests/genesis_government.mjs +11 -0
  67. package/tests/goal_map_page.mjs +2 -2
  68. package/tests/government_board_amendment.mjs +10 -0
  69. package/tests/government_charter_forms.mjs +10 -0
  70. package/tests/government_constitution_durable.mjs +304 -0
  71. package/tests/government_seed.mjs +1 -0
  72. package/tests/hall_city.mjs +1 -1
  73. package/tests/hall_fullscreen.mjs +101 -0
  74. package/tests/hall_my_agents.mjs +1 -1
  75. package/tests/hall_street.mjs +1 -1
  76. package/tests/nav_permission_atoms.mjs +1 -1
@@ -0,0 +1,304 @@
1
+ // tests/government_constitution_durable.mjs — a ratified constitution survives a
2
+ // restart (task 1005140).
3
+ //
4
+ // Found live on toast, 2026-11-07: the board ratified a Timocratic Republic, the
5
+ // amendment was written to <instance root>/config/government.json, and the next
6
+ // container start (deploy, restart, or an idle wake on a free plan) erased the
7
+ // file. The project went back to the day-one monarchy without an error.
8
+ //
9
+ // DB-free: the constitution row lives in an in-memory stand-in for db.js's two
10
+ // functions; the instance pack is a REAL temp file, and "a restart" is deleting
11
+ // it. The invariants:
12
+ //
13
+ // 1. A restart does not lose a ratified constitution — the row restores it.
14
+ // 2. A file with no row is ADOPTED into the row (nobody loses one on upgrade).
15
+ // 3. The row wins over a stale file.
16
+ // 4. The row is written BEFORE the file, and a write that did not take is
17
+ // REPORTED at the close (constitution_not_applied), never absorbed.
18
+ // 5. The operator's re-apply only ever re-applies a PASSED constitutional
19
+ // amendment — it cannot invent a block.
20
+ // 6. The restore runs at boot, immediately.
21
+ import assert from 'node:assert/strict';
22
+ import { test } from 'node:test';
23
+ import { createRequire } from 'node:module';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const GOV = path.join(ROOT, 'modules', 'government');
32
+ const db = require(path.join(GOV, 'db.js'));
33
+ const config = require(path.join(GOV, 'config.js'));
34
+ const constitution = require(path.join(GOV, 'constitution.js'));
35
+ const board = require(path.join(GOV, 'board.js'));
36
+
37
+ const MONARCHY = Object.freeze({ membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true });
38
+ const TIMOCRACY = Object.freeze({ membership: 'rank:metic+', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: true });
39
+ const quiet = { log: () => {}, error: () => {} };
40
+
41
+ let seq = 0;
42
+ const tmpPack = () => path.join(os.tmpdir(), `gov-durable-${process.pid}-${Date.now()}-${seq++}`, 'config', 'government.json');
43
+
44
+ // One harness: an in-memory row, and the config's instance path redirected to
45
+ // a temp file for both the write and the read — what a real process does with
46
+ // its own INSTANCE_PATH.
47
+ async function withInstance(fn, { row = null, saveFails = false, getFails = null, history } = {}) {
48
+ const pack = tmpPack();
49
+ const store = { row, saves: [] };
50
+ const orig = {
51
+ get: db.getConstitutionRow, save: db.saveConstitutionRow,
52
+ apply: config.applyBoardAmendment, load: config.loadGovernmentConfig,
53
+ list: db.listAmendmentHistory,
54
+ };
55
+ if (history !== undefined) db.listAmendmentHistory = async () => history;
56
+ db.getConstitutionRow = async () => { if (getFails) throw getFails; return store.row; };
57
+ db.saveConstitutionRow = async (args) => {
58
+ if (saveFails) throw new Error('connection refused');
59
+ store.saves.push(args);
60
+ store.row = { board: args.board, source: args.source, amendment_item_id: args.itemId };
61
+ return store.row;
62
+ };
63
+ config.applyBoardAmendment = (proposed, opts = {}) => orig.apply(proposed, { instancePath: opts.instancePath || pack });
64
+ config.loadGovernmentConfig = (opts = {}) => orig.load({ ...opts, instancePath: opts.instancePath || pack });
65
+ try { return await fn({ pack, store, live: () => orig.load({ instancePath: pack }).board }); }
66
+ finally {
67
+ db.getConstitutionRow = orig.get; db.saveConstitutionRow = orig.save; db.listAmendmentHistory = orig.list;
68
+ config.applyBoardAmendment = orig.apply; config.loadGovernmentConfig = orig.load;
69
+ fs.rmSync(path.dirname(path.dirname(pack)), { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ const restart = (pack) => fs.rmSync(pack, { force: true });
74
+
75
+ // ── 1. the bug itself ────────────────────────────────────────────────────────
76
+
77
+ test('a ratified constitution survives a container restart (the toast bug)', async () => {
78
+ await withInstance(async ({ pack, store, live }) => {
79
+ const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
80
+ assert.equal(out.failure, null, `the ratified amendment took: ${JSON.stringify(out.failure)}`);
81
+ assert.deepEqual({ ...live() }, TIMOCRACY, 'in force right after the close');
82
+ assert.deepEqual(store.row.board, TIMOCRACY, 'the durable row holds it');
83
+ assert.equal(store.row.source, 'amendment');
84
+
85
+ restart(pack); // the container's filesystem is wiped
86
+ assert.deepEqual({ ...live() }, MONARCHY, 'fixture: with the file gone the process alone falls back to monarchy');
87
+
88
+ const r = await constitution.reconcile({ instancePath: pack, ...quiet });
89
+ assert.equal(r.action, 'restored');
90
+ assert.deepEqual({ ...live() }, TIMOCRACY, 'after the boot restore the board\'s decision is in force again');
91
+ });
92
+ });
93
+
94
+ // ── 2 + 3. adopt on first read; the row wins ─────────────────────────────────
95
+
96
+ test('a file with no row is adopted into the database and the log says so', async () => {
97
+ await withInstance(async ({ pack, store }) => {
98
+ fs.mkdirSync(path.dirname(pack), { recursive: true });
99
+ fs.writeFileSync(pack, JSON.stringify({ ranks: { metic: { label: 'Trusted' } }, board: TIMOCRACY }));
100
+ const lines = [];
101
+ const r = await constitution.reconcile({ instancePath: pack, log: (m) => lines.push(m), error: () => {} });
102
+ assert.equal(r.action, 'adopted');
103
+ assert.deepEqual(store.row.board, TIMOCRACY);
104
+ assert.equal(store.row.source, 'adopted_file');
105
+ assert.ok(lines.some((l) => /adopted/.test(l)), 'the adoption is logged');
106
+ const again = await constitution.reconcile({ instancePath: pack, ...quiet });
107
+ assert.equal(again.action, 'in_step', 'a second pass changes nothing');
108
+ });
109
+ });
110
+
111
+ test('no row and no file is the vanilla instance: nothing is written', async () => {
112
+ await withInstance(async ({ pack, store }) => {
113
+ const r = await constitution.reconcile({ instancePath: pack, ...quiet });
114
+ assert.equal(r.action, 'none');
115
+ assert.equal(store.saves.length, 0);
116
+ assert.equal(fs.existsSync(pack), false, 'no file is invented for a vanilla instance');
117
+ }, { history: [] });
118
+ });
119
+
120
+ test('the toast case: no row, no file, a passed amendment — the first boot puts it back in force (part C)', async () => {
121
+ const history = [
122
+ { item_id: 30, amendment_id: 9, outcome: 'returned', proposed: MONARCHY, kind: 'constitution' },
123
+ { item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' },
124
+ ];
125
+ await withInstance(async ({ pack, store, live }) => {
126
+ assert.deepEqual({ ...live() }, MONARCHY, 'fixture: the erased instance answers the day-one monarchy');
127
+ const lines = [];
128
+ const r = await constitution.reconcile({ instancePath: pack, log: (m) => lines.push(m), error: () => {} });
129
+ assert.equal(r.action, 'recovered', JSON.stringify(r));
130
+ assert.equal(r.item_id, '21', 'the PASSED amendment, never the newer returned one');
131
+ assert.deepEqual({ ...live() }, TIMOCRACY, 'the board decision is in force again');
132
+ assert.deepEqual(store.row.board, TIMOCRACY, 'and durable, so the next restart keeps it');
133
+ assert.ok(lines.some((l) => /put back passed amendment 1/.test(l)), 'the log says so');
134
+ assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'in_step');
135
+ }, { history });
136
+ });
137
+
138
+ test('the row wins over a stale file, and the file keeps its other keys', async () => {
139
+ await withInstance(async ({ pack, live }) => {
140
+ fs.mkdirSync(path.dirname(pack), { recursive: true });
141
+ fs.writeFileSync(pack, JSON.stringify({ ranks: { metic: { label: 'Trusted' } }, board: MONARCHY }));
142
+ const r = await constitution.reconcile({ instancePath: pack, ...quiet });
143
+ assert.equal(r.action, 'restored');
144
+ assert.deepEqual({ ...live() }, TIMOCRACY);
145
+ assert.equal(JSON.parse(fs.readFileSync(pack, 'utf8')).ranks.metic.label, 'Trusted', 'rank templates survive the restore');
146
+ }, { row: { board: TIMOCRACY, source: 'amendment' } });
147
+ });
148
+
149
+ test('an unmigrated database (no table) and an unreadable one never throw and never touch the file', async () => {
150
+ const missing = Object.assign(new Error('relation "government_constitution" does not exist'), { code: '42P01' });
151
+ await withInstance(async ({ pack }) => {
152
+ assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'no_table');
153
+ assert.equal(fs.existsSync(pack), false);
154
+ }, { getFails: missing });
155
+ await withInstance(async ({ pack }) => {
156
+ assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'read_failed');
157
+ }, { getFails: new Error('timeout') });
158
+ });
159
+
160
+ // ── 4. row first, and a write that did not take is reported ──────────────────
161
+
162
+ test('the row is written before the file: a failed save leaves the old rule and SAYS so', async () => {
163
+ await withInstance(async ({ pack, live }) => {
164
+ const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
165
+ assert.equal(out.applied, null);
166
+ assert.ok(out.failure, 'reported, not absorbed');
167
+ assert.equal(out.failure.problems[0].where, 'database');
168
+ assert.equal(fs.existsSync(pack), false, 'nothing was put in force that would vanish at the next restart');
169
+ assert.equal(out.failure.problems[0].reason, 'save_failed');
170
+ assert.doesNotMatch(JSON.stringify(out.failure), /connection refused/, 'a voter sees a reason word, never the driver message');
171
+ assert.deepEqual({ ...live() }, MONARCHY);
172
+ }, { saveFails: true });
173
+ });
174
+
175
+ test('a file write that silently did not take is caught by the read-back', async () => {
176
+ await withInstance(async ({ store }) => {
177
+ config.applyBoardAmendment = (proposed) => ({ ...proposed }); // claims success, writes nothing
178
+ const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
179
+ assert.ok(out.failure, 'the verify caught it');
180
+ assert.deepEqual(out.failure.problems.map((p) => p.where), ['in_force']);
181
+ assert.deepEqual(out.failure.problems[0].fields, ['membership', 'pass_rule']);
182
+ assert.deepEqual(store.row.board, TIMOCRACY, 'the durable row still holds the decision, so a restore can finish the job');
183
+ });
184
+ });
185
+
186
+ test('closeItem hands a constitution that did not take back to the caller (and the vote route forwards it)', async () => {
187
+ const item = { id: '21', subject_type: 'constitutional_amendment', subject_id: '5', opened_by: '42', closed_at: null, expired: false, constitution: MONARCHY };
188
+ const stubs = {
189
+ getVotesForItem: async () => [{ id: '1', voter_id: '42', direction: 'yes' }],
190
+ closeBoardItem: async ({ outcome }) => ({ ...item, closed_at: 'T', outcome }),
191
+ getAmendment: async () => ({ id: '5', proposed: TIMOCRACY, kind: 'constitution' }),
192
+ insertAuthorRatifyKarma: async () => true,
193
+ getBoardItemAuthor: async () => '42',
194
+ };
195
+ const orig = {};
196
+ for (const [k, v] of Object.entries(stubs)) { orig[k] = db[k]; db[k] = v; }
197
+ try {
198
+ await withInstance(async () => {
199
+ const closed = await board.closeItem(item, { cause: 'vote' });
200
+ assert.equal(closed.outcome, 'passed');
201
+ assert.ok(closed.constitution_not_applied, 'the close itself reports it');
202
+ assert.equal(closed.constitution_not_applied.problems[0].where, 'database');
203
+ }, { saveFails: true });
204
+ await withInstance(async ({ live }) => {
205
+ const closed = await board.closeItem(item, { cause: 'vote' });
206
+ assert.equal(closed.constitution_not_applied, undefined, 'a clean apply reports nothing');
207
+ assert.deepEqual({ ...closed.applied_constitution }, TIMOCRACY);
208
+ assert.deepEqual({ ...live() }, TIMOCRACY);
209
+ });
210
+ } finally { for (const [k, v] of Object.entries(orig)) db[k] = v; }
211
+
212
+ const routeSrc = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
213
+ assert.match(routeSrc, /closure && closure\.constitution_not_applied \? \{ constitution_not_applied:/,
214
+ 'the vote route forwards it to the page the voter is standing on');
215
+ for (const page of ['board-room.js', 'street-card.js']) {
216
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', page), 'utf8');
217
+ assert.match(src, /constitution_not_applied/, `${page} says so instead of "in force"`);
218
+ }
219
+ });
220
+
221
+ // ── 5. the operator's re-apply ───────────────────────────────────────────────
222
+
223
+ test('re-apply puts the newest PASSED constitutional amendment back, skipping returned ones and laws', async () => {
224
+ const history = [
225
+ { item_id: 30, amendment_id: 12, outcome: 'returned', proposed: MONARCHY, kind: 'constitution' },
226
+ { item_id: 29, amendment_id: 11, outcome: 'passed', proposed: { enact: [] }, kind: 'laws' },
227
+ { item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' },
228
+ ];
229
+ const origList = db.listAmendmentHistory;
230
+ db.listAmendmentHistory = async () => history;
231
+ try {
232
+ await withInstance(async ({ store, live }) => {
233
+ const out = await constitution.reapplyNewestPassed({ updatedBy: '3' });
234
+ assert.equal(out.ok, true, JSON.stringify(out));
235
+ assert.equal(out.item_id, '21');
236
+ assert.deepEqual({ ...live() }, TIMOCRACY);
237
+ assert.equal(store.row.source, 'reapply');
238
+ assert.equal(store.saves[0].updatedBy, '3');
239
+ });
240
+ db.listAmendmentHistory = async () => history.slice(0, 2);
241
+ await withInstance(async ({ store }) => {
242
+ const out = await constitution.reapplyNewestPassed({});
243
+ assert.deepEqual(out, { ok: false, reason: 'no_passed_amendment' });
244
+ assert.equal(store.saves.length, 0, 'it never invents a constitution');
245
+ });
246
+ } finally { db.listAmendmentHistory = origList; }
247
+ });
248
+
249
+ test('the re-apply route is pinned to the owner atom and takes no block from the request', () => {
250
+ const { GOVERNMENT_PINS } = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
251
+ const pins = GOVERNMENT_PINS || {};
252
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
253
+ assert.match(src, /'POST \/government\/constitution\/reapply': 'perm:government\.manage'/);
254
+ if (Object.keys(pins).length) assert.equal(pins['POST /government/constitution/reapply'], 'perm:government.manage');
255
+ const routeSrc = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
256
+ assert.match(routeSrc, /router\.post\('\/government\/constitution\/reapply', api\.requireBuilder, api\.requirePermission\('government\.manage'\), constitution\.reapplyRoute\(log\)\)/,
257
+ 'the route exists, gated on the owner atom');
258
+ assert.doesNotMatch(constitution.reapplyRoute.toString(), /req\.body/, 'the block comes from a passed amendment, never the request');
259
+ });
260
+
261
+ test('the re-apply handler answers 404 with nothing passed and 200 with the board it put back', async () => {
262
+ const call = async () => {
263
+ const res = { code: 200, body: null,
264
+ status(c) { this.code = c; return this; }, json(b) { this.body = b; return this; },
265
+ fail(code, status, extra) { this.code = typeof status === 'number' ? status : status.status; this.body = { code, ...extra }; return this; } };
266
+ await constitution.reapplyRoute({ error: () => {} })({ builder: { id: '3' }, body: { board: MONARCHY } }, res);
267
+ return res;
268
+ };
269
+ const origList = db.listAmendmentHistory;
270
+ try {
271
+ db.listAmendmentHistory = async () => [];
272
+ await withInstance(async () => {
273
+ const res = await call();
274
+ assert.equal(res.code, 404);
275
+ assert.equal(res.body.code, 'no_passed_amendment');
276
+ });
277
+ db.listAmendmentHistory = async () => [{ item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' }];
278
+ await withInstance(async ({ live }) => {
279
+ const res = await call();
280
+ assert.equal(res.code, 200);
281
+ assert.deepEqual({ ...res.body.board }, TIMOCRACY, 'the passed amendment, not the request body');
282
+ assert.deepEqual({ ...live() }, TIMOCRACY);
283
+ });
284
+ } finally { db.listAmendmentHistory = origList; }
285
+ });
286
+
287
+ // ── 6. at boot ───────────────────────────────────────────────────────────────
288
+
289
+ test('the restore runs the moment the poller starts, not after a delay', () => {
290
+ const poller = require(path.join(GOV, 'pollers', 'constitution-restore.js'));
291
+ let calls = 0;
292
+ poller.start({ reconcileFn: async () => { calls += 1; return { action: 'in_step' }; } });
293
+ try {
294
+ return new Promise((resolve) => setImmediate(() => { assert.equal(calls, 1); resolve(); }));
295
+ } finally { poller.stop(); }
296
+ });
297
+
298
+ test('the module declares the poller and the migration creates the one-row table', () => {
299
+ const manifest = JSON.parse(fs.readFileSync(path.join(GOV, 'module.json'), 'utf8'));
300
+ assert.ok(manifest.contributes.pollers.includes('constitution-restore'));
301
+ const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_037_constitution.sql'), 'utf8');
302
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS government_constitution/);
303
+ assert.match(sql, /CHECK \(id = 1\)/, 'one row, by construction');
304
+ });
@@ -53,6 +53,7 @@ const NON_GRANT_MIGRATIONS = new Set([
53
53
  'government_031_treasury.sql', // the treasury: a pot per goal and the loans a dry pot asks the reserve for (task 1004712)
54
54
  'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
55
55
  'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
56
+ 'government_037_constitution.sql', // the constitution in force as a durable row (task 1005140)
56
57
  ]);
57
58
 
58
59
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -289,7 +289,7 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
289
289
  // task 1004685 (the living city): gov-living.js, the model under the drawing, and
290
290
  // gov-feeds.js, its one reader per page (shared with the shell's tally), load before it
291
291
  // task 1004702: the card answers its first ask in place, so the deck's card and sender (and the desk rows under them) load before the page
292
- assert.deepEqual(scripts, ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, then the shared dialog (task 1005085), as on every hall page');
292
+ assert.deepEqual(scripts, ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, then the shared dialog (task 1005085), as on every hall page');
293
293
  // task 1004925: every script but dom-utils.js is deferred, so none holds up the
294
294
  // parser; defer runs them in document order, which is why the order above still
295
295
  // holds. dom-utils.js stays synchronous: the one-escaper guard
@@ -25,6 +25,8 @@ const HALL = path.join(ROOT, 'modules/hall-ui/public');
25
25
  const SHELL = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
26
26
  // task 1004492: the way back lives in shell-trail.js, which every page loads first.
27
27
  const TRAIL = fs.readFileSync(path.join(HALL, 'shell-trail.js'), 'utf8');
28
+ // task 1005142: game mode, the hall-wide half of the switch; every page loads it before the shell.
29
+ const GAME = fs.readFileSync(path.join(HALL, 'shell-game.js'), 'utf8');
28
30
  const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
29
31
 
30
32
  // Boot the shell once — one page load. `store` is the browser's localStorage and
@@ -125,11 +127,16 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
125
127
  };
126
128
  sandbox.globalThis = sandbox;
127
129
  vm.createContext(sandbox);
130
+ vm.runInContext(GAME, sandbox, { filename: 'shell-game.js' });
128
131
  vm.runInContext(TRAIL, sandbox, { filename: 'shell-trail.js' });
129
132
  vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
130
133
  const root = documentObj.documentElement;
131
134
  return {
132
135
  store,
136
+ game: () => windowObj.OTBShellGame,
137
+ gameStamp: () => root.dataset.game === 'on',
138
+ // The "how to leave" line: showing = the shell created it and it is not hidden.
139
+ hintShowing: () => els.has('fullscreen-hint') && els.get('fullscreen-hint').hidden === false,
133
140
  button: () => getElementById('fullscreen-toggle'),
134
141
  on: () => root.dataset.fullscreen === 'on',
135
142
  plain: () => root.dataset.fullscreenPlain === 'on',
@@ -477,3 +484,97 @@ test('source: full screen hides the sidebar and foot, and collapses the top bar
477
484
  assert.match(css, /html\[data-fullscreen="on"\] \.topbar-side > :not\(\.fullscreen-toggle\)/);
478
485
  assert.doesNotMatch(css, /html\[data-fullscreen="on"\] \.app-topbar \{ display: none/);
479
486
  });
487
+
488
+ // GAME MODE (task 1005142, BONGOS-V3 R02). The spec's done-when: no page shows the
489
+ // sidebar in game mode. Game mode is hall-wide — one key, read by the SAME switch
490
+ // — so these drive the real shell-game.js + shell.js the way a reader moves
491
+ // through the hall: page after page over one browser store.
492
+ test('game mode: EVERY page opens without its bars — plain, laid-out, and one that remembers "off"', () => {
493
+ const store = { 'hall-game': 'on', 'hall-fullscreen:goals': 'off' };
494
+ for (const [page, optIn] of [['goals', false], ['work', false], ['thinking', true], ['city', true]]) {
495
+ const p = boot({ page, optIn, store });
496
+ assert.equal(p.on(), true, `${page} opens in full screen`);
497
+ assert.equal(p.gameStamp(), true, `${page}: html[data-game="on"]`);
498
+ assert.equal(p.plain(), optIn === false, `${page}: the plain mark still follows the page`);
499
+ }
500
+ assert.equal(boot({ page: 'goals', optIn: false, store: {} }).on(), false, 'and outside game mode nothing changed');
501
+ });
502
+
503
+ test('game mode: every hall page carries the switch — none opts out, and each loads shell-game.js before the shell', () => {
504
+ const pages = fs.readdirSync(HALL).filter((f) => f.endsWith('.html'));
505
+ assert.ok(pages.length >= 30, `found ${pages.length} hall pages`);
506
+ for (const f of pages) {
507
+ const html = read(f);
508
+ assert.doesNotMatch(html, /data-fullscreen-switch="off"/, `${f} must not opt out: it would keep its sidebar in game mode`);
509
+ assert.match(html, /id="app-topbar"/, `${f}: the top bar is where the shell renders the switch`);
510
+ const tags = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js[^"]*"( defer)?><\/script>/g)].map((m) => [m[1], !!m[2]]);
511
+ const game = tags.findIndex((t) => t[0] === 'shell-game');
512
+ const shell = tags.findIndex((t) => t[0] === 'shell');
513
+ assert.ok(game >= 0 && game < shell, `${f} loads shell-game.js before shell.js`);
514
+ assert.equal(tags[game][1], tags[shell][1], `${f}: both load the same way, so the order holds`);
515
+ }
516
+ });
517
+
518
+ test('game mode: leaving brings the bars back for THIS page only, and never rewrites a page\'s own memory', () => {
519
+ const store = { 'hall-game': 'on', 'hall-fullscreen:thinking': 'on' };
520
+ const goals = boot({ page: 'goals', optIn: false, store });
521
+ goals.press('Escape');
522
+ assert.equal(goals.on(), false, 'Escape still leaves');
523
+ assert.equal(store['hall-fullscreen:goals'], undefined, 'nothing remembered for the page');
524
+ assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'the next load opens without bars again');
525
+ const sky = boot({ page: 'thinking', store });
526
+ sky.button().click();
527
+ assert.equal(sky.on(), false, 'the corner button leaves too');
528
+ assert.equal(store['hall-fullscreen:thinking'], 'on', 'the sky\'s own choice survives game mode');
529
+ });
530
+
531
+ test('game mode: the Escape hand-off is unchanged — a press the page used, or one in a field, does not leave', () => {
532
+ const p = boot({ page: 'goals', optIn: false, store: { 'hall-game': 'on' } });
533
+ p.press('Escape', { pageUses: true });
534
+ assert.equal(p.on(), true, 'a page listener that called preventDefault keeps the press');
535
+ p.press('Escape', { target: { tagName: 'TEXTAREA' } });
536
+ assert.equal(p.on(), true);
537
+ });
538
+
539
+ test('game mode: set() switches the page live, and turning it off hands each page back its own memory', () => {
540
+ const store = { 'hall-fullscreen:thinking': 'on' };
541
+ const goals = boot({ page: 'goals', optIn: false, store });
542
+ goals.game().set(true);
543
+ assert.equal(store['hall-game'], 'on');
544
+ assert.equal(goals.on(), true, 'on at once, no reload');
545
+ assert.ok(goals.resizes() >= 1, 'a page that sizes to its box is told to re-measure');
546
+ goals.game().set(false);
547
+ assert.equal(goals.on(), false, 'goals remembered nothing, so its bars return');
548
+ assert.equal(goals.gameStamp(), false);
549
+ assert.equal(store['hall-fullscreen:goals'], undefined, 'and switching the mode wrote nothing for the page');
550
+ const sky = boot({ page: 'thinking', store: Object.assign(store, { 'hall-game': 'on' }) });
551
+ sky.game().set(false);
552
+ assert.equal(sky.on(), true, 'the sky had chosen full screen itself, and keeps it');
553
+ });
554
+
555
+ test('game mode: ?game=on / ?game=off set it from the URL; anything else is ignored', () => {
556
+ const store = {};
557
+ assert.equal(boot({ page: 'work', optIn: false, store, search: '?game=on' }).on(), true);
558
+ assert.equal(store['hall-game'], 'on');
559
+ assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'and it holds on the next page');
560
+ assert.equal(boot({ page: 'work', optIn: false, store, search: '?game=off' }).on(), false);
561
+ boot({ page: 'work', optIn: false, store, search: '?game=%22%3E%3Cimg%3E' });
562
+ assert.equal(store['hall-game'], 'off', 'a value that is not on/off is not stored');
563
+ });
564
+
565
+ test('game mode: how to leave is said once per tab, not on every page; outside it the shell\'s rule stands', () => {
566
+ const store = { 'hall-game': 'on' };
567
+ const session = {};
568
+ assert.equal(boot({ page: 'goals', optIn: false, store, session }).hintShowing(), true, 'the first page says it');
569
+ assert.equal(boot({ page: 'work', optIn: false, store, session }).hintShowing(), false, 'the next does not repeat it');
570
+ const own = { 'hall-fullscreen:thinking': 'on' };
571
+ assert.equal(boot({ page: 'thinking', store: own, session: {} }).hintShowing(), true);
572
+ assert.equal(boot({ page: 'thinking', store: own, session: {} }).hintShowing(), true, 'a remembered page says it on every open, as before');
573
+ });
574
+
575
+ test('game mode: storage that throws leaves the hall out of game mode, with the switch working', () => {
576
+ const p = boot({ page: 'goals', optIn: false, storageThrows: true, search: '?game=on' });
577
+ assert.equal(p.on(), false);
578
+ p.button().click();
579
+ assert.equal(p.on(), true);
580
+ });
@@ -443,7 +443,7 @@ test('PAGE: in the hall family, with no prototype key', () => {
443
443
  assert.deepEqual(sheets, ['style.css', 'oversight.css', 'speciality-tools.css', 'my-agents.css'],
444
444
  'rides the oversight archetype; the shared speciality sheet (task 1005052) before its own, so the page skin wins');
445
445
  const scripts = [...html.matchAll(/src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
446
- assert.deepEqual(scripts.slice(0, 4), ['dom-utils', 'shell-trail', 'craft-mode', 'shell']); // dom-utils first: the shell escapes through it (task 1004876)
446
+ assert.deepEqual(scripts.slice(0, 5), ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell']); // dom-utils first: the shell escapes through it (task 1004876)
447
447
  for (const s of ['gov-living', 'gov-positions', 'my-agents', 'speciality-tools']) assert.ok(scripts.includes(s), `loads ${s}.js`);
448
448
  assert.doesNotMatch(html, /ma-key|data-proto|Prototype data/, 'nothing on the page is prototype data, so there is no key');
449
449
  assert.doesNotMatch(read('my-agents.js'), /governor-overlay/, 'and it reads no overlay');
@@ -490,7 +490,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
490
490
  assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
491
491
  for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
492
492
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
493
- assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
493
+ assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
494
494
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
495
495
  });
496
496
 
@@ -102,7 +102,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
102
102
  // task 1005051: Roadmap left it (folded into Goals; /roadmap redirects).
103
103
  // task 1005070: the remembered nav (the rail paints before /me).
104
104
  // task 1005088: the sidebar mark is drawn by reference to the one brand mark.
105
- const SHELL_SHA = '2845bfedfaf7846f';
105
+ const SHELL_SHA = 'e9c13c142bcd2a95';
106
106
 
107
107
  test('shell.js has not changed without its cache-bust being bumped', () => {
108
108
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);