@bongos/core 1.25.29 → 1.25.31

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.
@@ -631,6 +631,12 @@ async function attachAsks(matters, readAsks, timeoutMs) {
631
631
  // task 1004705) and the docket says `routed: true`. null routes nothing.
632
632
  async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS, decisions = null, government = null, readAsks = null, readFlags = null, seats = null } = {}) {
633
633
  const held = new Set(permissions || []);
634
+ // `decisions`, `government` and `seats` may arrive as promises (docketFor starts
635
+ // those reads alongside the sources, task 1005310); they are awaited once the
636
+ // sources have answered. The handler is attached here, before anything waits, so
637
+ // a failure is never an unhandled rejection while the sources are reading.
638
+ const governmentReads = Promise.all([decisions, government, seats]);
639
+ governmentReads.catch(() => {});
634
640
  const ctx = Object.freeze({
635
641
  builderId: String(viewer.id),
636
642
  rank: viewer.rank || null,
@@ -681,8 +687,11 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
681
687
  const order = declared.map((d) => d.source);
682
688
  sourceStatus.sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source));
683
689
  matters.sort(compareMatters);
684
- await attachAsks(matters, readAsks, timeoutMs);
685
- await attachFlags(matters, readFlags, timeoutMs);
690
+ // Asks and reword flags are two independent reads on the same matters, each
691
+ // writing its own field: read together (task 1005310), alongside the
692
+ // government reads started above.
693
+ await Promise.all([attachAsks(matters, readAsks, timeoutMs), attachFlags(matters, readFlags, timeoutMs)]);
694
+ [decisions, government, seats] = await governmentReads;
686
695
  // THE DIRECTION DESK (task 1004705): who each matter waits on, from real seats.
687
696
  const yours = seats ? routing.routeAll(matters, ctx.builderId, seats, now) : null;
688
697
 
@@ -872,16 +881,38 @@ async function findMatterFor(builder, matterId) {
872
881
 
873
882
  // The route's entry point: resolve the viewer's permissions server-side (fresh,
874
883
  // uncached — ADR 0016), then build.
875
- async function docketFor(builder) {
884
+ //
885
+ // ONE BUILD PER VIEWER AT A TIME (task 1005310). The street asks for the docket
886
+ // and for GET /government/counts in the same breath, and counts is built from
887
+ // this same docket, so every street load built it twice, side by side, and each
888
+ // slowed the other. A second ask for the SAME viewer that arrives while a build
889
+ // is running shares that build; the moment it finishes nothing is kept, so the
890
+ // next ask builds afresh. Nothing is cached: no answer is older than the request
891
+ // that receives it, and permissions are still resolved inside every build.
892
+ const building = new Map();
893
+ function docketFor(builder) {
894
+ const key = String(builder && builder.id);
895
+ const running = building.get(key);
896
+ if (running) return running;
897
+ const p = buildDocketFor(builder).finally(() => { building.delete(key); });
898
+ building.set(key, p);
899
+ return p;
900
+ }
901
+ async function buildDocketFor(builder) {
876
902
  const permissions = await resolver.resolveBuilderPermissions(builder.id);
877
903
  // The table is read per request, uncached (ADR 0016). A failed read leaves
878
904
  // every building's decision null; it never fails the city.
879
- const decisions = await board.decisionRulesView().catch((err) => {
905
+ // The government reads (decision rules → the government → its seats) run
906
+ // ALONGSIDE the sources (task 1005310): buildDocket waits for them only once
907
+ // its sources have answered. Every step below catches its own failure.
908
+ const decisions = board.decisionRulesView().catch((err) => {
880
909
  log.warn({ err: err && err.message }, 'governor docket: decision rules unreadable');
881
910
  return null;
882
911
  });
883
- const government = permissions.includes(GOVERNMENT_GATE.permission) ? await loadGovernment({ decisions }) : null;
884
- const seats = government ? await loadSeats(government) : null;
912
+ const government = permissions.includes(GOVERNMENT_GATE.permission)
913
+ ? decisions.then((d) => loadGovernment({ decisions: d }))
914
+ : Promise.resolve(null);
915
+ const seats = government.then((g) => (g ? loadSeats(g) : null));
885
916
  return buildDocket({ viewer: builder, permissions, decisions, government, readAsks: asksDb.asksFor, readFlags: asksDb.unclearFor, seats });
886
917
  }
887
918
 
@@ -56,7 +56,7 @@
56
56
  leads: '/api/bongos/government/leads',
57
57
  // the pots each goal draws from, the reserve, the spend cap (task 1004712)
58
58
  treasury: '/api/bongos/government/treasury',
59
- claims: '/api/bongos/tasks/claimable?limit=1',
59
+ claims: '/api/bongos/tasks/claimable?view=claims',
60
60
  tasks: '/api/bongos/tasks?status=active&limit=200',
61
61
  cost: '/api/bongos/public/cost-summary',
62
62
  roster: '/api/bongos/builders/roster',
@@ -265,6 +265,10 @@
265
265
  return false;
266
266
  }
267
267
  function homeHref(mode) {
268
+ // In game mode the street is not a place (shell-game.js would send it back
269
+ // home), so the patron's home there is Home itself (task 1005307).
270
+ var g = window.OTBShellGame;
271
+ if (g && typeof g.on === 'function' && g.on() === true) return '/';
268
272
  var street = mode === 'patron' && window.GovCity && window.GovCity.STREET_HREF;
269
273
  return street || '/';
270
274
  }
@@ -42,8 +42,8 @@
42
42
  presets: '/government/presets',
43
43
  goals: '/goals',
44
44
  rollups: '/goals/rollups',
45
- // the active claims ride on the claimable feed; one claimable row is plenty
46
- claims: '/tasks/claimable?limit=1',
45
+ // the open claims alone (?view=claims, task 1005310): the claimable feed is not built
46
+ claims: '/tasks/claimable?view=claims',
47
47
  tasks: '/tasks?status=active&limit=200',
48
48
  cost: '/public/cost-summary',
49
49
  roster: '/builders/roster',
@@ -355,9 +355,17 @@
355
355
  }
356
356
 
357
357
  // ---- the governor's Home is the street; tests swap the hop for a recorder ----
358
+ // Classic view only (task 1005307): in game mode the street is not a place, so
359
+ // shell-game.js sends it straight back here — hopping from Home made an endless
360
+ // reload loop. The game's patron stays on the city and walks on from the visor.
361
+ // shell-game.js is the one answer: a page without it is never in game mode.
362
+ function gameOn() {
363
+ const g = window.OTBShellGame;
364
+ return !!(g && typeof g.on === 'function' && g.on() === true);
365
+ }
358
366
  function streetHop() {
359
367
  const href = window.GovCity && window.GovCity.STREET_HREF;
360
- if (document.documentElement.dataset.craft !== 'patron' || !href) return false;
368
+ if (document.documentElement.dataset.craft !== 'patron' || !href || gameOn()) return false;
361
369
  (window.OTBHomeStreetHop || ((h) => window.location.assign(h)))(href);
362
370
  return true;
363
371
  }
@@ -219,7 +219,7 @@ module.exports = function buildTasksRouter() {
219
219
  // RETURNED feed, never the query — lifecycle/CLAUDE.md has the why.
220
220
  const claimNeeds = parseNeedsFilter(req.query.needs); if (claimNeeds && claimNeeds.bad) return badNeedsFilter(res); // task 1004950
221
221
  const claimLimit = posInt(req.query.limit, 'bad_limit', 1000);
222
- if (claimLimit && claimLimit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer (max 1000).' });
222
+ if (claimLimit && claimLimit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer (max 1000).' }); if (req.query.view === 'claims') return res.json({ active_claims: await db.listActiveClaims() }); // open claims only, no claimable feed: the street needs just these (task 1005310)
223
223
  const [feed, active] = await Promise.all([
224
224
  db.listClaimableTasks({
225
225
  versionId: version || null, builderId: req.builder.id,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.29",
3
+ "version": "1.25.31",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.29",
9
+ "version": "1.25.31",
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.29",
3
+ "version": "1.25.31",
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",
@@ -11949,5 +11949,17 @@
11949
11949
  "id": "1005299",
11950
11950
  "text": "The city's sky clock no longer says \"hour 24\" in the last three minutes before midnight, and its check no longer fails at random late at night."
11951
11951
  }
11952
+ ],
11953
+ "1.25.30": [
11954
+ {
11955
+ "id": "1005307",
11956
+ "text": "Choosing Patron in game mode no longer reloads the hall endlessly; it settles on the city."
11957
+ }
11958
+ ],
11959
+ "1.25.31": [
11960
+ {
11961
+ "id": "1005310",
11962
+ "text": "The governor's street asks the server for less and gets its answers sooner."
11963
+ }
11952
11964
  ]
11953
11965
  }
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.29'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.31'; // 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');
@@ -31,7 +31,7 @@ function el() {
31
31
  }
32
32
 
33
33
  // One page: `page` is body[data-page], `active` the nav item the shell marked.
34
- function boot({ page, active = null, govCity = false }) {
34
+ function boot({ page, active = null, govCity = false, game = false }) {
35
35
  const hops = [];
36
36
  const body = el();
37
37
  body.setAttribute('data-page', page);
@@ -55,6 +55,7 @@ function boot({ page, active = null, govCity = false }) {
55
55
  location: { search: '' },
56
56
  };
57
57
  if (govCity) window.GovCity = { STREET_HREF: '/builders/street' };
58
+ if (game) window.OTBShellGame = { on: () => true };
58
59
  window.window = window;
59
60
  vm.runInNewContext(CRAFT, { window, document, CustomEvent: function () {}, Promise, console });
60
61
  return { CM: window.OTBCraftMode, hops };
@@ -83,6 +84,16 @@ test('choosing Patron on another craft\'s page lands on the street', () => {
83
84
  assert.equal(boot({ page: 'studio' }).CM.homeHref('patron'), '/');
84
85
  });
85
86
 
87
+ // task 1005307: in game mode the street is not a place (shell-game.js sends it back
88
+ // Home, and Home sent a patron to the street again: an endless reload loop), so a
89
+ // stranded page goes straight Home.
90
+ test('in game mode, choosing Patron on another craft\'s page goes Home, never the street', () => {
91
+ const { CM, hops } = boot({ page: 'studio', active: 'studio', govCity: true, game: true });
92
+ assert.equal(CM.homeHref('patron'), '/');
93
+ CM.choose('patron');
94
+ assert.deepEqual(hops, ['/']);
95
+ });
96
+
86
97
  test('a page the chosen craft also shows stays put', () => {
87
98
  // goals is in Artisan and Artist alike
88
99
  const { CM, hops } = boot({ page: 'goals', active: 'goals' });
@@ -0,0 +1,99 @@
1
+ // tests/docket_load_speed.mjs — the governor's street loads faster (task 1005310).
2
+ //
3
+ // Measured on the live hub: the street asked for GET /government/docket and
4
+ // GET /government/counts together, counts rebuilt the same docket, and each build
5
+ // waited for the government reads before it even started its sources. Three
6
+ // properties now hold, and each is driven here against the real docket.js:
7
+ // 1. two asks for the SAME viewer while a build runs share that one build, and
8
+ // nothing is kept once it finishes (no cache: the next ask builds afresh);
9
+ // 2. the sources start reading before the government reads have answered;
10
+ // 3. a government read that fails is never an unhandled rejection while the
11
+ // sources are still reading, and the docket still answers or fails as before.
12
+ // And the street's claims read (GET /tasks/claimable?view=claims) answers only the
13
+ // open claims, without building the claimable feed.
14
+ import { strict as assert } from 'node:assert';
15
+ import { test, after } from 'node:test';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { createRequire } from 'node:module';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const require = createRequire(import.meta.url);
23
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
24
+ const docket = require(path.join(ROOT, 'modules', 'government', 'docket.js'));
25
+ const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
26
+ const resolver = require(path.join(ROOT, 'modules', 'government', 'resolver.js'));
27
+ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
28
+
29
+ const REAL = { perms: resolver.resolveBuilderPermissions, rules: board.decisionRulesView };
30
+ after(() => { resolver.resolveBuilderPermissions = REAL.perms; board.decisionRulesView = REAL.rules; });
31
+
32
+ const order = [];
33
+ let permReads = 0;
34
+ // The docket's own atom only: no government gate, so no government tables are read.
35
+ resolver.resolveBuilderPermissions = async () => { permReads += 1; return ['board.vote.cast']; };
36
+ board.decisionRulesView = () => new Promise((r) => setTimeout(() => { order.push('decisions'); r(null); }, 40));
37
+ api.contribute(docket.CONTRIBUTION_POINT, {
38
+ source: 'blockers', building: 'planning', gate: { anyBuilder: true },
39
+ read: async () => { order.push('source'); return []; },
40
+ });
41
+
42
+ test('two asks for one viewer while a build runs share it; the next ask builds afresh', async () => {
43
+ permReads = 0;
44
+ const viewer = { id: 7001, rank: 'metic' };
45
+ const [a, b] = await Promise.all([docket.docketFor(viewer), docket.docketFor({ ...viewer })]);
46
+ assert.equal(permReads, 1, 'one build for both asks');
47
+ assert.equal(a, b, 'both asks receive the same answer');
48
+ await docket.docketFor(viewer);
49
+ assert.equal(permReads, 2, 'a finished build is not kept: the next ask builds again');
50
+ await Promise.all([docket.docketFor({ id: 7002 }), docket.docketFor({ id: 7003 })]);
51
+ assert.equal(permReads, 4, 'different viewers never share a build');
52
+ });
53
+
54
+ test('the sources start reading before the government reads answer', async () => {
55
+ order.length = 0;
56
+ await docket.docketFor({ id: 7004, rank: 'metic' });
57
+ assert.deepEqual(order, ['source', 'decisions']);
58
+ });
59
+
60
+ const VIEWER = { id: 42, rank: 'metic' };
61
+ const NOW = Date.parse('2026-11-14T12:00:00Z');
62
+ const slowSource = (ms) => ({
63
+ sources: new Map([['blockers', { source: 'blockers', building: 'planning', gate: { anyBuilder: true },
64
+ read: () => new Promise((r) => setTimeout(() => r([{ id: 1, title: 'Pick a host', state: 'needs', rule: 'decide' }]), ms)) }]]),
65
+ readings: new Map(), floors: new Map(), histories: new Map(), desks: new Map(),
66
+ });
67
+
68
+ test('a government read handed in as a promise answers exactly as its value does', async () => {
69
+ const plain = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: slowSource(1), now: NOW, decisions: [], government: null, seats: null });
70
+ const later = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: slowSource(1), now: NOW,
71
+ decisions: Promise.resolve([]), government: Promise.resolve(null), seats: Promise.resolve(null) });
72
+ assert.deepEqual(later, plain);
73
+ });
74
+
75
+ test('a failed government read is never an unhandled rejection while the sources read', async () => {
76
+ const unhandled = [];
77
+ const onUnhandled = (e) => unhandled.push(e);
78
+ process.on('unhandledRejection', onUnhandled);
79
+ try {
80
+ const boom = new Error('government unreadable');
81
+ await assert.rejects(docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: slowSource(30), now: NOW,
82
+ decisions: Promise.resolve(null), government: Promise.reject(boom), seats: Promise.resolve(null) }), /government unreadable/);
83
+ await new Promise((r) => setTimeout(r, 20));
84
+ assert.deepEqual(unhandled, []);
85
+ } finally {
86
+ process.off('unhandledRejection', onUnhandled);
87
+ }
88
+ });
89
+
90
+ test('the claims read answers the open claims without building the claimable feed', () => {
91
+ const src = read('modules/lifecycle/routes/tasks.js');
92
+ const at = src.indexOf("req.query.view === 'claims'");
93
+ assert.ok(at > 0, 'GET /tasks/claimable has a claims-only view');
94
+ assert.ok(at < src.indexOf('db.listClaimableTasks({', at - 2000), 'it answers before the claimable feed is built');
95
+ assert.match(src.slice(at, at + 200), /active_claims: await db\.listActiveClaims\(\)/);
96
+ for (const f of ['modules/hall-ui/public/gov-feeds.js', 'modules/hall-ui/public/city.js']) {
97
+ assert.match(read(f), /tasks\/claimable\?view=claims/, `${f} asks for the claims alone`);
98
+ }
99
+ });
@@ -488,7 +488,7 @@ async function bootLiving({ asks = false, answer = null } = {}) {
488
488
  const feeds = {
489
489
  '/api/bongos/government/docket': asks ? withAsks(docketRouted()) : docketRouted(), '/api/bongos/goals': fx('goals'), '/api/bongos/goals/rollups': fx('goals__rollups'),
490
490
  '/api/bongos/government/leads': leadsRead(), '/api/bongos/government/treasury': fx('government__treasury'),
491
- '/api/bongos/government/constitution': fx('government__constitution'), '/api/bongos/tasks/claimable?limit=1': fx('tasks__claimable'),
491
+ '/api/bongos/government/constitution': fx('government__constitution'), '/api/bongos/tasks/claimable?view=claims': fx('tasks__claimable'),
492
492
  '/api/bongos/tasks?status=active&limit=200': fx('tasks__status-active'), '/api/bongos/public/cost-summary': fx('public__cost-summary'),
493
493
  '/api/bongos/builders/roster': fx('builders__roster'), '/api/bongos/me': fx('me'), '/api/bongos/blockers?limit=100': fx('blockers'),
494
494
  '/api/bongos/autonomy/fence': fx('autonomy__fence'), '/api/bongos/moving': fx('moving'),
@@ -172,7 +172,7 @@ test('in the harness COUNT_FEEDS, with the docket the page already read, give th
172
172
  assert.equal(L.countsOf(m, s).on_others, L.countsOf(m).on_others - 1);
173
173
  // and nothing the shell reads is a task list
174
174
  assert.ok(!F.COUNT_FEEDS.includes('tasks') && !F.COUNT_FEEDS.includes('goals'), 'the tally reads no task or goal list');
175
- assert.match(F.FEEDS.claims, /limit=1\b/, 'the claims ride the claimable feed at one row');
175
+ assert.match(F.FEEDS.claims, /[?&]view=claims\b/, 'the claims ride the claims-only view of the claimable route (task 1005310)');
176
176
  });
177
177
 
178
178
  // ── 3. every surface prints the same numbers ────────────────────────────────
@@ -197,7 +197,7 @@ const FEED = {
197
197
  '/api/bongos/government/docket': 'government__docket', '/api/bongos/government/counts': 'government__counts', '/api/bongos/goals': 'goals', '/api/bongos/goals/rollups': 'goals__rollups',
198
198
  '/api/bongos/me': 'me', '/api/bongos/builders/roster': 'builders__roster', '/api/bongos/blockers': 'blockers',
199
199
  '/api/bongos/public/cost-summary': 'public__cost-summary', '/api/bongos/tasks/claimable': 'tasks__claimable',
200
- '/api/bongos/tasks/claimable?limit=1': 'tasks__claimable', '/api/bongos/tasks?status=active&limit=200': 'tasks__status-active',
200
+ '/api/bongos/tasks/claimable?view=claims': 'tasks__claimable', '/api/bongos/tasks?status=active&limit=200': 'tasks__status-active',
201
201
  '/api/bongos/blockers?limit=100': 'blockers', '/api/bongos/moving': 'moving', '/api/bongos/autonomy/fence': 'autonomy__fence',
202
202
  '/api/bongos/government/constitution': 'government__constitution',
203
203
  // the hub's law book, as the route answers it (no law passed yet)
@@ -411,7 +411,7 @@ const FEED = {
411
411
  '/api/bongos/me': 'me', '/api/bongos/builders/roster': 'builders__roster',
412
412
  '/api/bongos/blockers': 'blockers', '/api/bongos/public/cost-summary': 'public__cost-summary',
413
413
  // the living model's reads, as gov-feeds.js names them (one read each per page)
414
- '/api/bongos/tasks/claimable?limit=1': 'tasks__claimable', '/api/bongos/tasks?status=active&limit=200': 'tasks__status-active',
414
+ '/api/bongos/tasks/claimable?view=claims': 'tasks__claimable', '/api/bongos/tasks?status=active&limit=200': 'tasks__status-active',
415
415
  '/api/bongos/blockers?limit=100': 'blockers', '/api/bongos/moving': 'moving', '/api/bongos/autonomy/fence': 'autonomy__fence',
416
416
  '/api/bongos/government/constitution': 'government__constitution', '/api/bongos/government/leads': 'government__leads',
417
417
  };
@@ -471,7 +471,7 @@ async function bootPage({ withLiving = true } = {}) {
471
471
  const F = {
472
472
  '/api/bongos/government/docket': docketRouted(), '/api/bongos/goals': fx('goals'), '/api/bongos/goals/rollups': fx('goals__rollups'),
473
473
  '/api/bongos/government/leads': leadsRead(),
474
- '/api/bongos/tasks/claimable?limit=1': fx('tasks__claimable'), '/api/bongos/tasks?status=active': fx('tasks__status-active'),
474
+ '/api/bongos/tasks/claimable?view=claims': fx('tasks__claimable'), '/api/bongos/tasks?status=active': fx('tasks__status-active'),
475
475
  '/api/bongos/public/cost-summary': fx('public__cost-summary'), '/api/bongos/builders/roster': fx('builders__roster'), '/api/bongos/me': fx('me'),
476
476
  // the living model's reads, as gov-feeds.js names them
477
477
  '/api/bongos/tasks?status=active&limit=200': fx('tasks__status-active'), '/api/bongos/blockers?limit=100': fx('blockers'),
@@ -493,7 +493,7 @@ test('the page draws the living street: the skyline, a pod per claim, the plates
493
493
  const p = await bootPage();
494
494
  const all = (id) => walk(p.byId.get(id));
495
495
  const cls = (id, c) => all(id).filter((e) => (e.className || '').split(' ').includes(c));
496
- for (const u of ['/api/bongos/tasks/claimable?limit=1', '/api/bongos/government/leads']) assert.ok(p.calls.includes(u), u);
496
+ for (const u of ['/api/bongos/tasks/claimable?view=claims', '/api/bongos/government/leads']) assert.ok(p.calls.includes(u), u);
497
497
  assert.ok(!p.calls.some((u) => /governor-overlay/.test(u)), 'no overlay is read');
498
498
  assert.equal(p.calls.filter((u) => u === '/api/bongos/me').length, 1, 'the living feeds are read once');
499
499
  assert.equal(cls('streetDepthNear', 'street-depth-sil__face').length, 1);
@@ -48,12 +48,13 @@ const stubs = (status, craft) => `
48
48
  window.OTBCraftMode = { iconHtml: (k, cls) => '<svg class="' + cls + '" data-icon="' + k + '"></svg>', choose: (m) => window.__chosen.push(m), current: () => window.__craft };
49
49
  window.OTB = { contributeHallWidget(w) { window.__widget = w; } };`;
50
50
 
51
- async function boot({ status = 200, width = 1280, still = true, craft = null } = {}) {
51
+ async function boot({ status = 200, width = 1280, still = true, craft = null, game = false } = {}) {
52
52
  const ctx = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: still ? 'reduce' : 'no-preference' });
53
53
  const page = await ctx.newPage();
54
54
  await page.clock.install();
55
55
  await page.setContent(`<!doctype html><html><head><style>${CSS}</style></head><body><section id="home-city" class="scroll home-city" hidden></section></body></html>`);
56
56
  await page.addScriptTag({ content: stubs(status, craft) });
57
+ if (game) await page.addScriptTag({ content: 'window.OTBShellGame = { on: () => true };' });
57
58
  await page.addScriptTag({ content: GOV });
58
59
  await page.addScriptTag({ content: DRAW });
59
60
  await page.addScriptTag({ content: WIDGET });
@@ -184,6 +185,16 @@ test('the governor\'s Home is the street: Patron on Home goes there, opened or c
184
185
  await ctx.close();
185
186
  });
186
187
 
188
+ // task 1005307: in game mode the street is not a place — shell-game.js sends it back to
189
+ // Home — so hopping there from Home was an endless reload loop. The game's patron stays.
190
+ test('in game mode the patron stays on Home: no hop to the street, opened or chosen', { skip }, async () => {
191
+ const { ctx, page } = await boot({ craft: 'patron', game: true });
192
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
193
+ const flipped = await page.evaluate(() => { document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: 'patron' } })); return window.__hops.slice(); });
194
+ await ctx.close();
195
+ assert.deepEqual(flipped, [], 'Home with Patron in game mode builds the city and never leaves for the street');
196
+ });
197
+
187
198
  test('a refused read leaves the section hidden: never an empty plate', { skip }, async () => {
188
199
  const { ctx, page } = await boot({ status: 401 });
189
200
  await page.evaluate(() => new Promise((r) => setTimeout(r, 0)));