@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.
- package/.bongos-core.json +143 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0405-each-character-has-one-colour-and-the-visor-wears-it.md +31 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +49 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +12 -12
- package/docs/copy-registry.json +12 -12
- package/docs/design/characters/v2/artisan-visor.png +0 -0
- package/docs/design/characters/v2/artisan.png +0 -0
- package/docs/design/characters/v2/artist-visor.png +0 -0
- package/docs/design/characters/v2/artist.png +0 -0
- package/docs/design/characters/v2/draw-characters.js +465 -0
- package/docs/design/characters/v2/patron-visor.png +0 -0
- package/docs/design/characters/v2/patron.png +0 -0
- package/docs/design/characters/v2/sheet.html +114 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +35 -35
- package/modules/autonomy/db.js +48 -0
- package/modules/autonomy/routes/autonomy.js +15 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/characters/artisan-face.png +0 -0
- package/modules/hall-ui/public/characters/artisan-visor.png +0 -0
- package/modules/hall-ui/public/characters/artisan.png +0 -0
- package/modules/hall-ui/public/characters/artist-face.png +0 -0
- package/modules/hall-ui/public/characters/artist-visor.png +0 -0
- package/modules/hall-ui/public/characters/artist.png +0 -0
- package/modules/hall-ui/public/characters/patron-face.png +0 -0
- package/modules/hall-ui/public/characters/patron-visor.png +0 -0
- package/modules/hall-ui/public/characters/patron.png +0 -0
- package/modules/hall-ui/public/craft-mode.js +15 -4
- package/modules/hall-ui/public/home-city-draw.js +24 -2
- package/modules/hall-ui/public/home-city.css +15 -0
- package/modules/hall-ui/public/home-city.js +3 -1
- package/modules/hall-ui/public/home-runner.js +248 -0
- package/modules/hall-ui/public/index.html +8 -5
- package/modules/hall-ui/public/style.css +18 -5
- package/modules/hall-ui/public/visor.css +47 -5
- package/modules/hall-ui/public/visor.js +71 -5
- package/modules/hall-ui/records/characters.md +19 -0
- package/modules/hall-ui/records/home-runner.md +19 -0
- package/modules/hall-ui/records/visor.md +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/autonomy_runner_goal_stats.mjs +92 -0
- package/tests/character_creation.mjs +44 -7
- package/tests/hall_visor.mjs +48 -2
- package/tests/home_runner.mjs +115 -0
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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');
|
package/tests/hall_visor.mjs
CHANGED
|
@@ -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
|
+
});
|