hbengine 0.2.3 → 0.4.0

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 (72) hide show
  1. package/engine-cli/hbengine.mjs +106 -36
  2. package/engine-cli/outage.mjs +9 -0
  3. package/engine-cli/templates.mjs +1 -0
  4. package/engine-cli/upload-errors.mjs +18 -0
  5. package/engine-cli/upload.mjs +172 -0
  6. package/engine-conformance/analyse.mjs +155 -15
  7. package/engine-conformance/bundle.mjs +23 -5
  8. package/engine-conformance/census-worker.mjs +9 -0
  9. package/engine-conformance/chrome-flags.mjs +19 -0
  10. package/engine-conformance/diagnostic-text.mjs +19 -0
  11. package/engine-conformance/diagnostics.mjs +52 -0
  12. package/engine-conformance/focus.mjs +42 -0
  13. package/engine-conformance/hints.mjs +86 -0
  14. package/engine-conformance/js-releases.json +2365 -0
  15. package/engine-conformance/js-releases.mjs +75 -0
  16. package/engine-conformance/run.mjs +226 -39
  17. package/engine-conformance/scan.mjs +71 -18
  18. package/engine-conformance/sound.mjs +45 -0
  19. package/engine-conformance/travel.mjs +132 -6
  20. package/engine-examples/keno/math-kit/pack.mjs +87 -10
  21. package/engine-examples/lines/math-kit/pack.mjs +87 -10
  22. package/engine-examples/plinko/math-kit/pack.mjs +87 -10
  23. package/engine-examples/plinko-jar/AGENTS.md +3 -0
  24. package/engine-examples/plinko-jar/README.md +16 -0
  25. package/engine-examples/plinko-jar/bundle/assets.json +1 -0
  26. package/engine-examples/plinko-jar/bundle/game.css +10 -0
  27. package/engine-examples/plinko-jar/bundle/game.js +113 -0
  28. package/engine-examples/plinko-jar/bundle/hbtest.json +4 -0
  29. package/engine-examples/plinko-jar/bundle/index.html +16 -0
  30. package/engine-examples/plinko-jar/bundle/model.json +1 -0
  31. package/engine-examples/plinko-jar/math/gen.mjs +47 -0
  32. package/engine-examples/plinko-jar/math/manifest.json +1 -0
  33. package/engine-examples/plinko-jar/math/plinko-jar-12-high.tsv +13 -0
  34. package/engine-examples/plinko-jar/math/plinko-jar-12-low.tsv +13 -0
  35. package/engine-examples/plinko-jar/math/plinko-jar-12-medium.tsv +13 -0
  36. package/engine-examples/plinko-jar/math/plinko-jar-16-high.tsv +17 -0
  37. package/engine-examples/plinko-jar/math/plinko-jar-16-low.tsv +17 -0
  38. package/engine-examples/plinko-jar/math/plinko-jar-16-medium.tsv +17 -0
  39. package/engine-examples/plinko-jar/math/plinko-jar-8-high.tsv +9 -0
  40. package/engine-examples/plinko-jar/math/plinko-jar-8-low.tsv +9 -0
  41. package/engine-examples/plinko-jar/math/plinko-jar-8-medium.tsv +9 -0
  42. package/engine-examples/plinko-jar/math/plinko-jar-meter-jar.tsv +5 -0
  43. package/engine-examples/plinko-jar/math-kit/pack.mjs +242 -0
  44. package/engine-examples/plinko-jar/math-kit/package.json +8 -0
  45. package/engine-examples/plinko-jar/math-kit/slot.mjs +94 -0
  46. package/engine-examples/plinko-jar/math-kit/tumble.mjs +94 -0
  47. package/engine-examples/tumble/math-kit/pack.mjs +87 -10
  48. package/engine-math/pack.mjs +87 -10
  49. package/engine-mcp/server.mjs +30 -14
  50. package/engine-sdk/GUIDE.md +234 -32
  51. package/engine-sdk/bet-field.js +16 -3
  52. package/engine-sdk/bet.js +88 -0
  53. package/engine-sdk/frames.js +75 -0
  54. package/engine-sdk/progress.js +128 -0
  55. package/engine-sdk/protocol.js +3 -0
  56. package/engine-sdk/sdk.d.ts +35 -0
  57. package/engine-sdk/sdk.js +9 -0
  58. package/engine-sdk/ui.js +3 -0
  59. package/engine-shell/core/rgs.js +1 -0
  60. package/engine-shell/core/router.js +46 -54
  61. package/engine-shell/host/audio-session.js +24 -0
  62. package/engine-shell/host/boot.js +5 -2
  63. package/engine-shell/host/engine-v1.js +17 -4
  64. package/engine-shell/host/fair.js +27 -0
  65. package/engine-shell/host/index.html +44 -9
  66. package/engine-shell/host/intro.js +99 -0
  67. package/engine-shell/host/page.js +25 -5
  68. package/engine-shell/host/sandbox-panel.js +23 -3
  69. package/engine-shell/host/sandbox-scenarios.js +23 -11
  70. package/engine-shell/host/site-bridge.js +4 -2
  71. package/engine-shell/verifiers/engine-verify.js +12 -3
  72. package/package.json +2 -1
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Every number here is an integer: weights are BigInt, RTP is a fraction.
12
12
  */
