@homie-rocks/studio 0.35.1 → 0.37.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 (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/agents/agents.ts +8 -6
  4. package/app-starters/welcome/app.json +106 -0
  5. package/app-starters/welcome/index.html +4 -0
  6. package/app-starters/welcome/src/main.ts +107 -0
  7. package/apps/APPS.md +61 -0
  8. package/apps/apps.ts +28 -0
  9. package/bin/homie-studio.mjs +22 -10
  10. package/lab/lab.ts +15 -0
  11. package/lib/art-cli.mjs +2 -2
  12. package/lib/art-tools.mjs +6 -6
  13. package/lib/asset-check.mjs +3 -2
  14. package/lib/asset-import.mjs +3 -2
  15. package/lib/asset-manifest.mjs +8 -8
  16. package/lib/build.mjs +26 -18
  17. package/lib/characters.mjs +7 -7
  18. package/lib/check.mjs +49 -0
  19. package/lib/cloudflare.mjs +1 -1
  20. package/lib/codex.mjs +2 -2
  21. package/lib/collision.mjs +1 -1
  22. package/lib/decisions.mjs +7 -7
  23. package/lib/dev.mjs +42 -10
  24. package/lib/directory.mjs +1 -1
  25. package/lib/doctor.mjs +1 -1
  26. package/lib/handoff.mjs +1 -1
  27. package/lib/lab-check.mjs +18 -3
  28. package/lib/lab.mjs +29 -15
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +23 -3
  31. package/lib/parts-build.mjs +4 -3
  32. package/lib/parts-cli.mjs +3 -3
  33. package/lib/parts-store.mjs +8 -7
  34. package/lib/parts-tools.mjs +7 -7
  35. package/lib/parts.mjs +19 -14
  36. package/lib/perf.mjs +2 -1
  37. package/lib/player-image.mjs +2 -2
  38. package/lib/rules-build.mjs +97 -49
  39. package/lib/rules-check-worker.mjs +405 -0
  40. package/lib/rules-check.mjs +69 -0
  41. package/lib/rules-compiler.mjs +30 -0
  42. package/lib/rules-guard.mjs +26 -7
  43. package/lib/rules-types.mjs +167 -0
  44. package/lib/scaffold.mjs +21 -2
  45. package/lib/shop.mjs +1 -1
  46. package/lib/standalone-files.mjs +2 -2
  47. package/lib/standalone.mjs +12 -3
  48. package/lib/stats.mjs +2 -1
  49. package/lib/studio.mjs +32 -6
  50. package/lib/style-board.mjs +10 -9
  51. package/lib/template-history.json +85 -1
  52. package/lib/typecheck.mjs +52 -3
  53. package/links/links.d.ts +3 -0
  54. package/links/links.mjs +16 -0
  55. package/netplay/NETPLAY.md +123 -6
  56. package/netplay/netplay.ts +289 -53
  57. package/netplay/rules-output.mjs +85 -0
  58. package/package.json +16 -4
  59. package/parts/PARTS.md +10 -5
  60. package/rules/browser.ts +100 -0
  61. package/rules/core.ts +159 -58
  62. package/rules/guard.ts +11 -4
  63. package/rules/host.ts +90 -15
  64. package/rules/math.ts +16 -0
  65. package/rules/pack.ts +94 -17
  66. package/rules/rules.ts +21 -13
  67. package/rules/types.ts +63 -0
  68. package/rules/view.ts +36 -33
  69. package/shop/SHOP.md +13 -0
  70. package/site/SITE.md +1 -1
  71. package/standalone/shell/shell.js +8 -6
  72. package/worker/account-page.mjs +4 -3
  73. package/worker/app-format.mjs +42 -0
  74. package/worker/app-records.mjs +100 -0
  75. package/worker/app-shell.mjs +29 -0
  76. package/worker/brain.mjs +2 -1
  77. package/worker/embed.mjs +4 -2
  78. package/worker/hosted.mjs +1 -1
  79. package/worker/index.mjs +40 -20
  80. package/worker/limits.mjs +41 -0
  81. package/worker/pages.mjs +37 -29
  82. package/worker/parts.mjs +11 -9
  83. package/worker/room.mjs +136 -63
  84. package/worker/schema.mjs +6 -5
  85. package/worker/seats.mjs +2 -2
  86. package/worker/shop-page.mjs +4 -3
  87. package/worker/site.mjs +49 -40
  88. package/worker/standalone.mjs +2 -2
  89. package/worker/version.mjs +1 -1
package/lib/parts.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * GAME PARTS: THE FORMAT AND ITS CHECKER (parts/PARTS.md is the design; this file holds it to its word).
3
4
  *
@@ -24,7 +25,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, r
24
25
  import { dirname, join, relative } from 'node:path';
25
26
 
26
27
  export const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
27
- export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece']);
28
+ export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template']);
28
29
  export const NETPLAY_KINDS = Object.freeze(['host-authoritative', 'replicated', 'local']);
29
30
  export const PROVENANCE = Object.freeze(['original', 'generated', 'imported']);
30
31
  export const PIVOTS = Object.freeze(['feet', 'centre', 'base', 'origin']);
@@ -315,8 +316,9 @@ export function checkPart(part, { id = null } = {}) {
315
316
  if (!isObj(part.cost)) bad('cost', 'it is not an object', '{ "triangles": 0, "drawCalls": 0, "textureMB": 0, "bytes": 0, "measuredOn": "…" }');
316
317
  else for (const k of ['triangles', 'drawCalls', 'textureMB', 'bytes']) if (part.cost[k] !== undefined && !(Number.isFinite(part.cost[k]) && part.cost[k] >= 0)) bad(`cost.${k}`, 'it is not a number of 0 or more', 'leave out a cost nobody measured');
317
318
  }
319
+ if (part.uses !== undefined && (!Array.isArray(part.uses) || part.uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u)))) bad('uses', 'uses must name game, app, venue, cause, music or video');
318
320
  if (part.from !== undefined && part.from !== null) {
319
- if (!isObj(part.from) || (part.from.game != null && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(part.from.game)))) bad('from', 'it does not name the game the part came out of', '{ "game": "<the game\'s id>", "studio": "<the studio\'s name>" }');
321
+ if (!isObj(part.from) || ((part.from.game ?? part.from.app ?? part.from.music ?? part.from.video) != null && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(part.from.game ?? part.from.app ?? part.from.music ?? part.from.video)))) bad('from', 'it does not name the game the part came out of', '{ "game": "<the game\'s id>", "studio": "<the studio\'s name>" }');
320
322
  }
