@bongos/core 1.24.93 → 1.24.95

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 (44) hide show
  1. package/.bongos-core.json +123 -33
  2. package/docs/adr/0404-a-party-is-a-room-and-rooms-register-as-kinds.md +118 -0
  3. package/docs/adr/0405-each-character-has-one-colour-and-the-visor-wears-it.md +31 -0
  4. package/docs/adr/README.md +2 -0
  5. package/docs/copy-inventory.md +7 -7
  6. package/docs/copy-registry.json +7 -7
  7. package/docs/design/characters/v2/artisan-visor.png +0 -0
  8. package/docs/design/characters/v2/artisan.png +0 -0
  9. package/docs/design/characters/v2/artist-visor.png +0 -0
  10. package/docs/design/characters/v2/artist.png +0 -0
  11. package/docs/design/characters/v2/draw-characters.js +465 -0
  12. package/docs/design/characters/v2/patron-visor.png +0 -0
  13. package/docs/design/characters/v2/patron.png +0 -0
  14. package/docs/design/characters/v2/sheet.html +114 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/modules-contract.md +2 -0
  17. package/docs/page-readings.json +35 -35
  18. package/modules/hall-ui/public/characters/artisan-face.png +0 -0
  19. package/modules/hall-ui/public/characters/artisan-visor.png +0 -0
  20. package/modules/hall-ui/public/characters/artisan.png +0 -0
  21. package/modules/hall-ui/public/characters/artist-face.png +0 -0
  22. package/modules/hall-ui/public/characters/artist-visor.png +0 -0
  23. package/modules/hall-ui/public/characters/artist.png +0 -0
  24. package/modules/hall-ui/public/characters/patron-face.png +0 -0
  25. package/modules/hall-ui/public/characters/patron-visor.png +0 -0
  26. package/modules/hall-ui/public/characters/patron.png +0 -0
  27. package/modules/hall-ui/public/craft-mode.js +15 -4
  28. package/modules/hall-ui/public/home-city.js +14 -3
  29. package/modules/hall-ui/public/home-gallery.css +6 -0
  30. package/modules/hall-ui/public/home-gallery.js +30 -0
  31. package/modules/hall-ui/public/style.css +18 -5
  32. package/modules/hall-ui/public/visor.css +20 -5
  33. package/modules/hall-ui/public/visor.js +6 -3
  34. package/modules/hall-ui/records/characters.md +19 -0
  35. package/modules/hall-ui/records/visor.md +10 -1
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +20 -0
  39. package/src/module-api.js +1 -1
  40. package/src/module-seams.js +3 -0
  41. package/tests/character_creation.mjs +44 -7
  42. package/tests/hall_visor.mjs +28 -2
  43. package/tests/home_gallery_widget.mjs +43 -0
  44. package/tests/room_kinds_seam.mjs +68 -0
@@ -4,7 +4,7 @@ Covers: `visor.js` + `visor.css`, loaded on Home, `/city`, `/goal-map` and `/thi
4
4
 
5
5
  ## The rule every game page keeps (ADR 0403, owner 2026-11-11)
6
6
 
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 and the sky since task 1005210 (below).
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
9
  ## A place's own buttons on the glass (task 1005209, the Governor City first)
10
10
 
@@ -20,6 +20,15 @@ The visor is the control and the scene is the view: every main call to action in
20
20
  - **A visor fix that came with it.** The sheet had a click listener of its own inside `#visor`'s, so every row ran twice; a toggle row undid itself. The sheet's listener is gone.
