@homie-rocks/studio 0.32.0 → 0.33.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 (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,184 @@
1
+ /**
2
+ * What `homie-studio build` does for a game written as rules plus view (rooms-milestone-1-design.md, section 8).
3
+ *
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:
6
+ *
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
+ * 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
11
+ * (lib/rules-guard.mjs), written to site/src/rules/<id>.mjs with the game's tunables, map and
12
+ * settings beside it (<id>.data.mjs) and a table of every such game (index.mjs). The studio's
13
+ * site/src/worker.mjs imports that table, so the rules reach the Table in the Worker's own
14
+ * bundle. These files are build output that is committed with the studio, like wrangler.jsonc:
15
+ * the Worker cannot be bundled without them.
16
+ *
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.)
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { pathToFileURL } from 'node:url';
27
+ import { GUARD_MODULE, RULES_MODULE, guardRules, guardedPlugin, problemLine } from './rules-guard.mjs';
28
+ import { checkDecide } from '../worker/brain.mjs';
29
+ import { PACKAGE_ROOT, isRulesGame, rulesIndex } from './studio.mjs';
30
+
31
+ export { isRulesGame };
32
+
33
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
34
+ const RULES_FILES = { [RULES_MODULE]: 'rules.ts', [GUARD_MODULE]: 'guard.ts', [`${RULES_MODULE}/view`]: 'view.ts', [`${RULES_MODULE}/host`]: 'host.ts' };
35
+
36
+ /** An esbuild plugin: Homie's rules modules resolve to this package's own files, wherever the studio's node_modules puts it. */
37
+ export function rulesModulesPlugin() {
38
+ return {
39
+ name: 'homie-rules-modules',
40
+ setup(b) { b.onResolve({ filter: /^@homie-rocks\/studio\/rules(?:\/(?:guard|view|host))?$/ }, (a) => ({ path: join(PACKAGE_ROOT, 'rules', RULES_FILES[a.path]) })); },
41
+ };
42
+ }
43
+
44
+ /** The game's map: games/<id>/map/main.json, else the first map file by name. One file per map. */
45
+ function readMap(g) {
46
+ const dir = join(g.dir, 'map');
47
+ const names = existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.json')).sort() : [];
48
+ const name = names.includes('main.json') ? 'main.json' : names[0];
49
+ if (!name) throw new Error(`games/${g.id}/map/ needs a map: map/main.json with "bounds", and the "boxes", "circles" and named "spots" the rules use`);
50
+ const raw = readJson(join(dir, name));
51
+ if (!raw || typeof raw !== 'object') throw new Error(`games/${g.id}/map/${name} is not JSON`);
52
+ return { ...raw, name: name.replace(/\.json$/, '') };
53
+ }
54
+
55
+ /**
56
+ * The rules, loaded in Node: the linked module beside the runtime, as one file of its own that imports nothing.
57
+ * Returns the module (`def`) and the runtime it was linked with: `R` (rules.ts), `H` (host.ts), `C` (core.ts),
58
+ * `W` (guard.ts, the wall), `P` (pack.ts) and `M` (math.ts). They are one instance together, apart from any other
59
+ * copy of the runtime in this process, so the guard's budget counter is theirs alone.
60
+ */
61
+ export async function loadRules(esbuild, root, id, code) {
62
+ const dir = join(root, '.studio', 'rules');
63
+ mkdirSync(dir, { recursive: true });
64
+ const gen = join(dir, `${id}.linked.mjs`);
65
+ writeFileSync(gen, code);
66
+ const lib = (name) => JSON.stringify(join(PACKAGE_ROOT, 'rules', name));
67
+ 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()] });
69
+ const text = res.outputFiles[0].text;
70
+ const out = join(dir, `${id}.check-${createHash('sha256').update(text).digest('hex').slice(0, 12)}-${process.pid}-${(loads += 1)}.mjs`);
71
+ writeFileSync(out, text);
72
+ try { return await import(pathToFileURL(out).href); } finally { rmSync(out, { force: true }); rmSync(gen, { force: true }); }
73
+ }
74
+ let loads = 0;
75
+
76
+ /**
77
+ * Three seconds of the room's clock with bots and one seated player, on a clock of the build's own.
78
+ *
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.
82
+ */
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 });
86
+ 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 } });
88
+ const period = 1000 / compiled.settings.tickHz;
89
+ let slowest = 0; let slowestTick = 0; let over = 0;
90
+ for (let i = 0; i < 3 * compiled.settings.tickHz; i += 1) {
91
+ now += period;
92
+ const t0 = timer();
93
+ host.tickNow();
94
+ const ms = timer() - t0;
95
+ if (ms > slowest) { slowest = ms; slowestTick = host.tick; }
96
+ if (ms > period) over += 1;
97
+ }
98
+ const s = host.core.stats;
99
+ const facts = host.facts();
100
+ host.stop();
101
+ 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
+ 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
+ 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);
111
+ }
112
+
113
+ /**
114
+ * 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 }.
116
+ */
117
+ export async function prepareRules(esbuild, root, g, { log = () => {} } = {}) {
118
+ const guarded = await guardRules(esbuild, root, g.dir);
119
+ 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
+ const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
121
+ 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
+ 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}`);
130
+ }
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 };
135
+ 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,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * An esbuild plugin for a rules game's view bundle. The bundle's entry becomes two imports: first the game's
143
+ * declarations and its guarded move code handed to the view library (`setGame`), then the game's own view.ts. So
144
+ * `openRoom()` in view.ts needs no argument, and `move` in the bundle is the module the server runs.
145
+ */
146
+ export function viewPlugin(g, rules, entry) {
147
+ 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')) };
149
+ const guarded = guardedPlugin(rules.files, { stub: [join(g.dir, 'src', 'rules.ts')] });
150
+ return {
151
+ name: 'homie-rules-view',
152
+ 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'
155
+ ? { 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' }));
157
+ rulesModulesPlugin().setup(b);
158
+ guarded.setup(b);
159
+ },
160
+ };
161
+ }
162
+
163
+ const HEADER = '// Written by `homie-studio build` from this studio\'s games. Do not edit: the next build writes it again.\n';
164
+
165
+ /**
166
+ * site/src/rules/: one module and one data file per server-hosted game, and the table the Worker imports. `built` are
167
+ * the games this build made; a one-game build keeps the others' files. A game that is gone, or no longer a rules game,
168
+ * loses its files.
169
+ */
170
+ export function writeRules(root, built, ids) {
171
+ const dir = join(root, 'site', 'src', 'rules');
172
+ mkdirSync(dir, { recursive: true });
173
+ for (const r of built) {
174
+ writeFileSync(join(dir, `${r.id}.mjs`), `${HEADER}// The rules of games/${r.id}, checked and guarded (build ${r.build}).\n${r.code}`);
175
+ 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
+ }
177
+ const have = ids.filter((id) => existsSync(join(dir, `${id}.mjs`)) && existsSync(join(dir, `${id}.data.mjs`)));
178
+ for (const name of readdirSync(dir)) {
179
+ const id = /^(.+?)(?:\.data)?\.mjs$/.exec(name)?.[1];
180
+ if (name !== 'index.mjs' && id && !have.includes(id)) rmSync(join(dir, name), { force: true });
181
+ }
182
+ writeFileSync(join(dir, 'index.mjs'), rulesIndex(have));
183
+ return have;
184
+ }