@bongos/core 1.24.94 → 1.24.96

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 (56) hide show
  1. package/.bongos-core.json +143 -43
  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/adr/0405-each-character-has-one-colour-and-the-visor-wears-it.md +31 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +49 -1
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +12 -12
  12. package/docs/copy-registry.json +12 -12
  13. package/docs/design/characters/v2/artisan-visor.png +0 -0
  14. package/docs/design/characters/v2/artisan.png +0 -0
  15. package/docs/design/characters/v2/artist-visor.png +0 -0
  16. package/docs/design/characters/v2/artist.png +0 -0
  17. package/docs/design/characters/v2/draw-characters.js +465 -0
  18. package/docs/design/characters/v2/patron-visor.png +0 -0
  19. package/docs/design/characters/v2/patron.png +0 -0
  20. package/docs/design/characters/v2/sheet.html +114 -0
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/page-inventory.json +2 -0
  23. package/docs/page-readings.json +35 -35
  24. package/modules/autonomy/db.js +48 -0
  25. package/modules/autonomy/routes/autonomy.js +15 -0
  26. package/modules/government/catalog.js +1 -1
  27. package/modules/hall-ui/public/characters/artisan-face.png +0 -0
  28. package/modules/hall-ui/public/characters/artisan-visor.png +0 -0
  29. package/modules/hall-ui/public/characters/artisan.png +0 -0
  30. package/modules/hall-ui/public/characters/artist-face.png +0 -0
  31. package/modules/hall-ui/public/characters/artist-visor.png +0 -0
  32. package/modules/hall-ui/public/characters/artist.png +0 -0
  33. package/modules/hall-ui/public/characters/patron-face.png +0 -0
  34. package/modules/hall-ui/public/characters/patron-visor.png +0 -0
  35. package/modules/hall-ui/public/characters/patron.png +0 -0
  36. package/modules/hall-ui/public/craft-mode.js +15 -4
  37. package/modules/hall-ui/public/home-city-draw.js +24 -2
  38. package/modules/hall-ui/public/home-city.css +15 -0
  39. package/modules/hall-ui/public/home-city.js +3 -1
  40. package/modules/hall-ui/public/home-runner.js +248 -0
  41. package/modules/hall-ui/public/index.html +8 -5
  42. package/modules/hall-ui/public/style.css +18 -5
  43. package/modules/hall-ui/public/visor.css +47 -5
  44. package/modules/hall-ui/public/visor.js +71 -5
  45. package/modules/hall-ui/records/characters.md +19 -0
  46. package/modules/hall-ui/records/home-runner.md +19 -0
  47. package/modules/hall-ui/records/visor.md +7 -0
  48. package/package-lock.json +2 -2
  49. package/package.json +1 -1
  50. package/release-notes.json +12 -0
  51. package/scripts/hall-preview/server.js +4 -0
  52. package/src/module-api.js +1 -1
  53. package/tests/autonomy_runner_goal_stats.mjs +92 -0
  54. package/tests/character_creation.mjs +44 -7
  55. package/tests/hall_visor.mjs +48 -2
  56. package/tests/home_runner.mjs +115 -0
@@ -11677,5 +11677,17 @@
11677
11677
  "id": "1005220",
11678
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
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
+ }
11686
+ ],
11687
+ "1.24.96": [
11688
+ {
11689
+ "id": "1005243",
11690
+ "text": "In game mode, tap a goal tower on Home to send your Autobongos runner there with one visor button; the tower then shows your runner at work, and tapping it shows tasks done, tokens used, time left and a Recall button."
11691
+ }
11680
11692
  ]
11681
11693
  }
@@ -112,6 +112,8 @@ let liveWhy = null;
112
112
  // me__recent-ships.json. Query strings are ignored when matching. Id-bearing
113
113
  // paths fall back to the generic names below so one fixture serves any id.