13
- import { mkdirSync, writeFileSync } from 'node:fs';
13
+ import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
 
16
16
  const MAX_SUM = 1n << 63n;
@@ -121,16 +121,55 @@ function ranges(ids) {
121
121
  return out;
122
122
  }
123
123
 
124
+ // The verifier's bound on one bet's pay tiers (housegames-backend mathpack.MaxTiers).
125
+ export const MAX_TIERS = 16384;
126
+
127
+ // The most a pack's maths may be: the files in math/ together, as their bytes
128
+ // on disk. Housebets' limit, not a verifier's: the live engine holds a game's
129
+ // tables in memory, and the upload's checks slow as they grow. Checked here,
130
+ // by hbengine before it sends a pack, and by the upload's own check
131
+ // (pipeline/job), so no way in passes a bigger one. The Go side's figure is
132
+ // internal/archive MaxMathBytes (pack.test.mjs holds the two together).
133
+ export const MAX_MATH_BYTES = 200 * 1024 * 1024;
134
+
135
+ // The refusal of maths over MAX_MATH_BYTES, in the same words wherever it is
136
+ // checked; the size is rounded up, so maths just over never reads as 200 MB.
137
+ export const mathTooLarge = (bytes) => `The maths is ${Math.ceil((bytes * 10) / 1048576) / 10} MB; a pack's maths may be at most ${MAX_MATH_BYTES / 1048576} MB.`;
138
+
139
+ // The bytes of the maths in dir: its files together (math/ holds nothing below them).
140
+ export const mathBytes = (dir) => readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).reduce((n, e) => n + statSync(join(dir, e.name)).size, 0);
141
+
142
+ /**
143
+ * What a meter adds to the RTP of every bet of its modes, exactly (GUIDE:
144
+ * Meters): E[award] ÷ fill, in basis points of the stake. `rows` are the
145
+ * award's outcomes, `pay` the multiplier of the reference in bp.
146
+ */
147
+ export function meterRtp({ fill, rows }) {
148
+ const den = rows.reduce((s, r) => s + r.weight, 0n) * BigInt(fill);
149
+ const num = rows.reduce((s, r) => s + r.weight * BigInt(r.pay || 0), 0n);
150
+ return { num, den, bp: bp4(num, den) };
151
+ }
152
+
124
153
  /**
125
154
  * Writes manifest.json and one table per mode. A mode is { id, label?, rows,
126
- * bets: [id | {id, label}] }; its rows are sorted by what the first bet pays,
155
+ * bets: [id | {id, label}], meter? }; its rows are sorted by what the first bet pays,
127
156
  * so each pay tier is one range. Prints every bet's exact RTP, hit rate and
128
157
  * largest multiplier; returns them.
158
+ *
159
+ * meters: [{ id, label?, fill, rows }] — progress that pays (GUIDE: Meters).
160
+ * A meter's rows are its award's outcomes, each { weight, pay, data? } with
161
+ * `pay` in bp of the meter's reference (20000 = twice the reference); a mode
162
+ * names the meter its rounds fill with `meter: id`. Every bet of such a mode
163
+ * reports base + meter RTP, and the base must clear 90% on its own.
129
164
  */
