@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.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +22 -10
- package/lab/lab.ts +15 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- 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
|
|
357
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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(
|
|
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);
|
package/lib/player-image.mjs
CHANGED
|
@@ -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(
|
|
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; }
|
package/lib/rules-build.mjs
CHANGED
|
@@ -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
|
|
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'
|
|
10
|
-
* the
|
|
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
|
|
18
|
-
* (
|
|
19
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 });
|
|
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
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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 = () =>
|
|
84
|
-
let now =
|
|
85
|
-
|
|
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,
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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, ...
|
|
137
|
-
build: createHash('sha256').update(guarded.code).update(JSON.stringify(
|
|
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
|
-
|
|
154
|
-
|
|
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
|
}
|