21
21
  - **Not done:** the tour still spots the pills it explains, which are off the glass in game mode. Tests: `tests/hall_sky_visor.mjs` (both pages' own markup, the real visor and sheets, in a real browser).
22
22
 
23
+ ## The glass wears your character’s colour (task 1005218, ADR 0405)
24
+
25
+ The frame’s lines, the sight, your name, the rim of your picture and the accent on the glass (the one button, the segment being built) are the held character’s one colour, and the picture is your character’s head wearing the visor. Where the sections below say the lines are ice or the button is the accent, read "the character’s colour"; for an Artisan the two are the same. The detail is in `characters.md`.
26
+ ## Home's gallery on the glass (task 1005211)
27
+
28
+ - **What left the gallery in game mode** (`home-gallery.css`, under `html.visor-on`): the order tabs (Most popular, Latest), the ways (The full gallery, Your studio), the two arrows and the full-screen button. A work's label, its heart and a piece in the full gallery stay: they are looking at the art, not leaving it.
29
+ - **Where they went.** `home-gallery.js` gives Home its rows (`visorActions()`, and `onChange()` for when they change), each pressing the gallery's own control in its own words: the full gallery (or "Back to the bench") is the lead on G; the two tabs with "· on/off"; Previous work ←, Next work →, Full screen F, which hand the wall the keyboard so its own keys answer next; Your studio. `home-city.js` registers them as Home's scene, and none in a craft whose Home is not the gallery.
30
+ - **Not done:** the attention cards and the Artist's idea box on Home are not the gallery's and still sit on the scene in game mode.
31
+
23
32
  ## The minimal visor (Helm look 3, task 1005195)
24
33
 
25
34
  The glass was redrawn to the owner's mock-up "Cloud city: in the visor, minimal". Look and layout only: every action, key, id, aria attribute, copy string and data source in `visor.js` is as task 1005148 left it. Where this section and the older bullets below disagree about the look or where a part sits, this section is current.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.93",
3
+ "version": "1.24.95",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.93",
9
+ "version": "1.24.95",
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.24.93",
3
+ "version": "1.24.95",
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",
@@ -11663,5 +11663,25 @@
11663
11663
  "id": "1005210",
11664
11664
  "text": "In game mode the sky (Goals and Thinking) no longer has buttons of its own: \"Write a thought\" is on the visor (press /), jumping is J, and the lens switch, the pills, the clock and the legend are rows in the visor's Actions li"
11665
11665
  }
11666
+ ],
11667
+ "1.24.94": [
11668
+ {
11669
+ "id": "1005247",
11670
+ "text": "The V3 game plan now records the owner's eight answers from the task cleanup, so new work is written against them."
11671
+ },
11672
+ {
11673
+ "id": "1005211",
11674
+ "text": "In game mode the Artist's Home gallery no longer has tabs, links or arrows of its own: the full gallery is on the visor (press G), and the order tabs, previous/next work, full screen and Your studio are rows in the visor's Act"
11675
+ },
11676
+ {
11677
+ "id": "1005220",
11678
+ "text": "The game's parties now have one agreed design: any group that plays together gets the same voice channel, member list and chat, whether it's a party you form, a quest party or a meeting room."
11679
+ }
11680
+ ],
11681
+ "1.24.95": [
11682
+ {
11683
+ "id": "1005218",
11684
+ "text": "The three characters have a new look: space-faring aliens made of two animals each, drawn at a larger size. Each has its own colour, and in the game the visor now takes your character's colour and shows your character wearing"
11685
+ }
11666
11686
  ]