130
- export function writePack(dir, { game, rtpBp, modes }) {
131
- mkdirSync(dir, { recursive: true });
165
+ export function writePack(dir, { game, rtpBp, modes, meters = [] }) {
166
+ if (!Array.isArray(modes) || modes.length < 1 || modes.length > 64) throw new Error('a pack needs 1 to 64 modes');
167
+ for (const mode of modes) {
168
+ if (!Array.isArray(mode.bets) || mode.bets.length < 1 || mode.bets.length > 256) throw new Error(`${mode.id}: 1 to 256 bets per mode`);
169
+ }
132
170
  const manifest = { format: 'house-engine-math/1', game, rtpBp, modes: [] };
133
171
  const report = [];
172
+ const tables = new Map();
134
173
  for (const mode of modes) {
135
174
  const bets = mode.bets.map((b) => (typeof b === 'string' ? { id: b } : b));
136
175
  const first = bets[0].id;
@@ -141,25 +180,63 @@ export function writePack(dir, { game, rtpBp, modes }) {
141
180
  const lines = rows.map((r) => {
142
181
  if (r.weight <= 0n) throw new Error(`${mode.id}: a weight is not positive`);
143
182
  const json = r.data === undefined ? '' : JSON.stringify(r.data);
144
- if (json.length > 4096) throw new Error(`${mode.id}: a row's data is over 4096 bytes`);
183
+ if (Buffer.byteLength(json) > 4096) throw new Error(`${mode.id}: a row's data is over 4096 UTF-8 bytes`);
145
184
  return json ? `${r.weight}\t${json}` : String(r.weight);
146
185
  });
147
186
  const table = `${game}-${mode.id}.tsv`;
148
- writeFileSync(join(dir, table), lines.join('\n') + '\n');
187
+ tables.set(table, lines.join('\n') + '\n');
149
188
  manifest.modes.push({
150
- id: mode.id, ...(mode.label ? { label: mode.label } : {}), kind: 'table', table,
189
+ id: mode.id, ...(mode.label ? { label: mode.label } : {}), kind: 'table', table, ...(mode.meter ? { meter: mode.meter } : {}),
151
190
  bets: bets.map((b) => {
152
191
  const tiers = new Map();
153
192
  rows.forEach((r, i) => { const m = r.pay[b.id] || 0; if (m > 0) (tiers.get(m) || tiers.set(m, []).get(m)).push(i); });
154
193
  const { num, den, bp } = rtp(rows, b.id);
155
194
  const hit = rows.reduce((s, r) => s + (r.pay[b.id] > 0 ? r.weight : 0n), 0n);
195
+ // The engine's verifier refuses a bet with more pay tiers than this (one tier per distinct
196
+ // multiplier): refused here, at the build, not at the upload (mathpack.MaxTiers).
197
+ if (tiers.size > MAX_TIERS) throw new Error(`${mode.id}: bet "${b.id}": ${tiers.size} distinct payouts (tiers), at most ${MAX_TIERS}: round the multipliers to fewer values`);
156
198
  const max = Math.max(...tiers.keys());
157
- report.push({ mode: mode.id, bet: b.id, rtp: bp, hit: bp4(hit * 10000n, den), max: max / 10000, rows: rows.length, exact: num % den === 0n });
199
+ const entry = { mode: mode.id, bet: b.id, rtp: bp, hit: bp4(hit * 10000n, den), max: max / 10000, rows: rows.length, exact: num % den === 0n };
200
+ if (mode.meter) {
201
+ const mt = meters.find((x) => x.id === mode.meter);
202
+ if (!mt) throw new Error(`${mode.id}: meter "${mode.meter}" is not in meters`);
203
+ const add = meterRtp(mt);
204
+ // base + E[award]/fill, as one exact fraction
205
+ const tn = num * add.den + add.num * den, td = den * add.den;
206
+ Object.assign(entry, { baseRtp: bp, meter: mode.meter, rtp: bp4(tn, td), exact: tn % td === 0n });
207
+ }
208
+ report.push(entry);
158
209
  return { id: b.id, ...(b.label ? { label: b.label } : {}), tiers: [...tiers].map(([multBp, ids]) => ({ outcomes: ranges(ids), multBp })) };
159
210
  }),
160
211
  });
161
212
  }
162
- writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest) + '\n');
163
- for (const r of report) console.log(`${r.mode}/${r.bet}: RTP ${r.rtp} bp hit ${r.hit} bp max ${r.max}× rows ${r.rows}`);
213
+ for (const mt of meters) {
214
+ if (!Number.isSafeInteger(mt.fill) || mt.fill < 2 || mt.fill > 1_000_000) throw new Error(`meter ${mt.id}: fill must be 2 to 1,000,000`);
215
+ if (!modes.some((m) => m.meter === mt.id)) throw new Error(`meter ${mt.id}: no mode fills it`);
216
+ const rows = [...mt.rows].sort((a, b) => (a.pay || 0) - (b.pay || 0));
217
+ if (rows.length < 1) throw new Error(`meter ${mt.id}: an award needs rows`);
218
+ const table = `${game}-meter-${mt.id}.tsv`;
219
+ tables.set(table, rows.map((r) => {
220
+ if (r.weight <= 0n) throw new Error(`meter ${mt.id}: a weight is not positive`);
221
+ const json = r.data === undefined ? '' : JSON.stringify(r.data);
222
+ return json ? `${r.weight}\t${json}` : String(r.weight);
223
+ }).join('\n') + '\n');
224
+ const tiers = new Map();
225
+ rows.forEach((r, i) => { const m = r.pay || 0; if (m > 0) (tiers.get(m) || tiers.set(m, []).get(m)).push(i); });
226
+ if (!tiers.size) throw new Error(`meter ${mt.id}: the award never pays`);
227
+ (manifest.meters ||= []).push({ id: mt.id, ...(mt.label ? { label: mt.label } : {}), fill: mt.fill, table,
228
+ award: [...tiers].map(([multBp, ids]) => ({ outcomes: ranges(ids), multBp })) });
229
+ const ev = meterRtp({ fill: 1, rows });
230
+ console.log(`meter ${mt.id}: fill ${mt.fill} award E ${bp4(ev.num, ev.den * 10000n)}× the reference adds ${meterRtp({ fill: mt.fill, rows }).bp} bp`);
231
+ }
232
+ tables.set('manifest.json', JSON.stringify(manifest) + '\n');
233
+ // The pack as it will be on disk, with what else is in dir already (the
234
+ // generator, often), measured before a file is written.
235
+ mkdirSync(dir, { recursive: true });
236
+ const others = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && !tables.has(e.name));
237
+ const bytes = [...tables.values()].reduce((n, text) => n + Buffer.byteLength(text), 0) + others.reduce((n, e) => n + statSync(join(dir, e.name)).size, 0);
238
+ if (bytes > MAX_MATH_BYTES) throw new Error(mathTooLarge(bytes));
239
+ for (const [name, text] of tables) writeFileSync(join(dir, name), text);
240
+ for (const r of report) console.log(`${r.mode}/${r.bet}: RTP ${r.rtp} bp${r.meter ? ` (base ${r.baseRtp} + meter ${r.meter})` : ''} hit ${r.hit} bp max ${r.max}× rows ${r.rows}`);
164
241
  return report;