114
114
  const FIXTURE_FALLBACKS = [
115
+ // Home's runner card (task 1005243): one goal's runner numbers, the same for every goal
116
+ [/^autonomy\/me\/goals\/\d+\/stats$/, 'autonomy__me__goals__stats'],
115
117
  [/^goals\/\d+\/conflicts$/, 'goals__conflicts'],
116
118
  [/^goals\/\d+\/dependency-forest$/, 'goals__dependency-forest'],
117
119
  [/^goals\/\d+\/requests$/, 'goals__requests'],
@@ -221,6 +223,8 @@ const CANNED_WRITES = [
221
223
  ['POST', /^auth\/runner-code\/issue$/, 'auth__runner-code__issue.post'],
222
224
  ['POST', /^me\/sessions\/revoke$/, 'me__sessions__revoke.post'],
223
225
  ['DELETE', /^autonomy\/runners\/[^/]+$/, 'autonomy__runners.delete'],
226
+ // Home's runner card (task 1005243): Send runner here writes the one pick.
227
+ ['PUT', /^autonomy\/me\/goals$/, 'autonomy__me__goals.put'],
224
228
  ];
225
229
 
226
230
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
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.94'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.96'; // 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');
@@ -0,0 +1,92 @@
1
+ // tests/autonomy_runner_goal_stats.mjs — one goal, seen from the caller's runner
2
+ // (task 1005243): the numbers the game's runner card on Home shows.
3
+ //
4
+ // The route is driven as a REAL request through the assembled router with only the
5
+ // auth gates stubbed, and the SQL against a stubbed pool (the
6
+ // autonomy_builder_scope precedent). DB-free, no network.
7
+ // Run: node --test tests/autonomy_runner_goal_stats.mjs
8
+
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createRequire } from 'node:module';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const req = createRequire(import.meta.url);
17
+ const express = req('express');
18
+ const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
19
+ const api = req(path.join(ROOT, 'src/module-api.js'));
20
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
21
+
22
+ const permStub = (key) => { const mw = (_q, _s, next) => next(); mw.permKey = key; return mw; };
23
+ const passBuilder = (q, _s, next) => { q.builder = { id: 7, rank: 'metic' }; next(); };
24
+
25
+ function router() {
26
+ const saved = { b: api.requireBuilder, p: api.requirePermission };
27
+ api.requireBuilder = passBuilder;
28
+ api.requirePermission = permStub;
29
+ const p = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
30
+ delete req.cache[p];
31
+ try { return req(p)(); } finally { api.requireBuilder = saved.b; api.requirePermission = saved.p; }
32
+ }
33
+ async function serve(r) {
34
+ const app = express();
35
+ app.use(express.json()); app.use(attachFail); app.use(r);
36
+ const server = app.listen(0);
37
+ await new Promise((ok) => server.once('listening', ok));
38
+ return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
39
+ }
40
+ async function withPool(answer, fn) {
41
+ const saved = api.pool.query;
42
+ const calls = [];
43
+ api.pool.query = async (sql, params) => { calls.push({ sql, params }); return answer(sql, params); };
44
+ try { return await fn(calls); } finally { api.pool.query = saved; }
45
+ }
46
+
47
+ test('the read counts only the caller\'s RUNNER claims that shipped in that goal, and their sessions\' tokens', async () => {
48
+ await withPool(() => ({ rows: [{ tasks_done: 7, tokens: '4820000', avg_s: '2280.4', tasks_left: 5 }] }), async (calls) => {
49
+ const out = await db.readRunnerGoalStats(7, 1000090);
50
+ assert.deepEqual(out, { tasks_done: 7, tokens: 4820000, avg_task_seconds: 2280, tasks_left: 5 });
51
+ const { sql, params } = calls[0];
52
+ assert.deepEqual(params, [7, 1000090], 'scoped to the caller and the goal');
53
+ assert.match(sql, /c\.autonomous/, 'a runner\'s claim, not a person\'s');
54
+ assert.match(sql, /c\.outcome = 'shipped'/, 'shipped, by the ledger');
55
+ assert.match(sql, /sr\.builder_id = \$1/, 'only the caller\'s sessions');
56
+ assert.match(sql, /status IN \('ready', 'active'\)/, 'what is left is what a runner could still take');
57
+ });
58
+ });
59
+
60
+ test('a goal not opened to runners tells no count of what is left', async () => {
61
+ await withPool(() => ({ rows: [{ tasks_done: 0, tokens: '0', avg_s: null, tasks_left: null }] }), async () => {
62
+ assert.equal((await db.readRunnerGoalStats(7, 9)).tasks_left, null);
63
+ });
64
+ });
65
+
66
+ test('nothing done yet reads as zeros and no average, never a made-up pace', async () => {
67
+ await withPool(() => ({ rows: [{ tasks_done: 0, tokens: '0', avg_s: null, tasks_left: 3 }] }), async () => {
68
+ assert.deepEqual(await db.readRunnerGoalStats(7, 1), { tasks_done: 0, tokens: 0, avg_task_seconds: null, tasks_left: 3 });
69
+ });
70
+ });
71
+
72
+ test('GET /autonomy/me/goals/:id/stats: behind autonomy.run, the caller\'s own, a bad id refused', async () => {
73
+ const r = router();
74
+ const layer = r.stack.find((l) => l.route && l.route.path === '/autonomy/me/goals/:goalId/stats' && l.route.methods.get);
75
+ assert.ok(layer, 'the route is registered');
76
+ assert.deepEqual(layer.route.stack.map((s) => s.handle.permKey).filter(Boolean), ['autonomy.run']);
77
+ const s = await serve(r);
78
+ try {
79
+ await withPool(() => ({ rows: [{ tasks_done: 2, tokens: 1000, avg_s: 600, tasks_left: 1 }] }), async (calls) => {
80
+ const ok = await fetch(`${s.base}/autonomy/me/goals/1000090/stats`);
81
+ assert.equal(ok.status, 200);
82
+ assert.deepEqual(await ok.json(), { goal_id: 1000090, tasks_done: 2, tokens: 1000, avg_task_seconds: 600, tasks_left: 1 });
83
+ assert.deepEqual(calls[0].params, [7, 1000090], 'keyed by the session\'s builder, never the request');
84
+ const bad = await fetch(`${s.base}/autonomy/me/goals/abc/stats`);
85
+ assert.equal(bad.status, 400);
86
+ });
87
+ await withPool(() => { throw new Error('db down'); }, async () => {
88
+ const down = await fetch(`${s.base}/autonomy/me/goals/5/stats`);
89
+ assert.equal(down.status, 503);
90
+ });
91
+ } finally { s.close(); }
92
+ });
@@ -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
  });