321
323
  if (part.provenance !== undefined) {
322
324
  if (!isObj(part.provenance)) bad('provenance', 'it is not an object', '{ "source": "original" }');
@@ -353,14 +355,16 @@ export function requiredParts(part) {
353
355
  */
354
356
  export function assetRecords(root) {
355
357
  const out = new Map();
356
- const games = join(root, 'games');
357
- if (!existsSync(games)) return out;
358
+ for (const folder of ['games', 'apps']) {
359
+ const games = join(root, folder);
360
+ if (!existsSync(games)) continue;
358
361
  for (const e of readdirSync(games, { withFileTypes: true })) {
359
362
  if (!e.isDirectory()) continue;
360
363
  let m = null;
361
364
  try { m = JSON.parse(readFileSync(join(games, e.name, 'assets', 'manifest.json'), 'utf8')); } catch { m = null; }
362
365
  for (const a of Array.isArray(m?.assets) ? m.assets : []) for (const f of a.files ?? []) if (/^[a-f0-9]{64}$/.test(String(f.sha256 ?? ''))) out.set(f.sha256, { game: e.name, asset: a.id, kind: a.license?.kind ?? null, attribution: a.license?.attribution ?? null });
363
366
  }
367
+ }
364
368
  return out;
365
369
  }
366
370
  /** The asset-manifest licence kinds a file may be handed on under (lib/asset-manifest.mjs LICENSES, `redistribute`). */
@@ -520,7 +524,7 @@ function relSpec(fromFile, toFile) {
520
524
  * would point nowhere, and a part that reaches back into one game is not a part another game can use.
521
525
  */
522
526
  function liftPlan(root, game, paths) {
523
- const gdir = join(root, 'games', game);
527
+ const gdir = join(experienceDir(root, game));
524
528
  const files = [];
525
529
  const walk = (rel) => {
526
530
  const abs = join(gdir, rel);
@@ -528,15 +532,15 @@ function liftPlan(root, game, paths) {
528
532
  if (statSync(abs).isDirectory()) { for (const e of readdirSync(abs, { withFileTypes: true })) if (!e.name.startsWith('.') && e.name !== 'node_modules') walk(`${rel}/${e.name}`); } else files.push(rel);
529
533
  };
530
534
  for (const raw of paths) {
531
- const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^games\/[^/]+\//, '').replace(/\/+$/, '');
535
+ const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^(?:games|apps)\/[^/]+\//, '').replace(/\/+$/, '');
532
536
  if (!rel || rel.split('/').includes('..') || rel.startsWith('/')) throw new Error(`"${raw}" is not a path inside games/${game}`);
533
537
  walk(rel);
534
538
  }
535
539
  if (!files.length) throw new Error(`name the files of games/${game} that become the part (src/creature.ts, or a folder)`);
536
540
  // A part is a piece of a game, never the whole game: its entry, its page and its game.json stay the game's.
537
541
  let entry = 'src/main.ts';
538
- try { entry = JSON.parse(readFileSync(join(gdir, 'game.json'), 'utf8')).entry ?? entry; } catch { /* the default entry */ }
539
- const whole = files.filter((f) => f === entry || f === 'game.json' || f === 'index.html');
542
+ try { entry = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).entry ?? entry; } catch { /* the default entry */ }
543
+ const whole = files.filter((f) => f === entry || f === 'game.json' || f === 'app.json' || f === 'index.html');
540
544
  if (whole.length) throw new Error(`${whole.join(', ')} ${whole.length === 1 ? 'is' : 'are'} the game itself. A part is a piece of a game, never the whole game: name the files of one piece (the creature, the level generator, the camera).`);
541
545
  // The folder they share (without a leading src/, which every part has of its own).
542
546
  const dirs = files.map((f) => f.split('/').slice(0, -1));
@@ -560,15 +564,16 @@ function liftPlan(root, game, paths) {
560
564
  * `parts new`: a private part with every field a sharer will need, so the gaps are visible from the first minute.
561
565
  * `from` (a game's id) with `paths` lifts those files out of the game (above); without, the part starts empty.
562
566
  */
563
- export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [] } = {}) {
567
+ export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [], uses = [] } = {}) {
564
568
  if (!PART_ID.test(String(id ?? ''))) return { ok: false, command: 'parts new', why: `"${id ?? ''}" is not a part id: lowercase letters, digits and hyphens (chase-camera)` };
565
569
  if (!PART_KINDS.includes(kind)) return { ok: false, command: 'parts new', why: `"${kind}" is not a kind of part: ${PART_KINDS.join(', ')}` };
570
+ if (!Array.isArray(uses) || uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u))) return { ok: false, command: 'parts new', why: 'invalid intended uses' };
566
571
  const dir = partDir(root, id);
567
572
  if (existsSync(dir)) return { ok: false, command: 'parts new', why: `parts/${id} is there already` };
568
573
  const title = clean(name, 80) || id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
569
574
  let entry = null; let lifted = null; let fromGame = null;
570
575
  if (from) {
571
- if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(from)) || !existsSync(join(root, 'games', from, 'game.json'))) return { ok: false, command: 'parts new', why: `there is no game "${from}" in this studio to lift a part out of` };
576
+ if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(from)) || !existsSync(experienceFile(root, from))) return { ok: false, command: 'parts new', why: `there is no game "${from}" in this studio to lift a part out of` };
572
577
  let plan;
