@homie-rocks/studio 0.35.0 → 0.36.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 (45) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/agents/agents.ts +8 -6
  3. package/bin/homie-studio.mjs +9 -6
  4. package/lab/lab.ts +15 -0
  5. package/lib/build.mjs +20 -15
  6. package/lib/dev.mjs +16 -1
  7. package/lib/lab-check.mjs +18 -3
  8. package/lib/lab.mjs +21 -7
  9. package/lib/mcp-tools.mjs +2 -1
  10. package/lib/rules-build.mjs +97 -49
  11. package/lib/rules-check-worker.mjs +405 -0
  12. package/lib/rules-check.mjs +69 -0
  13. package/lib/rules-compiler.mjs +30 -0
  14. package/lib/rules-guard.mjs +26 -7
  15. package/lib/rules-types.mjs +167 -0
  16. package/lib/shop-catalog.mjs +20 -1
  17. package/lib/shop-links.mjs +3 -1
  18. package/lib/shop.mjs +14 -8
  19. package/lib/standalone-files.mjs +2 -2
  20. package/lib/standalone.mjs +6 -2
  21. package/lib/stripe-connect.mjs +18 -7
  22. package/lib/template-history.json +83 -0
  23. package/lib/typecheck.mjs +52 -3
  24. package/netplay/NETPLAY.md +123 -6
  25. package/netplay/netplay.ts +289 -53
  26. package/netplay/rules-output.mjs +85 -0
  27. package/package.json +3 -2
  28. package/rules/browser.ts +100 -0
  29. package/rules/core.ts +159 -58
  30. package/rules/guard.ts +11 -4
  31. package/rules/host.ts +90 -15
  32. package/rules/math.ts +16 -0
  33. package/rules/pack.ts +94 -17
  34. package/rules/rules.ts +21 -13
  35. package/rules/types.ts +63 -0
  36. package/rules/view.ts +36 -33
  37. package/standalone/shell/shell.js +8 -6
  38. package/worker/brain.mjs +2 -1
  39. package/worker/hosted.mjs +1 -1
  40. package/worker/index.mjs +7 -11
  41. package/worker/limits.mjs +41 -0
  42. package/worker/pages.mjs +4 -2
  43. package/worker/room.mjs +136 -63
  44. package/worker/stripe.mjs +2 -2
  45. package/worker/version.mjs +1 -1
@@ -2,33 +2,36 @@
2
2
  * What `homie-studio build` does for a game written as rules plus view (rooms-milestone-1-design.md, section 8).
3
3
  *
4
4
  * A rules game is games/<id>/src/rules.ts, move.ts and view.ts, with game.json ("entry": "src/view.ts"),
5
- * tunables.json and map/. Its build makes two things:
5
+ * tunables.json and map/. Its build makes the view and the guarded rules:
6
6
  *
7
7
  * the view's bundle view.ts and move.ts, for the browser, as every game's bundle is made (lib/build.mjs). The
8
8
  * move code in it is the guarded, counted module the server runs, not the source; none of
9
- * the rules' own code is in it (rules.ts is left out whole), only their declarations as data.
10
- * the rules' module rules.ts and move.ts as ONE JavaScript module, types removed and guards added
9
+ * the rules' handlers enter the view itself, only their declarations as data.
10
+ * the offline module the guarded rules, host runtime and all tunables, fetched after playable when offline is
11
+ * enabled. Browser-hosted games import it eagerly; private server games do not ship it.
12
+ * the server module rules.ts and move.ts as ONE JavaScript module, types removed and guards added
11
13
  * (lib/rules-guard.mjs), written to site/src/rules/<id>.mjs with the game's tunables, map and
12
14
  * settings beside it (<id>.data.mjs) and a table of every such game (index.mjs). The studio's
13
15
  * site/src/worker.mjs imports that table, so the rules reach the Table in the Worker's own
14
16
  * bundle. These files are build output that is committed with the studio, like wrangler.jsonc:
15
17
  * the Worker cannot be bundled without them.
16
18
  *