@@ -271,6 +271,26 @@ test('Escape closes one layer, top first: the sheet, then the scene\'s pick, the
271
271
  await ctx.close();
272
272
  });
273
273
 
274
+ test('a scene\'s pick: the card is on the glass, its action takes the accent button, Escape closes it first (task 1005243)', { skip }, async () => {
275
+ const { ctx, page, errors } = await open();
276
+ await page.evaluate(() => OTBVisor.focus({ key: 'runner:2', title: 'Area 2', status: { text: 'Running', tone: 'ok' }, lines: ['7 tasks done here'],
277
+ lead: { label: 'Recall runner', run() { window.led = (window.led || 0) + 1; } }, onClose() { window.unpicked = true; } }));
278
+ const g = await page.evaluate(() => ({
279
+ title: document.querySelector('.visor__focus-title').textContent,
280
+ status: document.querySelector('.visor__focus-status').textContent,
281
+ main: document.querySelector('.visor__main').textContent,
282
+ takes: getComputedStyle(document.querySelector('.visor__focus')).pointerEvents,
283
+ }));
284
+ assert.deepEqual(g, { title: 'Area 2', status: 'Running', main: 'Recall runner', takes: 'auto' });
285
+ await page.click('[data-visor-lead]');
286
+ assert.equal(await page.evaluate(() => window.led), 1, 'the accent button runs the scene\'s action');
287
+ await page.keyboard.press('Escape');
288
+ const s = await page.evaluate(() => ({ focus: OTBVisor.focused(), unpicked: window.unpicked, picked: window.picked, menu: window.optionsOpened, main: document.querySelector('.visor__main').textContent }));
289
+ assert.deepEqual(s, { focus: null, unpicked: true, picked: true, menu: 0, main: 'Goal skyG' }, 'the card took the press; the character\'s own button is back');
290
+ assert.deepEqual(errors, []);
291
+ await ctx.close();
292
+ });
293
+
274
294
  test('a lead scene action is the first key hint, and its click and its key run it (task 1005209)', { skip }, async () => {
275
295
  const { ctx, page, errors } = await open();
276
296
  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; } }] }));
@@ -366,6 +386,32 @@ for (const [width, height] of [[1440, 900], [390, 844], [320, 640]]) {
366
386
  });
367
387
  }
368
388
 
389
+ // task 1005218 (ADR 0405): each character has one colour, and the glass wears the held one.
390
+ 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 () => {
391
+ const { ctx, page } = await open({ helm: true });
392
+ const g = await page.evaluate(() => {
393
+ 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; };
394
+ const out = { accent: probe('var(--accent)') };
395
+ for (const id of ['artisan', 'artist', 'patron']) {
396
+ window.OTBCraftMode.held = id; window.OTBVisor.render();
397
+ const cs = (s) => getComputedStyle(document.querySelector(s));
398
+ out[id] = { char: probe('var(--char-' + id + ')'), main: cs('.visor__main').backgroundColor, name: cs('.visor__name').color,
399
+ line: cs('.visor__line:not(.visor__line--under)').stroke, rim: cs('.visor__sprite').borderTopColor, src: document.querySelector('.visor__sprite').getAttribute('src') };
400
+ }
401
+ return out;
402
+ });
403
+ assert.equal(g.artisan.main, g.accent, 'an Artisan\'s glass is the accent it always was');
404
+ for (const id of ['artisan', 'artist', 'patron']) {
405
+ assert.equal(g[id].main, g[id].char, id + ': the one button is the character\'s colour');
406
+ assert.equal(g[id].name, g[id].char, id + ': so is the name');
407
+ assert.equal(g[id].line, g[id].char, id + ': and the frame\'s lines');
408
+ assert.equal(g[id].rim, g[id].char, id + ': and the rim of the picture');
409
+ assert.equal(g[id].src, '/builders/characters/' + id + '-visor.png');
410
+ }
411
+ assert.equal(new Set([g.artisan.char, g.artist.char, g.patron.char]).size, 3, 'three characters, three colours');
412
+ await ctx.close();
413
+ });
414
+
369
415
  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