165
242
  }
@@ -4,7 +4,8 @@
4
4
  * (Claude Code, Cursor, …) builds, verifies and ships a studio game without
5
5
  * leaving its editor. Stdio JSON-RPC, the Model Context Protocol 2024-11-05
6
6
  * subset every client speaks: initialize, tools/list, tools/call. No
7
- * dependencies: each tool runs hbengine and returns what it printed.
7
+ * dependencies: each tool runs hbengine and returns what it printed. Uploads
8
+ * use hbengine's direct-to-storage session flow through that same CLI path.
8
9
  *
9
10
  * { "mcpServers": { "house-engine": { "command": "node", "args": ["…/engine-mcp/server.mjs"],
10
11
  * "env": { "HBENGINE_KEY": "hbk_…" } } } }
@@ -16,9 +17,9 @@
16
17
  * run, handle and serve.
17
18
  */
18
19
  import { spawnSync } from 'node:child_process';
19
- import { readFileSync } from 'node:fs';
20
+ import { readFileSync, realpathSync } from 'node:fs';
20
21
  import { createInterface } from 'node:readline';
21
- import { pathToFileURL } from 'node:url';
22
+ import { fileURLToPath, pathToFileURL } from 'node:url';
22
23
  import { TEMPLATES } from '../engine-cli/templates.mjs';