17
- * Before anything is written the rules are loaded in Node, with no Cloudflare at all: compiled against the contract
18
- * (rules/rules.ts `compileRules`), then run for three seconds of the room's clock with bots and one seated player.
19
- * A declaration that does not fit, a handler that throws or runs out its budget in those seconds, or a tick that takes
20
- * longer on this computer's own clock than the room's period (`smokeRun`), stops the build with its name. (The full
21
- * build check, with generated runs compared tick by tick, is a later release's.)
19
+ * Before anything is written, the toolkit's own compiler checks rules, movement and view against the game's
20
+ * declarations (lib/typecheck.mjs, on every build), and the guarded module is played with generated input in a worker
21
+ * thread (lib/rules-check.mjs). The module that is played is the module that is written.
22
22
  */
23
23
  import { createHash } from 'node:crypto';
24
24
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import { pathToFileURL } from 'node:url';
27
27
  import { GUARD_MODULE, RULES_MODULE, guardRules, guardedPlugin, problemLine } from './rules-guard.mjs';
28
- import { checkDecide } from '../worker/brain.mjs';
28
+ import { capOf, RULES_FRAMES, rulesRates } from '../worker/limits.mjs';
29
29
  import { PACKAGE_ROOT, isRulesGame, rulesIndex } from './studio.mjs';
30
+ import { ALLOWANCE, LONG_ALLOWANCE, runRulesWorker } from './rules-check.mjs';
31
+ import { typecheckRules } from './typecheck.mjs';
30
32
 
31
33
  export { isRulesGame };
34
+ export { stateHash } from './rules-check.mjs';
32
35
 
33
36
  const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
34
37
  const RULES_FILES = { [RULES_MODULE]: 'rules.ts', [GUARD_MODULE]: 'guard.ts', [`${RULES_MODULE}/view`]: 'view.ts', [`${RULES_MODULE}/host`]: 'host.ts' };
@@ -58,33 +61,56 @@ function readMap(g) {
58
61
  * `W` (guard.ts, the wall), `P` (pack.ts) and `M` (math.ts). They are one instance together, apart from any other
59
62
  * copy of the runtime in this process, so the guard's budget counter is theirs alone.
60
63
  */
61
- export async function loadRules(esbuild, root, id, code) {
64
+ export async function loadRules(esbuild, root, id, code, { bundleOnly = false } = {}) {
62
65
  const dir = join(root, '.studio', 'rules');
63
66
  mkdirSync(dir, { recursive: true });
64
- const gen = join(dir, `${id}.linked.mjs`);
67
+ const gen = join(dir, `${id}-${process.pid}-${++loads}.linked.mjs`);
65
68
  writeFileSync(gen, code);
66
69
  const lib = (name) => JSON.stringify(join(PACKAGE_ROOT, 'rules', name));
67
70
  const entry = `import def from ${JSON.stringify(gen)};\nimport * as R from ${lib('rules.ts')};\nimport * as H from ${lib('host.ts')};\nimport * as C from ${lib('core.ts')};\nimport * as W from ${lib('guard.ts')};\nimport * as P from ${lib('pack.ts')};\nimport * as M from ${lib('math.ts')};\nexport { def, R, H, C, W, P, M };\n`;
68
- const res = await esbuild.build({ stdin: { contents: entry, resolveDir: root, loader: 'js' }, bundle: true, format: 'esm', target: 'es2022', platform: 'neutral', write: false, logLevel: 'silent', plugins: [rulesModulesPlugin()] });
71
+ try {
72
+ const res = await esbuild.build({ stdin: { contents: entry, resolveDir: root, loader: 'js' }, bundle: true, format: bundleOnly ? 'cjs' : 'esm', target: 'es2022', platform: 'neutral', write: false, logLevel: 'silent', plugins: [rulesModulesPlugin()] });
69
73
  const text = res.outputFiles[0].text;
74
+ if (bundleOnly) { rmSync(gen, { force: true }); return text; }
70
75
  const out = join(dir, `${id}.check-${createHash('sha256').update(text).digest('hex').slice(0, 12)}-${process.pid}-${(loads += 1)}.mjs`);
71
76
  writeFileSync(out, text);
72
- try { return await import(pathToFileURL(out).href); } finally { rmSync(out, { force: true }); rmSync(gen, { force: true }); }
77
+ try { return await import(pathToFileURL(out).href); } finally { rmSync(out, { force: true }); }
78
+ } catch (error) {
79
+ if (/No matching export.*default/s.test(error.message)) throw new Error(`games/${id}/src/rules.ts: the rules need a default export. Use export default defineRules({ ... }).`);
80
+ throw error;
81
+ } finally { rmSync(gen, { force: true }); }
73
82
  }
74
83
  let loads = 0;
75
84
 
76
85
  /**
77
- * Three seconds of the room's clock with bots and one seated player, on a clock of the build's own.
86
+ * Three seconds of the room's clock with bots and one seated player, on a clock of the caller's own.
78
87
  *
79
- * Every tick is also timed on this computer's real clock (`timer`, milliseconds). A tick has one period of the room's
80
- * clock to run in. One slow tick on a busy computer proves nothing, so the line is three ticks of the run over the
81
- * period, or one tick over four periods: either stops the build, naming the handler that used the most.
88
+ * Every tick is also timed (`timer`, milliseconds). A tick has one period of the room's clock to run in. One slow tick
89
+ * on a busy computer proves nothing, so the line is three ticks of the run over the period, or one tick over four
90
+ * periods: either throws, naming the handler that used the most.
91
+ *
92
+ * The build calls this with a constant timer solely to measure browser frame sizes and rates. Real CPU timing is
93
+ * only for manual measurements: what it would decide depends on the computer and on what else that computer is doing,
94
+ * and a build's verdict may not (lib/rules-check.mjs plays the game instead, and counts in budget units).
82
95
  */
83
- export function smokeRun(H, compiled, id, { timer = () => performance.now(), vocab = null } = {}) {
84
- let now = 0;
85
- const host = H.createHost({ game: id, compiled, check: true, send() {}, clock: { now: () => now, setTimer: () => 0, clearTimer() {} }, random: () => 0.5 });
96
+ export function smokeRun(H, compiled, id, { timer = () => { const t = process.cpuUsage(); return (t.user + t.system) / 1000; }, vocab = null } = {}) {
97
+ let now = 1_000_000_000_000;
98
+ let control = [];
99
+ let snapshotBytes = 0; let checkpointBytes = 0;
100
+ const frameBytes = {}; const frameRates = {}; const sentAt = {};
101
+ const measure = (m, text) => {
102
+ if (!RULES_FRAMES.includes(m.t)) return;
103
+ frameBytes[m.t] = Math.max(frameBytes[m.t] ?? 0, Buffer.byteLength(JSON.stringify({ ...m, rules: true })));
104
+ const times = (sentAt[m.t] ??= []);
105
+ // The large synthetic clock loses a fraction of a millisecond at sixty ticks per second.
106
+ while (times.length && now - times[0] >= 1000 - 0.1) times.shift();
107
+ times.push(now);
108
+ frameRates[m.t] = Math.max(frameRates[m.t] ?? 0, times.length);
109
+ if (m.t === 'snap') { snapshotBytes = frameBytes.snap; control = m.c.map(([seat, r, ack]) => [seat, r, 1, ack]); }
110
+ };
111
+ const host = H.createHost({ game: id, compiled, check: true, send: measure, clock: { now: () => now, setTimer: () => 0, clearTimer() {} }, random: () => 0.5 });
86
112
  if (vocab) { host.frame({ t: 'vocabulary', vocab }); host.frame({ t: 'policy', policy: { kind: 'beginner', guides: 1, aiSeats: 0, bots: 'fill', brain: 'script' } }); }
87
- host.frame({ t: 'join', peer: { id: 'build', seat: 0, name: 'Build', occ: 1 } });
113
+ if (typeof compiled.join === 'function') host.frame({ t: 'join', peer: { id: 'build', seat: 0, name: 'Build', occ: 1 } });
88
114
  const period = 1000 / compiled.settings.tickHz;
89
115
  let slowest = 0; let slowestTick = 0; let over = 0;
90
116
  for (let i = 0; i < 3 * compiled.settings.tickHz; i += 1) {
@@ -92,6 +118,7 @@ export function smokeRun(H, compiled, id, { timer = () => performance.now(), voc
92
118
  const t0 = timer();
93
119
  host.tickNow();
94
120
  const ms = timer() - t0;
121
+ checkpointBytes = Math.max(checkpointBytes, Buffer.byteLength(JSON.stringify({ t: 'ckpt', rules: true, k: host.tick, st: now, c: control, d: { rules: '0000000000000000', data: JSON.parse(new TextDecoder().decode(host.save())) } })));
95
122
  if (ms > slowest) { slowest = ms; slowestTick = host.tick; }
96
123
  if (ms > period) over += 1;
97
124
  }
@@ -101,40 +128,50 @@ export function smokeRun(H, compiled, id, { timer = () => performance.now(), voc
101
128
  if (facts.faults) throw new Error(`games/${id}: its rules ran for three seconds with bots and the runtime itself failed ${facts.faults} ${facts.faults === 1 ? 'time' : 'times'}. The last: ${facts.lastFault}`);
102
129
  if (s.errors) throw new Error(`games/${id}: its rules ran for three seconds with bots and ${s.errors} ${s.errors === 1 ? 'handler' : 'handlers'} failed. The last: ${s.lastError}`);
103
130
  if (over >= 3 || slowest > 4 * period) throw new Error(`games/${id}: its rules ran for three seconds with bots and were too slow: tick ${slowestTick} took ${Math.round(slowest)} ms${over > 1 ? `, and ${over} ticks took longer than a tick lasts` : ''}. A tick lasts ${Math.round(period)} ms at ${compiled.settings.tickHz} ticks a second. The handler that used the most: ${s.worst || 'none'} (${s.maxUnits} budget units); the busiest tick used ${s.maxTickUnits} of ${compiled.settings.budget.tick}`);
104
- return { ...s, slowestMs: slowest };
105
- }
106
-
107
- /** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
108
- export function stateHash(c) {
109
- const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats } = c;
110
- return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, tickHz: c.settings.tickHz }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
131
+ return { ...s, slowestMs: slowest, snapshotBytes, checkpointBytes, frameBytes: { ...frameBytes, ckpt: checkpointBytes }, frameRates };
111
132
  }
112
133
 
113
134
  /**
114
135
  * Everything a rules game's build needs, or an Error that says what to fix (each refused line named).
115
- * Returns { code, tune, map, settings, seats, schema, publicTune, build, files, rounds, problems, units, tickUnits, slowestMs }.
136
+ * Returns { code, tune, map, settings, seats, stateHash, schema, publicTune, build, files, rounds, units, tickUnits, check }.
137
+ * `longCheck`: eight times the generated play (`homie-studio build --long-check`).
116
138
  */
117
- export async function prepareRules(esbuild, root, g, { log = () => {} } = {}) {
139
+ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false } = {}) {
118
140
  const guarded = await guardRules(esbuild, root, g.dir);
119
141
  if (!guarded.ok) throw new Error(`games/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
120
142
  const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
121
143
  const map = readMap(g);
122
- const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
123
- const { settings, problems } = R.roomSettings(g.room);
124
- for (const p of problems) log(`warning: games/${g.id}/game.json: ${p}`);
125
- if (settings.host === 'browser') throw new Error(`games/${g.id}/game.json asks for "room": { "host": "browser" }. Rules hosted by a player's browser arrive in a later release; until then a rules game is hosted by the server ("host": "server", the default).`);
126
144
  const seats = Math.max(1, Math.min(32, Math.floor(Number(g.players?.max)) || 8));
127
- for (const [name, ask] of Object.entries(def.asks ?? {})) {
128
- const checked = checkDecide({}, ask.questions);
129
- if (!checked.ok) throw new Error(`games/${g.id}/src/rules.ts: asks.${name}.questions: ${checked.why}`);
145
+ const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true });
146
+ const data = { id: g.id, tune, map, room: g.room, seats, vocab: readJson(join(g.dir, 'agents.json')), sites: guarded.sites };
147
+ // The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
148
+ await typecheckRules(root, g, await runRulesWorker(bundle, data, { declarationsOnly: true }), tune, { log });
149
+ const checked = await runRulesWorker(bundle, data, { allowance: longCheck ? LONG_ALLOWANCE : ALLOWANCE });
150
+ for (const p of checked.problems) log(`warning: games/${g.id}/game.json: ${p}`);
151
+ for (const line of checked.information) log(`info: ${line}`);
152
+ const { settings, schema, publicTune, rounds, stateHash } = checked;
153
+ const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
154
+ const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
155
+ const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
156
+ const snapshotCap = capOf('snap', seats); const checkpointCap = capOf('ckpt', seats);
157
+ const exceeded = RULES_FRAMES.flatMap(t => {
158
+ const bytes = stats.frameBytes[t] ?? 0; const cap = capOf(t, seats);
159
+ const rate = stats.frameRates[t] ?? 0; const rateCap = rulesRates(settings.tickHz, seats)[t];
160
+ return [...(bytes > cap ? [`${t} ${bytes} B (cap ${cap} B; over by ${bytes - cap} B)`] : []),
161
+ ...(t !== 'snap' && rate > rateCap ? [`${t} ${rate}/s (cap ${rateCap}/s; over by ${rate - rateCap}/s)`] : [])];
162
+ });
163
+ if (exceeded.length) {
164
+ const message = `games/${g.id}: browser rules smoke run measured snapshot ${stats.snapshotBytes} B (cap ${snapshotCap} B), checkpoint ${stats.checkpointBytes} B (cap ${checkpointCap} B). Exceeded: ${exceeded.join(', ')}. Reduce these frames or use room.host: server; offline play has no relay caps.`;
165
+ if (settings.host === 'browser') throw new Error(message);
166
+ log(`warning: ${message}`);
130
167
  }
131
- let compiled;
132
- try { compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats }); } catch (error) { throw new Error(`games/${g.id}/src/rules.ts: ${error.message}`); }
133
- const stats = smokeRun(H, compiled, g.id, { vocab: readJson(join(g.dir, 'agents.json')) });
134
- const data = { tune, map, settings, seats };
168
+ const built = { tune, map, settings, seats };
135
169
  return {
136
- code: guarded.code, ...data, stateHash: stateHash(compiled), schema: R.schemaOf(compiled), publicTune: compiled.publicTune, files: guarded.files, rounds: compiled.rounds,
137
- build: createHash('sha256').update(guarded.code).update(JSON.stringify(data)).digest('hex'), units: stats.maxUnits, tickUnits: stats.maxTickUnits, slowestMs: stats.slowestMs,
170
+ code: guarded.code, ...built, stateHash, schema, publicTune, files: guarded.files, rounds,
171
+ build: createHash('sha256').update(guarded.code).update(JSON.stringify(built)).digest('hex'),
172
+ snapshotCap, checkpointCap, snapshotBytes: stats.snapshotBytes, checkpointBytes: stats.checkpointBytes,
173
+ units: checked.maxUnits, tickUnits: checked.maxTickUnits,
174
+ check: { information: checked.information, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
138
175
  };
139
176
  }
140
177
 
@@ -143,17 +180,28 @@ export async function prepareRules(esbuild, root, g, { log = () => {} } = {}) {
143
180
  * declarations and its guarded move code handed to the view library (`setGame`), then the game's own view.ts. So
144
181
  * `openRoom()` in view.ts needs no argument, and `move` in the bundle is the module the server runs.
145
182
  */
146
- export function viewPlugin(g, rules, entry) {
183
+ export function viewPlugin(g, rules, entry, { lab = false } = {}) {
147
184
  const move = join(g.dir, 'src', 'move.ts');
148
- const game = { id: g.id, schema: rules.schema, tune: rules.publicTune, map: rules.map, vocab: readJson(join(g.dir, 'agents.json')) };
185
+ const game = { id: g.id, schema: lab ? { ...rules.schema, settings: { ...rules.settings, offline: true } } : rules.schema, tune: rules.publicTune, map: rules.map, vocab: readJson(join(g.dir, 'agents.json')) };
186
+ const local = lab || rules.settings.host === 'browser';
187
+ const available = local || rules.settings.offline;
188
+ const factory = local ? `import { makeHost } from 'homie:host';\nconst loadHost = () => Promise.resolve(makeHost);` : available ? `const loadHost = () => import('homie:host').then(m => m.makeHost);` : 'const loadHost = undefined;';
149
189
  const guarded = guardedPlugin(rules.files, { stub: [join(g.dir, 'src', 'rules.ts')] });
150
190
  return {
151
191
  name: 'homie-rules-view',
152
192
  setup(b) {
153
- b.onResolve({ filter: /^homie:(?:view|game)$/ }, (a) => ({ path: a.path, namespace: 'homie-view' }));
154
- b.onLoad({ filter: /.*/, namespace: 'homie-view' }, (a) => (a.path === 'homie:view'
193
+ if (!lab) {
194
+ b.onResolve({ filter: /lab\/lab\.ts$/ }, () => ({ path: 'production-lab', namespace: 'homie-no-lab' }));
195
+ b.onLoad({ filter: /.*/, namespace: 'homie-no-lab' }, () => ({ contents: 'export const lab = { rulesTune: x => x, stage: null };', loader: 'js' }));
196
+ }
197
+ b.onResolve({ filter: /^homie:(?:view|game|host|rules)$/ }, (a) => ({ path: a.path, namespace: 'homie-view' }));
198
+ b.onLoad({ filter: /.*/, namespace: 'homie-view' }, (a) => {
199
+ if (a.path === 'homie:rules') return { contents: rules.code, resolveDir: g.dir, loader: 'js' };
200
+ if (a.path === 'homie:host') return { contents: `import def from 'homie:rules';\nimport { browserHost } from ${JSON.stringify(join(PACKAGE_ROOT, 'rules', 'browser.ts'))};\nexport const makeHost = browserHost(def, ${JSON.stringify({ id: g.id, build: rules.build, tune: rules.tune, map: rules.map, settings: rules.settings, seats: rules.seats, vocab: game.vocab })});\n`, resolveDir: g.dir, loader: 'js' };
201
+ return (a.path === 'homie:view'
155
202
  ? { contents: `import 'homie:game';\nimport ${JSON.stringify(entry)};\n`, resolveDir: g.dir, loader: 'js' }
156
- : { contents: `${existsSync(move) ? `import { move } from ${JSON.stringify(move)};` : 'const move = {};'}\nimport { setGame${game.vocab ? ', setAgentFactory' : ''} } from '${RULES_MODULE}/view';\n${game.vocab ? `import { useAgents } from ${JSON.stringify(join(PACKAGE_ROOT, 'agents/agents.ts'))}; setAgentFactory(useAgents);` : ''}\nsetGame(${JSON.stringify(game)}, move);\n`, resolveDir: g.dir, loader: 'js' }));
203
+ : { contents: `${existsSync(move) ? `import { move } from ${JSON.stringify(move)};` : 'const move = {};'}\nimport { setGame${game.vocab ? ', setAgentFactory' : ''} } from '${RULES_MODULE}/view';\n${game.vocab ? `import { useAgents } from ${JSON.stringify(join(PACKAGE_ROOT, 'agents/agents.ts'))}; setAgentFactory(useAgents);` : ''}\n${factory}\nsetGame(${JSON.stringify(game)}, move, loadHost);\n`, resolveDir: g.dir, loader: 'js' });
204
+ });
157
205
  rulesModulesPlugin().setup(b);
158
206
  guarded.setup(b);
159
207
  },
@@ -170,7 +218,7 @@ const HEADER = '// Written by `homie-studio build` from this studio\'s games. Do
170
218
  export function writeRules(root, built, ids) {
171
219
  const dir = join(root, 'site', 'src', 'rules');
172
220
  mkdirSync(dir, { recursive: true });
173
- for (const r of built) {
221
+ for (const r of built.filter((r) => r.settings.host === 'server')) {
174
222
  writeFileSync(join(dir, `${r.id}.mjs`), `${HEADER}// The rules of games/${r.id}, checked and guarded (build ${r.build}).\n${r.code}`);
175
223
  writeFileSync(join(dir, `${r.id}.data.mjs`), `${HEADER}export default ${JSON.stringify({ tune: r.tune, map: r.map, settings: r.settings, seats: r.seats, build: r.build, stateHash: r.stateHash }, null, 2)};\n`);
176
224
  }