416
  const { ctx, page } = await open({ helm: true, width: 1440, height: 900 });
371
417
  const g = await page.evaluate(() => {
@@ -0,0 +1,115 @@
1
+ // tests/home_runner.mjs — your Autobongos runner on Home's city (task 1005243).
2
+ //
3
+ // The decision is pure (home-runner.js cardFor / stateOf), so it runs here for
4
+ // real against the same runner-button.js the Settings card draws from. The page
5
+ // wiring is pinned by its source: the scene paints the pick, every button is the
6
+ // visor's (ADR 0403), and only a viewer the server says may run gets any of it.
7
+ // Run: node --test tests/home_runner.mjs
8
+
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createRequire } from 'node:module';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
18
+ const require = createRequire(import.meta.url);
19
+ const R = require(path.join(HALL, 'home-runner.js'));
20
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
21
+
22
+ const NOW = Date.parse('2026-11-12T10:00:00Z');
23
+ const seen = new Date(NOW - 20000).toISOString();
24
+ const fenceOf = (picks, allowed = [1, 2, 3], extra = {}) => ({ enabled: true, goals: picks, scope: { may_run: true, goals: picks }, project: { enabled: true, goals: allowed }, ...extra });
25
+ const dev = (o = {}) => ({ host: 'pc', run_requested: true, run_changed_at: new Date(NOW - 3600e3).toISOString(), last_seen_at: seen, health: { state: 'alive', workingTaskId: null }, ...o });
26
+ const STATS = { tasks_done: 7, tokens: 4820000, avg_task_seconds: 2280, tasks_left: 5 };
27
+ const card = (o) => R.cardFor({ goal: { id: 2, title: 'Area 2' }, nowMs: NOW, titleOf: (g) => `Area ${g}`, stats: STATS, ...o });
28
+
29
+ test('no permission to run, or no fence read: no card, the tower stays a link', () => {
30
+ assert.equal(card({ runners: [dev()], fence: null }), null);
31
+ assert.equal(card({ runners: [dev()], fence: { ...fenceOf([]), scope: { may_run: false, goals: [] } } }), null);
32
+ });
33
+
34
+ test('no runner set up yet: the card offers setting one up', () => {
35
+ const c = card({ runners: [], fence: fenceOf([]) });
36
+ assert.equal(c.lead.act, 'setup');
37
+ assert.equal(c.status.text, 'No runner yet');
38
+ });
39
+
40
+ test('a goal the project has not opened to runners: no send, only the goal', () => {
41
+ const c = card({ runners: [dev()], fence: fenceOf([], [1, 3]) });
42
+ assert.equal(c.lead.act, 'open');
43
+ assert.match(c.lines[0], /not opened this goal to runners/);
44
+ });
45
+
46
+ test('your runner is elsewhere or resting: Send runner here, with what it did here and what is left', () => {
47
+ const away = card({ runners: [dev()], fence: fenceOf([1]) });
48
+ assert.deepEqual(away.lead, { label: 'Send runner here', act: 'send' });
49
+ assert.equal(away.status.text, 'Your runner is at Area 1');
50
+ assert.deepEqual(away.lines, ['7 tasks done here · 4.8M tokens', '5 tasks ready · about 3 h for your runner']);
51
+ const off = card({ runners: [dev({ run_requested: false })], fence: fenceOf([]) });
52
+ assert.equal(off.status.text, 'Your runner is resting');
53
+ assert.equal(off.lead.act, 'send');
54
+ });
55
+
56
+ test('your runner is here and on: its device face, the numbers, and Recall runner', () => {
57
+ const work = card({ runners: [dev({ health: { state: 'alive', workingTaskId: '9' } })], fence: fenceOf([2]) });
58
+ assert.deepEqual(work.lead, { label: 'Recall runner', act: 'recall' });
59
+ assert.deepEqual([work.status.text, work.status.tone], ['Running', 'ok']);
60
+ assert.equal(work.lines[0], 'Working on task 9.');
61
+ const rest = card({ runners: [dev({ health: { state: 'alive', workingTaskId: null, holdUntil: new Date(NOW + 42 * 60e3).toISOString() } })], fence: fenceOf([2]) });
62
+ assert.match(rest.status.text, /^Resting until .+ · 42 min$/);
63
+ assert.equal(rest.lead.act, 'recall');
64
+ });
65
+
66
+ test('recalled here: it says so and offers to send it back', () => {
67
+ const c = card({ runners: [dev({ run_requested: false })], fence: fenceOf([2]) });
68
+ assert.equal(c.status.text, 'Recalled');
69
+ assert.equal(c.lead.act, 'send');
70
+ });
71
+
72
+ test('nothing done yet: no invented numbers; nothing ready: says so', () => {
73
+ const c = card({ runners: [dev()], fence: fenceOf([1]), stats: { tasks_done: 0, tokens: 0, avg_task_seconds: null, tasks_left: 0 } });
74
+ assert.deepEqual(c.lines, ['Nothing ready to build here right now.']);
75
+ const noPace = card({ runners: [dev()], fence: fenceOf([1]), stats: { tasks_done: 0, tokens: 0, avg_task_seconds: null, tasks_left: 4 } });
76
+ assert.deepEqual(noPace.lines, ['4 tasks ready'], 'no time is promised without a pace to measure it by');
77
+ });
78
+
79
+ test('the marker\'s state follows the device; several picks are not one tower', () => {
80
+ assert.equal(R.stateOf({ runners: [dev({ health: { state: 'alive', workingTaskId: '9' } })], fence: fenceOf([2]), nowMs: NOW }), 'working');
81
+ assert.equal(R.stateOf({ runners: [dev({ run_requested: false })], fence: fenceOf([2]), nowMs: NOW }), 'off');
82
+ assert.equal(R.stateOf({ runners: [dev({ health: { state: 'alive', holdUntil: new Date(NOW + 60e3).toISOString() } })], fence: fenceOf([2]), nowMs: NOW }), 'resting');
83
+ assert.equal(R.assignedGoal(fenceOf([2])), '2');
84
+ assert.equal(R.assignedGoal(fenceOf([1, 2])), null);
85
+ });
86
+
87
+ test('numbers read like a person would say them', () => {
88
+ assert.equal(R.tokensText(4820000), '4.8M tokens');
89
+ assert.equal(R.tokensText(340400), '340k tokens');
90
+ assert.equal(R.aboutText(5 * 2280), 'about 3 h');
91
+ assert.equal(R.aboutText(1200), 'about 20 min');
92
+ assert.equal(R.aboutText(3 * 86400), 'about 3 days');
93
+ assert.equal(R.aboutText(0), '');
94
+ });
95
+
96
+ test('source: the scene paints the pick, the visor holds every button (ADR 0403), the writes are Settings\' routes', () => {
97
+ const src = read('home-runner.js');
98
+ assert.match(src, /OTBVisor\.focus/, 'the card and its button are the visor\'s');
99
+ assert.doesNotMatch(src, /createElement\(['"]button/, 'no button is drawn in the scene');
100
+ assert.match(src, /scope\.may_run === true/, 'only a viewer the server says may run');
101
+ assert.match(src, /dataset\.game === 'on'/, 'game mode only: in classic a tower stays a link');
102
+ for (const route of ["'PUT', '/autonomy/me/goals'", '/autonomy/runners/${encodeURIComponent(r.host)}/run', '/autonomy/me/goals/${encodeURIComponent(picked)}/stats']) assert.ok(src.includes(route), route);
103
+ const draw = read('home-city-draw.js');
104
+ assert.match(draw, /'data-goal': a\.id/, 'every tower names its goal');
105
+ assert.match(draw, /hc-tower--picked/);
106
+ const html = read('index.html');
107
+ const at = (f) => html.indexOf(`/builders/${f}`);
108
+ assert.ok(at('runner-button.js') > 0 && at('runner-button.js') < at('home-runner.js') && at('home-runner.js') < at('home-city.js'), 'runner-button.js, then home-runner.js, before home-city.js');
109
+ });
110
+
111
+ test('source: the marker moves only in CSS, and the hall\'s reduced-motion rule stills it', () => {
112
+ const css = read('home-city.css');
113
+ assert.match(css, /\.hc-runner--working \.hc-runner__ring \{[^}]*animation: hc-turn/);
114
+ assert.doesNotMatch(read('home-runner.js'), /requestAnimationFrame|setInterval/, 'no motion in script');
115
+ });