23
24
 
24
25
 
@@ -34,8 +35,8 @@ const VERSION = (() => {
34
35
  }
35
36
  return 'dev';
36
37
  })();
37
- const HBENGINE = new URL('../engine-cli/hbengine.mjs', import.meta.url).pathname;
38
- const GUIDE = new URL('../engine-sdk/GUIDE.md', import.meta.url).pathname;
38
+ const HBENGINE = fileURLToPath(new URL('../engine-cli/hbengine.mjs', import.meta.url));
39
+ const GUIDE = fileURLToPath(new URL('../engine-sdk/GUIDE.md', import.meta.url));
39
40
  const DIR = { type: 'string', description: 'the project directory (math/, bundle/, hbengine.json)' };
40
41
  // The most a reply to the reviewer may say (the studio platform refuses more).
41
42
  export const REPLY_MAX = 4000;
@@ -49,11 +50,11 @@ export const TOOLS = [
49
50
  { name: 'engine_init', description: 'Start a game from a template: math/, bundle/, hbengine.json in a new directory.', inputSchema: { type: 'object', properties: { dir: { type: 'string', description: 'new directory' }, template: { type: 'string', enum: Object.keys(TEMPLATES) }, slug: { type: 'string', description: 'the game slug, a-z 0-9 -, 2–24 chars' } }, required: ['dir', 'slug'] } },
50
51
  { name: 'engine_verify', description: 'Verify the math pack with the engine’s own verifier: exact RTP, hit rate and max multiplier per bet, the mathHash; or the errors.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
51
52
  { name: 'engine_lint', description: 'Scan the bundle as the upload will: budgets, assets.json, forbidden code, outside URLs, engine use.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
52
- { name: 'engine_test', description: 'Offline: the upload\'s checks (boot, idle, fit at desktop and phone sizes and fullscreen, a bet through the game\'s own UI, currency, errors, network) in Chrome against engine-dev, the engine on this machine. Needs Chrome and playwright-core. About two minutes.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
53
- { name: 'engine_pack', description: 'Verify and lint, then write dist/<slug>-<date>.tgz (math, bundle, store page, tile) and its sha256: the file to hand to Housebets.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
54
- { name: 'engine_upload', description: 'Ship a version: verify + lint, upload to the portal, which registers it with the sandbox and plays the conformance run on desktop and phone (bet, currency, outage, fit, errors, network). Returns the report and, on success, the link to play it inside the site. Takes about two minutes.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
53
+ { name: 'engine_test', description: 'Offline: the upload\'s checks (boot, idle, fit at desktop and phone sizes and fullscreen, a bet through the game\'s own UI, currency, errors, network) in Chrome against engine-dev, the engine on this machine. Needs Chrome and playwright-core. About two minutes; with timeTravel, also the fake-clock matrix the upload plays (the clock moved days and years, other zones and languages, a 30-minute jump after a round, 300 fast rounds): about ten.', inputSchema: { type: 'object', properties: { dir: DIR, timeTravel: { type: 'boolean', description: 'Also play the time-travel matrix, as the upload does.' } }, required: ['dir'] } },
54
+ { name: 'engine_pack', description: 'Verify and lint, then write dist/<slug>-<date>.tgz (math and bundle; the store page goes apart) and its sha256: the file to hand to Housebets.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
55
+ { name: 'engine_upload', description: 'Ship a version: verify + lint, upload directly to storage through the portal session, which registers it with the sandbox and plays the conformance run on desktop and phone (bet, currency, outage, fit, errors, network), then the time-travel matrix (other dates, zones and languages, 300 fast rounds). Returns the report and, on success, the link to play it inside the site. Takes several minutes: about two for desktop and phone, and up to nine more for the matrix; engine_status shows the state of a run still going.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
55
56
  { name: 'engine_status', description: 'Every version of the game and its state (uploaded, verified, sandbox, failed), its review (in review, approved, returned) and which is live, and the game\'s state on the studio platform.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
56
- { name: 'engine_store', description: 'Send the store page from store.json in the project (title, category, description, features, tags, languages, rules per language; tile.png or tile.webp, 3:4, if present). Answers what review still needs. On a live game the tile stays the reviewed one until Housebets reset it; the page is saved all the same. Needs HBENGINE_KEY.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
57
+ { name: 'engine_store', description: 'Send the store page from store.json in the project (title, category, description, features, tags, languages, rules per language). Answers what review still needs. Housebets makes the tile from the game’s art when a version goes live. Needs HBENGINE_KEY.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
57
58
  { name: 'engine_submit', description: 'Submit the newest version that passed every check for review by Housebets. Needs the studio agreement (accepted by an owner in the dashboard), a complete store page and HBENGINE_KEY. Housebets emails the verdict; once approved, engine_live puts it live.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
58
59
  { name: 'engine_live', description: 'Put the newest approved version live, as an owner pressing Make live on the game\'s page: the round server and the front end switch to it within seconds. Only after Housebets approved it (engine_status shows "approved"; the studio gets an email). Going live is an owner\'s: the key must be one an owner of the studio made. The first time, players find the game on Housebets once Housebets switches its catalogue page on. Needs HBENGINE_KEY.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
59
60
  { name: 'engine_preflight', description: 'Before engine_submit: the review checklist items a machine can check on the newest version (the RTP and max win the rules quote against the verified maths, rules in every store language, the store page, fit on phone and desktop, recovery from errors, load, the network and the scan), each pass / fail with the reason, and the exact facts the rules must quote. Fix every failure first; a reviewer would send it back. Needs HBENGINE_KEY.', inputSchema: { type: 'object', properties: { dir: DIR }, required: ['dir'] } },
@@ -64,6 +65,11 @@ export const TOOLS = [
64
65
  { name: 'engine_tickets', description: 'The studio\'s support tickets and Housebets\' answers: each open one with its whole conversation. Answer one with engine_feedback and its ticket id. Needs HBENGINE_KEY.', inputSchema: { type: 'object', properties: {} } },
65
66
  ];
66
67
 
68
+ // What an agent is told when it connects: where to start.
69
+ const INSTRUCTIONS = 'House Engine tools. Building a game: read engine_guide first. '
70
+ + 'Games may keep progress per player: engine.progress saves cosmetic progress (skins, collections, settings) on the server for every device and version (guide §3.1), '
71
+ + 'and a meter is progress that pays — a jar or potion that fills with the stakes and pays a bonus when full, kept and verified by the engine (guide §2.2; template plinko-jar).';
72
+
67
73
  export function run(tool, a) {
68
74
  if (tool === 'engine_templates') return { text: Object.entries(TEMPLATES).map(([t, about]) => `${t}: ${about}`).join('\n'), status: 0 };
69
75
  if (tool === 'engine_connect') {
@@ -83,8 +89,9 @@ export function run(tool, a) {
83
89
  }
84
90
  const args = tool === 'engine_init' ? ['init', a.dir, '--template', a.template || 'coin', '--slug', a.slug]
85
91
  : tool === 'engine_reply' ? ['reply', a.dir, '--thread', String(a.thread), '--message', String(a.message || ''), ...(a.fixed ? ['--fixed'] : [])]
92
+ : tool === 'engine_test' && a.timeTravel ? ['test', a.dir, '--time-travel']
86
93
  : [tool.slice('engine_'.length), a.dir];
87
- const r = spawnSync('node', [HBENGINE, ...args], { encoding: 'utf8', timeout: 10 * 60 * 1000 });
94
+ const r = spawnSync('node', [HBENGINE, ...args], { encoding: 'utf8', timeout: (tool === 'engine_test' && a.timeTravel ? 20 : 10) * 60 * 1000 });
88
95
  // hbengine did not run at all (not found, killed at the timeout): say so, not "nullnull".
89
96
  if (r.error) return { text: `hbengine did not run: ${r.error.message}`, status: 1 };
90
97
  return { text: ((r.stdout || '') + (r.stderr || '')).trim() || (r.status === 0 ? 'ok' : `exit ${r.status ?? 1}`), status: r.status ?? 1 };
@@ -110,13 +117,22 @@ function support(tool, a) {
110
117
 
111
118
  const error = (text) => ({ content: [{ type: 'text', text }], isError: true });
112
119
 
120
+ /** A server's request handler: its tools, what runs them, its name and its first words to an agent. */
121
+ export function handler({ tools, run: runTool, name, instructions }) {
122
+ return (msg) => answer(msg, tools, runTool, name, instructions);
123
+ }
124
+
113
125
  /** One request's result (null for a notification, or for a line that is not a request), the same whatever the transport. */
114
126
  export function handle(msg) {
127
+ return answer(msg, TOOLS, run, 'house-engine', INSTRUCTIONS);
128
+ }
129
+
130
+ function answer(msg, TOOLS, run, name, INSTRUCTIONS) {
115
131
  if (!msg || typeof msg !== 'object') return null;
116
132
  const { id, method } = msg;
117
133
  // `params` may be missing, null or a scalar from a loose client: an empty one, never a crash.
118
134
  const params = msg.params && typeof msg.params === 'object' ? msg.params : {};
119
- if (method === 'initialize') return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'house-engine', version: VERSION } };
135
+ if (method === 'initialize') return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name, version: VERSION }, instructions: INSTRUCTIONS };
120
136
  if (method === 'tools/list') return { tools: TOOLS };
121
137
  if (method === 'tools/call') {
122
138
  const tool = TOOLS.find((t) => t.name === params.name);
@@ -134,13 +150,13 @@ export function handle(msg) {
134
150
 
135
151
  /** Serves JSON-RPC over a pair of streams (stdio by default). No line can end the server: a
136
152
  * request it cannot handle is answered with a JSON-RPC error, anything else is dropped. */
137
- export function serve({ input = process.stdin, output = process.stdout } = {}) {
153
+ export function serve({ input = process.stdin, output = process.stdout, handle: reply = handle } = {}) {
138
154
  const send = (body) => output.write(JSON.stringify({ jsonrpc: '2.0', ...body }) + '\n');
139
155
  createInterface({ input }).on('line', (line) => {
140
156
  let msg;
141
157
  try { msg = JSON.parse(line); } catch { return; }
142
158
  try {
143
- const result = handle(msg);
159
+ const result = reply(msg);
144
160
  if (result !== null) send({ id: msg.id, result });
145
161
  } catch (e) {
146
162
  if (msg && typeof msg === 'object' && msg.id !== undefined) send({ id: msg.id, error: { code: -32603, message: e && e.message ? e.message : String(e) } });
@@ -148,4 +164,4 @@ export function serve({ input = process.stdin, output = process.stdout } = {}) {
148
164
  });
149
165
  }
150
166
 
151
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) serve();
167
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) serve();