11667
11687
  }
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.24.93'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.95'; // 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');
@@ -190,6 +190,9 @@ function listEvents() {
190
190
  // the slices merge into the GET /api/bongos/me payload.
191
191
  // (The client settings-panel seam is its browser twin,
192
192
  // window.OTB.contributeSettingsPanel in dom-utils.js.)
193
+ // • 'room.kinds' — one plain object per kind of room (party, chain, scoping,
194
+ // decision), contributed by the module that owns the group;
195
+ // read with contributions('room.kinds'). ADR 0404.
193
196
 
194
197
  const contributors = new Map(); // point -> [contribution, ...] (registration order)
195
198
 
@@ -153,7 +153,7 @@ test('a held character is the only one offered, and the top bar wears its sprite
153
153
  const sw = t.C.switchHtml();
154
154
  assert.match(sw, /craft-switch--character/);
155
155
  assert.match(sw, /data-craft-me/);
156
- assert.match(sw, /\/builders\/characters\/artisan\.png/);
156
+ assert.match(sw, /\/builders\/characters\/artisan-face\.png/, 'the chip is the head, its own picture (task 1005218)');
157
157
  assert.ok(!/data-craft-opt=/.test(sw), 'the three-way switch is gone');
158
158
  // A session's stored mode from before the character cannot override it.
159
159
  t.session['hall-craft'] = 'patron';
@@ -162,7 +162,7 @@ test('a held character is the only one offered, and the top bar wears its sprite
162
162
 
163
163
  test('the sprite is drawn on first paint from the last held character, before /me answers', () => {
164
164
  const t = boot({ store: { 'hall-character': 'patron' } });
165
- assert.match(t.C.switchHtml(), /\/builders\/characters\/patron\.png/);
165
+ assert.match(t.C.switchHtml(), /\/builders\/characters\/patron-face\.png/);
166
166
  });
167
167
 
168
168
  test('an older core that says nothing about characters changes nothing', () => {
@@ -187,15 +187,52 @@ test('the character section never writes `hidden` onto the nav: a character only
187
187
  assert.ok(!/nav-item|app-sidebar/.test(section), 'the character code does not touch the nav itself; applyNav does');
188
188
  });
189
189
 
190
- test('the owner\'s three sprites are on disk, one per character', () => {
190
+ // task 1005218: the second look. Three pictures per character, drawn by
191
+ // docs/design/characters/v2/draw-characters.js --hall: the whole figure (64), the head for
192
+ // the chip (32) and the head wearing the visor for the visor's corner (32).
193
+ test('each character\'s three pictures are on disk, at their sizes', () => {
191
194
  for (const id of ['artisan', 'artist', 'patron']) {
192
- const f = path.join(HALL, 'characters', id + '.png');
193
- assert.ok(fs.existsSync(f), `${id}.png`);
194
- const head = fs.readFileSync(f).subarray(0, 8);
195
- assert.deepEqual([...head], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], `${id}.png is a PNG`);
195
+ for (const [part, size] of [['', 64], ['-face', 32], ['-visor', 32]]) {
196
+ const f = path.join(HALL, 'characters', id + part + '.png');
197
+ assert.ok(fs.existsSync(f), `${id}${part}.png`);
198
+ const buf = fs.readFileSync(f);
199
+ assert.deepEqual([...buf.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], `${id}${part}.png is a PNG`);
200
+ assert.deepEqual([buf.readUInt32BE(16), buf.readUInt32BE(20)], [size, size], `${id}${part}.png is ${size} by ${size}`);
201
+ }
196
202
  }
197
203
  });
198
204
 
205
+ test('the hall\'s pictures are what the drawing script draws: nobody edited one by hand', async () => {
206
+ const { createRequire } = await import('node:module');
207
+ const draw = createRequire(import.meta.url)(path.join(ROOT, 'docs/design/characters/v2/draw-characters.js'));
208
+ const drawn = draw.hallFiles();
209
+ const onDisk = fs.readdirSync(path.join(HALL, 'characters')).sort();
210
+ assert.deepEqual(onDisk, Object.keys(drawn).sort(), 'nine files, three per character, and nothing else');
211
+ // Compared pixel for pixel, not byte for byte: two zlib builds may pack the same picture differently.
212
+ const zlib = await import('node:zlib');
213
+ const pixels = (png) => {
214
+ const parts = [];
215
+ for (let at = 8; at < png.length;) {
216
+ const len = png.readUInt32BE(at), type = png.toString('latin1', at + 4, at + 8);
217
+ if (type === 'IDAT') parts.push(png.subarray(at + 8, at + 8 + len));
218
+ at += 12 + len;
219
+ }
220
+ return zlib.inflateSync(Buffer.concat(parts));
221
+ };
222
+ for (const [name, buf] of Object.entries(drawn)) {
223
+ assert.ok(pixels(buf).equals(pixels(fs.readFileSync(path.join(HALL, 'characters', name)))), name + ' is stale or hand-edited: run node docs/design/characters/v2/draw-characters.js --hall');
224
+ }
225
+ });
226
+
227
+ test('the three characters\' colours are mixed from the hall\'s tokens, never typed in (ADR 0405)', () => {
228
+ const css = fs.readFileSync(path.join(HALL, 'style.css'), 'utf8');
229
+ const block = css.slice(css.indexOf('--char-artisan:'), css.indexOf('--char-patron:') + 120);
230
+ assert.match(block, /--char-artisan: var\(--accent\);/);
231
+ assert.match(block, /--char-artist: color-mix\(/);
232
+ assert.match(block, /--char-patron: color-mix\(/);
233
+ assert.ok(!/#[0-9a-fA-F]{3,8}/.test(block), 'no literal colour');
234
+ });
235
+
199
236
  // ---------- the server half ----------
200
237
  test('core_297 adds the account column, and the CHECK names exactly the three characters', () => {
201
238
  const sql = fs.readFileSync(path.join(ROOT, 'migrations/core_297_builder_character.sql'), 'utf8');
@@ -179,7 +179,7 @@ const SCENE = `<script>
179
179
  cv.addEventListener('pointerup', () => { drag = null; });
180
180
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && window.picked) { e.preventDefault(); window.picked = false; } });
181
181
  window.OTBCraftMode = { held: 'artisan', character() { return this.held; }, current() { return null; },
182
- me() { return { builder: { total_credits: 56021 } }; }, spriteSrc(id) { return '/builders/characters/' + id + '.png'; }, openTool(id) { window.toolOpened = id; } };
182
+ me() { return { builder: { total_credits: 56021 } }; }, spriteSrc(id) { return '/builders/characters/' + id + '.png'; }, portraitSrc(id) { return '/builders/characters/' + id + '-visor.png'; }, openTool(id) { window.toolOpened = id; } };
183
183
  window.__visorScene = { name: 'test', place: 'Test city', actions: [{ label: 'Back to centre', key: '0', run() { window.centred = true; } }], keys: [{ label: 'Look around', key: 'drag' }] };
184
184
  </script>`;
185
185
  // `helm` links the hall's real sheet (the token tier) instead of the stand-in colours.
@@ -240,7 +240,7 @@ test('in game mode the glass shows the place, the main action, the key hints, th
240
240
  // task 1005195: every other control is a quiet key hint. Actions kept its key and its sheet; nothing was dropped.
241
241
  assert.deepEqual(g.hints, ['TYour thinking', 'AActions', 'EscOptions']);
242
242
  assert.equal(g.arcs, 2, 'two arcs frame the glass');
243
- assert.equal(g.sprite, '/builders/characters/artisan.png');
243
+ assert.equal(g.sprite, '/builders/characters/artisan-visor.png', 'your picture on the glass is you wearing the visor (task 1005218)');
244
244
  assert.deepEqual(errors, []);
245
245
  await ctx.close();
246
246
  });
@@ -366,6 +366,32 @@ for (const [width, height] of [[1440, 900], [390, 844], [320, 640]]) {
366
366
  });
367
367
  }
368
368
 
369
+ // task 1005218 (ADR 0405): each character has one colour, and the glass wears the held one.
370
+ test('the glass wears the held character\'s colour: the Artisan\'s is the accent, the Artist\'s and the Patron\'s are their own', { skip }, async () => {
371
+ const { ctx, page } = await open({ helm: true });
372
+ const g = await page.evaluate(() => {
373
+ const probe = (v) => { const d = document.createElement('i'); d.style.color = v; document.body.appendChild(d); const c = getComputedStyle(d).color; d.remove(); return c; };
374
+ const out = { accent: probe('var(--accent)') };
375
+ for (const id of ['artisan', 'artist', 'patron']) {
376
+ window.OTBCraftMode.held = id; window.OTBVisor.render();
377
+ const cs = (s) => getComputedStyle(document.querySelector(s));
378
+ out[id] = { char: probe('var(--char-' + id + ')'), main: cs('.visor__main').backgroundColor, name: cs('.visor__name').color,
379
+ line: cs('.visor__line:not(.visor__line--under)').stroke, rim: cs('.visor__sprite').borderTopColor, src: document.querySelector('.visor__sprite').getAttribute('src') };
380
+ }
381
+ return out;
382
+ });
383
+ assert.equal(g.artisan.main, g.accent, 'an Artisan\'s glass is the accent it always was');
384
+ for (const id of ['artisan', 'artist', 'patron']) {
385
+ assert.equal(g[id].main, g[id].char, id + ': the one button is the character\'s colour');
386
+ assert.equal(g[id].name, g[id].char, id + ': so is the name');
387
+ assert.equal(g[id].line, g[id].char, id + ': and the frame\'s lines');
388
+ assert.equal(g[id].rim, g[id].char, id + ': and the rim of the picture');
389
+ assert.equal(g[id].src, '/builders/characters/' + id + '-visor.png');
390
+ }
391
+ assert.equal(new Set([g.artisan.char, g.artist.char, g.patron.char]).size, 3, 'three characters, three colours');
392
+ await ctx.close();
393
+ });
394
+
369
395
  test('the glass is the kit\'s: the cut accent button, ice hints with no box, the name voice, the inset focus ring', { skip }, async () => {
370
396
  const { ctx, page } = await open({ helm: true, width: 1440, height: 900 });
371
397
  const g = await page.evaluate(() => {
@@ -218,3 +218,46 @@ test('Home is the gallery only in Artist: the city\'s widget switches on the cra
218
218
  assert.equal(art.hidden, false);
219
219
  assert.notEqual(back, 'none', 'and comes back in another craft');
220
220
  });
221
+
222
+ // ---- task 1005211 (ADR 0403): in game mode the gallery's controls are the visor's ----
223
+ test("the gallery's rows for the visor: each in its control's own words, each pressing that control", { skip }, async () => {
224
+ const { ctx, page } = await boot();
225
+ const rows = () => page.evaluate(() => window.__g.visorActions().map((a) => [a.label, a.key || '', !!a.lead, !!a.bind]));
226
+ assert.deepEqual(await rows(), [
227
+ ['The full gallery', 'g', true, true], ['Most popular · on', '', false, false], ['Latest · off', '', false, false],
228
+ ['Previous work', '←', false, false], ['Next work', '→', false, false], ['Full screen', 'F', false, false], ['Your studio', '', false, false],
229
+ ], 'the full gallery is the lead, on G; the wall keeps its own arrow keys and F');
230
+ await page.evaluate(() => { window.__changes = 0; window.__g.onChange(() => { window.__changes += 1; }); });
231
+ await page.evaluate(() => window.__g.visorActions().find((a) => a.label === 'Next work').run());
232
+ assert.equal((await state(page)).count, `2 of ${N}`);
233
+ assert.equal(await page.evaluate(() => document.activeElement.className), 'hg-room', 'a row hands the wall the keyboard, so the arrows answer next');
234
+ await page.keyboard.press('ArrowRight');
235
+ assert.equal((await state(page)).count, `3 of ${N}`);
236
+ await page.evaluate(() => window.__g.visorActions()[0].run());
237
+ assert.deepEqual((await rows()).map((r) => r[0]), ['Back to the bench', 'Most popular · on', 'Latest · off', 'Your studio'], 'in the full gallery there is no wall to walk');
238
+ assert.ok(await page.evaluate(() => window.__changes) >= 1, 'Home is told the rows changed');
239
+ await page.evaluate(() => window.__g.visorActions().find((a) => a.label === 'Latest · off').run());
240
+ await page.waitForFunction(() => window.__g.visorActions().some((a) => a.label === 'Latest · on'));
241
+ await ctx.close();
242
+ });
243
+
244
+ test('game mode: the tabs, the ways, the arrows and full screen leave the wall; classic mode keeps them', { skip }, async () => {
245
+ const { ctx, page } = await boot();
246
+ const shown = () => page.evaluate(() => ['.hg-sort', '.hg-ways', '.hg-prev', '.hg-next', '.hg-fsbtn'].map((s) => getComputedStyle(document.querySelector(s)).display !== 'none'));
247
+ assert.deepEqual(await shown(), [true, true, true, true, true], 'classic');
248
+ await page.evaluate(() => document.documentElement.classList.add('visor-on'));
249
+ assert.deepEqual(await shown(), [false, false, false, false, false], 'with the visor up');
250
+ assert.ok(await page.evaluate(() => getComputedStyle(document.querySelector('.hg-heart')).display !== 'none'), 'a heart is the work\'s, and stays');
251
+ await ctx.close();
252
+ });
253
+
254
+ test('Home registers the gallery\'s rows as its scene in Artist, and none in another craft', { skip }, async () => {
255
+ const { ctx, page } = await boot({ city: true });
256
+ await page.evaluate(() => { window.__scenes = []; window.OTBVisor = { scene: (s) => window.__scenes.push(s) }; window.__widget.mount(document.getElementById('home-city')); });
257
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
258
+ assert.deepEqual(await page.evaluate(() => window.__visorScene.actions.length), 0, 'the builder\'s Home: no gallery rows');
259
+ await page.evaluate(() => { document.documentElement.dataset.craft = 'artist'; document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: 'artist' } })); });
260
+ await page.waitForFunction(() => window.__visorScene.actions.length > 0 && window.__visorScene.actions[0].label === 'The full gallery');
261
+ assert.equal(await page.evaluate(() => window.__visorScene.place), 'Home');
262
+ await ctx.close();
263
+ });
@@ -0,0 +1,68 @@
1
+ // tests/room_kinds_seam.mjs — ADR 0404 (task 1005220): rooms register as kinds
2
+ // through the 'room.kinds' CONTRIBUTION POINT, because several modules own a kind
3
+ // (lifecycle: party, chain, scoping; government: decision) and a port is
4
+ // single-provider. Pins that choice: two kinds from two modules register side by
5
+ // side and the boot check still passes, while the same pair as a port is refused.
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import { createRequire } from 'node:module';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
15
+
16
+ function kind(name, owner, members) {
17
+ return {
18
+ kind: name,
19
+ owner,
20
+ cap: name === 'party' ? 8 : 40,
21
+ members: async () => members,
22
+ isMember: async (_roomId, builderId) => members.some((m) => m.builderId === builderId),
23
+ };
24
+ }
25
+
26
+ test('two kinds from two modules both register, and the boot check passes', async () => {
27
+ seams._reset();
28
+ seams.contribute('room.kinds', kind('scoping', 'lifecycle', [{ builderId: 1, joinedAt: 't' }]));
29
+ seams.contribute('room.kinds', kind('decision', 'government', [{ builderId: 2, joinedAt: 't' }]));
30
+
31
+ const kinds = seams.contributions('room.kinds');
32
+ assert.deepEqual(kinds.map((k) => k.kind), ['scoping', 'decision']);
33
+ assert.deepEqual(kinds.map((k) => k.owner), ['lifecycle', 'government']);
34
+
35
+ // a consumer finds a kind by name and calls it — it never reads the owner's tables
36
+ const decision = kinds.find((k) => k.kind === 'decision');
37
+ assert.equal(await decision.isMember('r1', 2), true);
38
+ assert.equal(await decision.isMember('r1', 1), false);
39
+
40
+ // the modules' manifests declare no port for rooms, so the boot check is clean
41
+ const manifests = [
42
+ { key: 'lifecycle', provides: [], consumes: [] },
43
+ { key: 'government', provides: [], consumes: [] },
44
+ ];
45
+ assert.deepEqual(seams.verifyPortsSatisfied(manifests), { ok: true, errors: [] });
46
+ seams._reset();
47
+ });
48
+
49
+ test('the rejected design: the same pair as one port is refused at boot and at registration', () => {
50
+ seams._reset();
51
+ const manifests = [
52
+ { key: 'lifecycle', provides: ['room.members'], consumes: [] },
53
+ { key: 'government', provides: ['room.members'], consumes: [] },
54
+ ];
55
+ const res = seams.verifyPortsSatisfied(manifests);
56
+ assert.equal(res.ok, false);
57
+ assert.match(res.errors.join('\n'), /room\.members.*more than one enabled module/);
58
+
59
+ seams.registerProvider('room.members', async () => []);
60
+ assert.throws(() => seams.registerProvider('room.members', async () => []));
61
+ seams._reset();
62
+ });
63
+
64
+ test('an instance with no room owners has no rooms, and nothing throws', () => {
65
+ seams._reset();
66
+ assert.deepEqual(seams.contributions('room.kinds'), []);
67
+ seams._reset();
68
+ });