573
578
  try { plan = liftPlan(root, from, paths); } catch (error) { return { ok: false, command: 'parts new', why: error.message }; }
574
579
  if (plan.outside.length) return { ok: false, command: 'parts new', why: `These files still reach into the rest of games/${from}: ${plan.outside.slice(0, 6).join('; ')}. A part has to stand without the game: lift those files too, or pass what they provide in as an argument first. Nothing was moved.`, outside: plan.outside };
@@ -589,7 +594,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
589
594
  entry = mods.find((p) => /^src\/index\.[a-z]+$/.test(p)) ?? mods[0] ?? null;
590
595
  let studio = null;
591
596
  try { studio = JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')).name ?? null; } catch { studio = null; }
592
- fromGame = { game: from, ...(studio ? { studio: clean(studio, 80) } : {}) };
597
+ fromGame = { [experienceFile(root, from).endsWith('/app.json') ? 'app' : 'game']: from, ...(studio ? { studio: clean(studio, 80) } : {}) };
593
598
  } else {
594
599
  mkdirSync(dir, { recursive: true });
595
600
  if (['mechanic', 'ui', 'effect', 'shader', 'level-generator', 'bot-brain'].includes(kind)) {
@@ -602,7 +607,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
602
607
  mkdirSync(join(dir, 'preview'), { recursive: true });
603
608
  if (!existsSync(join(dir, 'preview', 'index.html'))) writeFileSync(join(dir, 'preview', 'index.html'), PREVIEW_HTML(title));
604
609
  writePart(dir, {
605
- id, name: title, kind, version: '0.1.0', summary: '', license: '', attribution: '', share: false, tags: [],
610
+ id, name: title, kind, version: '0.1.0', summary: '', license: '', attribution: '', share: false, tags: [], ...(uses.length ? { uses } : {}),
606
611
  ...(fromGame ? { from: fromGame } : {}),
607
612
  ...(entry ? { entry } : {}),
608
613
  files: [],
@@ -628,7 +633,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
628
633
 
629
634
  /* ------------------------------------------------------------------ checking a set together */
630
635
 
631
- const creditsFile = (root, game) => join(root, 'games', game, 'credits.json');
636
+ const creditsFile = (root, game) => join(experienceDir(root, game), 'credits.json');
632
637
 
633
638
  /**
634
639
  * THE LICENCES OF THE PARTS A GAME USES, and where they cannot be combined: [{ level: 'conflict' | 'warn' | 'note',
@@ -687,7 +692,7 @@ export function syncPartCredits(root, game, origins) {
687
692
  const keep = (Array.isArray(credits.parts) ? credits.parts : []).filter((p) => typeof p?.part !== 'string');
688
693
  const mine = Object.entries(origins.parts ?? {}).filter(([, e]) => (e.games ?? []).includes(game)).map(([ref, e]) => creditLine(ref, e));
689
694
  if (!mine.length && !existsSync(file)) return null;
690
- if (!existsSync(join(root, 'games', game))) return null;
695
+ if (!existsSync(join(experienceDir(root, game)))) return null;
691
696
  const text = `${JSON.stringify({ ...credits, parts: [...keep, ...mine] }, null, 2)}\n`;
692
697
  if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
693
698
  return `games/${game}/credits.json`;
package/lib/perf.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * `homie-studio perf <game> --url <site>` — how fast a game runs, measured the same way every time, so a change can be
3
4
  * judged against the build before it (`perf compare`, lib/perf-stats.mjs).
@@ -681,7 +682,7 @@ export function perfSizes(root, game) {
681
682
  const dir = join(root, 'site', 'dist', 'games', game);
682
683
  if (!existsSync(dir)) return { ok: false, command: 'perf sizes', why: `no build of ${game}: run npm run build first` };
683
684
  let cover = null;
684
- try { cover = JSON.parse(readFileSync(join(root, 'games', game, 'game.json'), 'utf8')).cover ?? null; } catch { /* none */ }
685
+ try { cover = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).cover ?? null; } catch { /* none */ }
685
686
  const files = listFiles(dir).map((f) => {
686
687
  const buf = readFileSync(join(dir, f));
687
688
  const kind = kindOf(f);
@@ -12,10 +12,10 @@ import { PLAYER_IMAGE, playerEnabled, playerImageRule } from '../worker/embed.mj
12
12
  */
13
13
  export async function playerImage(cat, game, dist, log) {
14
14
  if (!playerEnabled(cat, game)) return null;
15
- const sources = [...new Set([game.landing?.hero?.wideImage, game.landing?.cover,
15
+ const sources = [...new Set([game.landing?.hero?.wideImage, game.landing?.hero?.tallImage, game.landing?.cover,
16
16
  game.cover ? `/games/${game.id}/${game.cover}` : null, cat.studio.theme?.social].filter(Boolean))];
17
17
  const warn = (file, rule) => log(`warning: ${file}: player card needs ${rule}; keeping the picture card unless another image qualifies.`);
18
- if (!sources.length) warn(`games/${game.id}/game.json`, 'a cover or a studio social picture in the built site (starters do not include one)');
18
+ if (!sources.length) warn(`${game.kind === 'app' ? 'apps' : 'games'}/${game.id}/${game.kind === 'app' ? 'app' : 'game'}.json`, 'a cover or a studio social picture in the built site (starters do not include one)');
19
19
  const root = realpathSync(dist);
20
20
  for (const src of sources) {
21
21
  if (!src.startsWith('/') || src.startsWith('//')) { warn(src, 'a local image the build can measure'); continue; }
@@ -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
  }