@homie-rocks/studio 0.35.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/agents/agents.ts +8 -6
- package/bin/homie-studio.mjs +9 -6
- package/lab/lab.ts +15 -0
- package/lib/build.mjs +20 -15
- package/lib/dev.mjs +16 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +21 -7
- package/lib/mcp-tools.mjs +2 -1
- 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/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +6 -2
- package/lib/template-history.json +44 -1
- package/lib/typecheck.mjs +52 -3
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +3 -2
- 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/standalone/shell/shell.js +8 -6
- package/worker/brain.mjs +2 -1
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +7 -11
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +4 -2
- package/worker/room.mjs +136 -63
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,30 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.36.0] - 2026-10-08
|
|
21
|
+
|
|
22
|
+
**Plugin 0.37.0** · [#68](https://github.com/homie-rocks/homie/pull/68) · [#73](https://github.com/homie-rocks/homie/pull/73)
|
|
23
|
+
|
|
24
|
+
The build plays your rules game and names problems before you publish it.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **The build plays your rules game before it is written.** After checking the types in rules, movement and view against the game's own declarations, it seats generated players in a room on the real server runtime. They steer, hold every control, send every command the game declares, and sometimes send nothing at all; they leave, come back and give up their seats; the room is restarted as a deploy restarts it. A game with AI companions is played with them too.
|
|
29
|
+
- **A build is refused only for what that play proved.** A handler that threw, a tick that used its whole budget, a character the server had to hold back because its movement outran its declared top speed, a room that did not come back the same from its own save, or a value the room had to change and lose: `NaN`, an infinite number in a fraction or a vector, more entries than a list or a map declares, a text longer than declared. The message names the file, the line of the write and the handler, says what a live room does with the value, and how to repair it. Everything else is an `info:` line that never fails a build: how much was played, which handlers never ran, and how often a whole number was held to its range.
|
|
30
|
+
- **The same game gets the same answer on every computer.** The play is counted in ticks and in the rules' own budget units, never in seconds, so a slow or busy computer builds what a fast one builds and prints the same lines. A small game is played for fifteen minutes of the room's clock, which takes about a second; a heavy game is played until its units are used, a few seconds, and the line says how far that was. `homie-studio build --long-check` plays eight times as much, for a release or a CI run.
|
|
31
|
+
- **A live room never stops for a value that does not fit its field.** It stores the nearest thing the type holds, as before: `NaN` becomes `0`, a list is cut to its size, a whole number is held to its range (`Infinity` is the top of it, so it can stand for "never"). The build is where such a write is caught. `-0` is now stored as `0`, so a room divides the same way before and after a restart, and a map keeps its keys in the order they were added. The `game` skill's `RULES.md` has the table. Saved matches are written in a new form: a match saved by a build made before this change starts afresh once.
|
|
32
|
+
|
|
33
|
+
- **Let a player host a friends game.** Studios can choose `room.host: "browser"` to run the same rules in an elected player's browser. When that player leaves, another takes over with the saved seats, scores, round clock and AI companions. Server hosting remains the default.
|
|
34
|
+
- **Play offline from the same rules.** Browsers and packaged apps can play with local bots without a connection, unless the studio turns offline play off. Offline progress stays local. The Game Lab uses the same rules with its stages and sliders; online rooms can also use AI guides and decisions.
|
|
35
|
+
- **Browser hosting has different limits.** It depends on a player's device and connection and offers no protection against a modified host: that player can change scores or the match. It has no server recovery guarantee. When a host hides or closes the tab, another player takes over from the current tick; a crash can lose up to a second. Updates are paced to fit the connection, and a room whose state grows beyond the browser limits stops with a clear message. Choose server hosting for stronger authority or larger rooms.
|
|
36
|
+
- **The netplay contract is revision 11** (`netplay/NETPLAY.md`, section 29): the server as a host, input as steps stamped with the room's tick, explicit browser runtime output, and checkpoint handover. Nothing a game written before sends or receives has changed.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- Local development publishes one complete rules build per restart, avoiding the two reloads caused by separate code and asset watchers.
|
|
41
|
+
- Browser rooms recover after a hidden tab, sleep or a dropped connection. Returning players regain a playable body, unavailable offline files keep reconnecting, and failed rules explain why the room stopped.
|
|
42
|
+
- Builds check the relay's browser state limits. A room that grows beyond them stops with a clear message instead of repeatedly changing hosts. Saved inputs and answers are checked before rules use them.
|
|
43
|
+
|
|
20
44
|
## [0.35.1] - 2026-10-08
|
|
21
45
|
|
|
22
46
|
**Plugin 0.36.0** · [#71](https://github.com/homie-rocks/homie/pull/71)
|
package/agents/agents.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface AgentsOptions {
|
|
|
59
59
|
/** Runtime integration: a room clock and an explicit beat replace the browser timer. */
|
|
60
60
|
now?: () => number;
|
|
61
61
|
manual?: boolean;
|
|
62
|
+
/** A rules view is always a client; the separate runtime owns guides. */
|
|
63
|
+
viewOnly?: boolean;
|
|
62
64
|
/** Keep an asked-for goal through scripted floor decisions until completion or sixty seconds. */
|
|
63
65
|
carryFloor?: boolean;
|
|
64
66
|
restore?: AgentsSaved;
|
|
@@ -283,7 +285,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
|
|
|
283
285
|
function onAsk(slot: number, k: string, args: Record<string, unknown>, from: number): void {
|
|
284
286
|
const def = own(vocab.asks, k);
|
|
285
287
|
const s = slotOf(slot);
|
|
286
|
-
if (!def || !s || argsWhy(def.args, args,
|
|
288
|
+
if (!def || !s || argsWhy(def.args, args, viewOf(slot), people())) { st.drops += 1; return; }
|
|
287
289
|
const now = wall();
|
|
288
290
|
if (def.leave) { const m = avoid.get(slot) ?? new Map<number, number>(); m.set(from, now + AGENT_RULES.leaveMs); avoid.set(slot, m); }
|
|
289
291
|
asks.set(slot, [...(asks.get(slot) ?? []), { k, args, from, at: now }].slice(-4));
|
|
@@ -298,7 +300,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
|
|
|
298
300
|
const offEvent = net.on('event', (e: NetEvent) => {
|
|
299
301
|
const k = e.k;
|
|
300
302
|
const d = (e.d && typeof e.d === 'object' ? e.d : {}) as Record<string, unknown>;
|
|
301
|
-
if (net.isHost) {
|
|
303
|
+
if (net.isHost && !opts.viewOnly) {
|
|
302
304
|
const s = slotOfSeat(e.from);
|
|
303
305
|
if (k === 'agent:do' && s) {
|
|
304
306
|
st.dos += 1;
|
|
@@ -311,11 +313,11 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
|
|
|
311
313
|
if (ask && e.from !== null && !net.isAgent(e.from) && Number.isInteger(d['slot'])) { onAsk(d['slot'] as number, ask, (d['args'] ?? {}) as Record<string, unknown>, e.from); return; }
|
|
312
314
|
return;
|
|
313
315
|
}
|
|
314
|
-
if (k === 'agent:offer' && e.from === net.host?.seat && Number.isInteger(d['slot']) && d['view'] && typeof d['view'] === 'object') { lastView.set(d['slot'] as number, d['view'] as Record<string, unknown>); return; }
|
|
316
|
+
if (k === 'agent:offer' && e.from === (opts.viewOnly ? null : net.host?.seat) && Number.isInteger(d['slot']) && d['view'] && typeof d['view'] === 'object') { lastView.set(d['slot'] as number, d['view'] as Record<string, unknown>); return; }
|
|
315
317
|
// Every other browser: a guide's line the host relayed (Quiet AI already dropped it in the helper). Its words are
|
|
316
318
|
// this browser's own copy of the vocabulary; an argument is a seat, a value of the line's list, or an id.
|
|
317
319
|
const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
|
|
318
|
-
if (e.from === net.host?.seat && line && d['ai'] === true && Number.isInteger(d['slot'])) {
|
|
320
|
+
if (e.from === (opts.viewOnly ? null : net.host?.seat) && line && d['ai'] === true && Number.isInteger(d['slot'])) {
|
|
319
321
|
const args = (d['args'] ?? {}) as Record<string, unknown>;
|
|
320
322
|
const def = own(vocab.lines, line);
|
|
321
323
|
const ids = Object.fromEntries(Object.entries(def?.args ?? {}).map(([n, t]) => [n, t]).filter(([, t]) => typeof t === 'string' && VIEW_ARG.test(t)).map(([n, t]) => [VIEW_ARG.exec(t as string)![1], /^[a-z0-9][a-z0-9_-]{0,39}$/.test(String(args[n as string] ?? '')) ? [args[n as string]] : []]));
|
|
@@ -327,7 +329,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
|
|
|
327
329
|
|
|
328
330
|
// The host's beat: views to the guides an AI holds (at most one every 2 s each), the floor for the rest.
|
|
329
331
|
const beat = (): void => {
|
|
330
|
-
if (!net.isHost || net.offline) return;
|
|
332
|
+
if (opts.viewOnly || !net.isHost || net.offline) return;
|
|
331
333
|
const now = wall();
|
|
332
334
|
for (const s of guides()) {
|
|
333
335
|
const seat = s.agent?.seat ?? null;
|
|
@@ -372,7 +374,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
|
|
|
372
374
|
const s = slotOf(slot);
|
|
373
375
|
if (!def || !s) return false;
|
|
374
376
|
const seat = s.agent?.seat ?? null;
|
|
375
|
-
if (net.isHost) {
|
|
377
|
+
if (net.isHost && !opts.viewOnly) {
|
|
376
378
|
const me = net.offline ? 0 : net.seat;
|
|
377
379
|
if (me === null) return false;
|
|
378
380
|
onAsk(slot, k, args, me);
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
* the cards (lib/mcp.mjs); --studios: the folder the studios live in
|
|
16
16
|
* homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
|
|
17
17
|
* homie-studio games
|
|
18
|
-
* homie-studio build [<id>] [--maps] [--types] (--maps: also keep each bundle's source map and
|
|
19
|
-
* .studio/maps/<id>/, never in site/dist: what `perf` reads a
|
|
20
|
-
* --types: check the games' TypeScript first, with the
|
|
21
|
-
* `typescript`; a type error stops the build
|
|
18
|
+
* homie-studio build [<id>] [--maps] [--types] [--long-check] (--maps: also keep each bundle's source map and
|
|
19
|
+
* module sizes in .studio/maps/<id>/, never in site/dist: what `perf` reads a
|
|
20
|
+
* CPU profile through; --types: check the games' TypeScript first, with the
|
|
21
|
+
* studio's own `typescript`; a type error stops the build; --long-check: play
|
|
22
|
+
* each rules game eight times as long before it is written (a rules game is
|
|
23
|
+
* always type-checked and played; this is more of the same play, for a
|
|
24
|
+
* release or a CI run). The site is built in a folder of
|
|
22
25
|
* its own and put in place only when all of it is there: a game that does not
|
|
23
26
|
* build fails the command and leaves site/dist as it was. Each game's line says
|
|
24
27
|
* changed or unchanged, its build hash, and whether its source is shared)
|
|
@@ -414,7 +417,7 @@ function print(result) {
|
|
|
414
417
|
lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
|
|
415
418
|
// Per game: did it change since the build before, which build it is (the hash the live site's manifest says once
|
|
416
419
|
// deployed), and what loads later.
|
|
417
|
-
for (const g of result.games) lines.push(` ${g.id}: ${g.changed ?? 'new'}, build ${g.hash ?? '?'}${g.bundle ? ` (${g.bundle}${g.chunks ? ` + ${g.chunks} ${g.chunks === 1 ? 'chunk' : 'chunks'}
|
|
420
|
+
for (const g of result.games) lines.push(` ${g.id}: ${g.changed ?? 'new'}, build ${g.hash ?? '?'}${g.bundle ? ` (${g.bundle}${g.chunks ? ` + ${g.chunks} additional ${g.chunks === 1 ? 'chunk' : 'chunks'}` : ''})` : ''}`);
|
|
418
421
|
if (result.retired) lines.push(`Note: ${result.retired}`);
|
|
419
422
|
if (result.types) lines.push(`Types: ${result.types.games.filter((g) => g.checked).map((g) => g.id).join(', ') || 'no game has TypeScript to check'}${result.types.games.some((g) => g.checked) ? ` checked with TypeScript ${result.types.typescript ?? '?'}, no errors` : ''}`);
|
|
420
423
|
if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
|
|
@@ -906,7 +909,7 @@ async function main() {
|
|
|
906
909
|
// go, so an old note, an old card or an agent's memory of the toolkit does not end at "unknown command".
|
|
907
910
|
if (cmd === 'game' && sub === 'remix') return { ok: false, command: 'game remix', why: 'remix was retired: a game is no longer handed over whole, and no studio serves one. Games build on each other through parts, the pieces of a game its studio chose to share: find one with `homie-studio parts find <words>` and bring it in with `homie-studio parts add` (in chat: parts_find, part_add; parts/PARTS.md)' };
|
|
908
911
|
if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
|
|
909
|
-
if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps'), types: flags.has('types') }), 'build');
|
|
912
|
+
if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps'), types: flags.has('types'), longCheck: flags.has('long-check') }), 'build');
|
|
910
913
|
if (cmd === 'preview') return preview(root, positional[1] ?? (listGames(root).length === 1 ? listGames(root)[0].id : null));
|
|
911
914
|
if (cmd === 'perf' && sub === 'sizes') return perfSizes(root, positional[2] ?? listGames(root)[0]?.id);
|
|
912
915
|
if (cmd === 'lab') return labCommand(root, sub);
|
package/lab/lab.ts
CHANGED
|
@@ -166,6 +166,21 @@ export const lab = {
|
|
|
166
166
|
return out as Tuned<S>;
|
|
167
167
|
},
|
|
168
168
|
|
|
169
|
+
/** Rules use the same slider file as the view. Public values keep their public. prefix in the Lab's UI. */
|
|
170
|
+
rulesTune(raw: Record<string, unknown>): Record<string, unknown> {
|
|
171
|
+
if (!ON) return raw;
|
|
172
|
+
const pub = raw.public && typeof raw.public === 'object' ? raw.public as Record<string, unknown> : {};
|
|
173
|
+
const spec: TunableFile = {};
|
|
174
|
+
for (const [k, v] of Object.entries(raw)) if (k !== 'public' && (typeof v === 'number' || typeof (v as TunableSpec)?.value === 'number')) spec[k] = v as TunableSpec | number;
|
|
175
|
+
for (const [k, v] of Object.entries(pub)) if (typeof v === 'number' || typeof (v as TunableSpec)?.value === 'number') spec[`public.${k}`] = v as TunableSpec | number;
|
|
176
|
+
const values = host!.tunables(spec);
|
|
177
|
+
const out: Record<string, unknown> & { public: Record<string, unknown> } = { ...raw, public: { ...pub } };
|
|
178
|
+
for (const k of Object.keys(spec)) if (Number.isFinite(values[k])) {
|
|
179
|
+
if (k.startsWith('public.')) out.public[k.slice(7)] = values[k]; else out[k] = values[k];
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
},
|
|
183
|
+
|
|
169
184
|
/**
|
|
170
185
|
* Dice for juice (particles, shake, sparks): its own stream in the lab, so a build that adds a particle never moves
|
|
171
186
|
* a gem or a bot in the other build (Math.random() is the world's dice, the same in both). Math.random() outside.
|
package/lib/build.mjs
CHANGED
|
@@ -75,7 +75,7 @@ import { audienceOf } from '../worker/shop-rules.mjs';
|
|
|
75
75
|
import { loungeConfig, loungeProblems } from '../worker/lounge-store.mjs';
|
|
76
76
|
// GAME PARTS (parts/PARTS.md): the three call-outs below are all the build knows of them.
|
|
77
77
|
import { buildParts, partsPlugin } from './parts-build.mjs';
|
|
78
|
-
// RULES ON THE SERVER (NETPLAY.md section 29): a game
|
|
78
|
+
// RULES ON THE SERVER (NETPLAY.md section 29): a game declaring a room object and src/rules.ts builds as a view bundle plus a rules module.
|
|
79
79
|
|
|
80
80
|
/** Never copied into a static game's served folder. */
|
|
81
81
|
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
|
|
@@ -376,7 +376,7 @@ function pointAtBundle(html, bundle) {
|
|
|
376
376
|
* game is served (/games/<id>/ on the site, /<id>/__game/ in its frame, the lab). A game with no dynamic import is
|
|
377
377
|
* one file, as before. `hashed` (the site's build) names the bundle by its content too: see the top of this file.
|
|
378
378
|
*/
|
|
379
|
-
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false } = {}) {
|
|
379
|
+
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false } = {}) {
|
|
380
380
|
rmSync(out, { recursive: true, force: true });
|
|
381
381
|
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
382
382
|
const mode = g.build?.mode ?? 'bundle';
|
|
@@ -394,7 +394,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
394
394
|
}
|
|
395
395
|
if (ruled && mode !== 'bundle') throw new Error(`games/${g.id} has a src/rules.ts and game.json "build": { "mode": "${mode}" }. A game written as rules plus view is built by Homie itself: take the "build" setting out.`);
|
|
396
396
|
const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
|
|
397
|
-
const rules = ruled ? await prepareRules(esbuild, root, g, { log }) : null;
|
|
397
|
+
const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck }) : null;
|
|
398
398
|
const bundle = async (entryRel) => {
|
|
399
399
|
const entry = join(g.dir, entryRel);
|
|
400
400
|
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
@@ -406,7 +406,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
406
406
|
// game.json assets.budgets, read by createModels() (assets/assets.ts): a constant in the bundle, never a fetch.
|
|
407
407
|
define: { __HOMIE_MODEL_BUDGETS__: JSON.stringify(budgets ?? {}) },
|
|
408
408
|
// GAME PARTS: `@parts/<host>/<id>` and `@parts/<id>` resolve to the part's entry, and the game is credited.
|
|
409
|
-
plugins: [partsPlugin(root, g.id), ...(rules ? [viewPlugin(g, rules, entry)] : [])],
|
|
409
|
+
plugins: [partsPlugin(root, g.id), ...(rules ? [viewPlugin(g, rules, entry, { lab })] : [])],
|
|
410
410
|
};
|
|
411
411
|
const failed = (error) => {
|
|
412
412
|
const first = error.errors?.[0];
|
|
@@ -416,7 +416,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
416
416
|
warnings += result.warnings.length;
|
|
417
417
|
metafile = result.metafile;
|
|
418
418
|
const outputs = Object.entries(result.metafile.outputs).filter(([k]) => k.endsWith('.js'));
|
|
419
|
-
const main = outputs.find(([, v]) => v.entryPoint)?.[0];
|
|
419
|
+
const main = outputs.find(([, v]) => rules ? v.entryPoint === 'homie-view:homie:view' : Boolean(v.entryPoint))?.[0];
|
|
420
420
|
if (!main) throw new Error(`games/${g.id} did not build: esbuild wrote no bundle for ${entryRel}`);
|
|
421
421
|
bundled = `assets/${basename(main)}`;
|
|
422
422
|
chunks = outputs.filter(([k]) => k !== main).map(([k]) => `assets/${basename(k)}`).sort();
|
|
@@ -466,6 +466,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
466
466
|
for (const file of [bundled, ...chunks].filter(Boolean)) digest.update(readFileSync(join(out, file)));
|
|
467
467
|
digest.update(readFileSync(join(out, 'index.html')));
|
|
468
468
|
rules.build = digest.digest('hex').slice(0, 32);
|
|
469
|
+
writeFileSync(join(out, 'rules.json'), `${JSON.stringify({ v: 1, host: rules.settings.host, offline: rules.settings.offline, build: rules.build, files: [bundled, ...chunks] })}\n`);
|
|
469
470
|
if (g.netplay?.version !== undefined) log(` ${g.id}: rules games use their build hash as the revision; netplay.version is not read`);
|
|
470
471
|
}
|
|
471
472
|
return { mode, warnings, metafile, bundle: bundled, chunks, rules };
|
|
@@ -486,7 +487,7 @@ export function orderGames(games, order, log = () => {}) {
|
|
|
486
487
|
return [...games].sort((a, b) => (at.get(a.id) ?? Infinity) - (at.get(b.id) ?? Infinity) || a.id.localeCompare(b.id));
|
|
487
488
|
}
|
|
488
489
|
|
|
489
|
-
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
|
|
490
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
|
|
490
491
|
const esbuild = await studioEsbuild(root);
|
|
491
492
|
const studio = readStudio(root);
|
|
492
493
|
// The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
|
|
@@ -504,13 +505,13 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
504
505
|
// from the site as it is.
|
|
505
506
|
const dist = openStage(root, { from: only ? live : null });
|
|
506
507
|
try {
|
|
507
|
-
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps });
|
|
508
|
+
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck });
|
|
508
509
|
} finally {
|
|
509
510
|
rmSync(dist, { recursive: true, force: true });
|
|
510
511
|
}
|
|
511
512
|
}
|
|
512
513
|
|
|
513
|
-
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps }) {
|
|
514
|
+
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck }) {
|
|
514
515
|
const built = [];
|
|
515
516
|
const retired = [];
|
|
516
517
|
const cache = {};
|
|
@@ -519,7 +520,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
519
520
|
for (const g of games) {
|
|
520
521
|
const out = join(dist, 'games', g.id);
|
|
521
522
|
const started = Date.now();
|
|
522
|
-
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true });
|
|
523
|
+
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck });
|
|
523
524
|
if (rules) ruled.push({ id: g.id, ...rules }); else browserHosted.push(g.id);
|
|
524
525
|
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
525
526
|
vocabFor(g, out);
|
|
@@ -542,11 +543,14 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
542
543
|
id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
|
|
543
544
|
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
544
545
|
});
|
|
545
|
-
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} ${chunks.length === 1 ? 'chunk' : 'chunks'}
|
|
546
|
-
if (rules
|
|
546
|
+
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
|
|
547
|
+
if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this game's rules and tunables are sent to players' devices so it can be played offline`);
|
|
548
|
+
if (rules) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
|
|
549
|
+
if (rules && rules.settings.host === 'server' && (rules.snapshotBytes > rules.snapshotCap || rules.checkpointBytes > rules.checkpointCap)) log(` ${g.id}: this state fits server and offline play but exceeds browser hosting limits.`);
|
|
550
|
+
if (rules) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
|
|
547
551
|
}
|
|
548
552
|
// A game written before rules (its own code is the host) builds and runs exactly as it did. Said in one line.
|
|
549
|
-
if (browserHosted.length) log(`hosted by a player's browser, as before (no
|
|
553
|
+
if (browserHosted.length) log(`hosted by a player's browser, as before (no room object; nothing to do): ${browserHosted.join(', ')}`);
|
|
550
554
|
const all = listGames(root);
|
|
551
555
|
// No game is handed over whole any more (remix was retired; worker/license.mjs). A one-game build starts from the
|
|
552
556
|
// site as it is, which an older toolkit may have built: what that wrote for remixers (a game's whole source, and
|
|
@@ -571,8 +575,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
571
575
|
// studio.json site.order: the catalogue's order is every listing's order.
|
|
572
576
|
const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
|
|
573
577
|
// Server-hosted games: what this build made, and for a one-game build what the catalogue before it said of the others.
|
|
574
|
-
const rulesOf = new Map(ruled.map((r) => [r.id, { host:
|
|
575
|
-
if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.
|
|
578
|
+
const rulesOf = new Map(ruled.map((r) => [r.id, { host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds }]));
|
|
579
|
+
if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.contract && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
|
|
576
580
|
// What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
|
|
577
581
|
const builds = {};
|
|
578
582
|
const rows = shown.map((g) => {
|
|
@@ -714,7 +718,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
714
718
|
if (deploy) for (const b of built) log(` ${b.id}: ${b.changed}${b.changed === 'new' ? '' : ' since the last build here'}, build ${b.hash}`);
|
|
715
719
|
// The rules of the server-hosted games, where the studio's Worker imports them (site/src/rules/). Written with the
|
|
716
720
|
// site, never before: a game that did not build has left both as they were.
|
|
717
|
-
|
|
721
|
+
await beforePublish();
|
|
722
|
+
const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
|
|
718
723
|
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
719
724
|
const swapped = swapIn(dist, live);
|
|
720
725
|
return {
|
package/lib/dev.mjs
CHANGED
|
@@ -413,6 +413,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
413
413
|
// A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
|
|
414
414
|
const stamps = new Map(listGames(root).filter(isRulesGame).map((g) => [g.id, rulesStamp(g)]));
|
|
415
415
|
let rebuilding = false;
|
|
416
|
+
let published = Promise.resolve();
|
|
416
417
|
const rulesLook = async () => {
|
|
417
418
|
if (rebuilding || ending) return;
|
|
418
419
|
for (const g of listGames(root).filter(isRulesGame)) {
|
|
@@ -424,7 +425,20 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
424
425
|
log(`games/${g.id} changed: checking its rules and building it again…`);
|
|
425
426
|
const hash = () => { try { return JSON.parse(readFileSync(join(root, 'site/dist/games.json'), 'utf8')).games.find((x) => x.id === g.id)?.room?.stateHash; } catch { return null; } };
|
|
426
427
|
const before = hash();
|
|
427
|
-
|
|
428
|
+
let release;
|
|
429
|
+
published = new Promise(resolve => { release = resolve; });
|
|
430
|
+
try {
|
|
431
|
+
await build(root, { only: g.id, log, beforePublish: async () => {
|
|
432
|
+
// Wrangler independently watches assets and code. Publishing both while it runs
|
|
433
|
+
// reloads the old bundle first, then the new one. Stop once at the publish boundary;
|
|
434
|
+
// the loop below starts once with the complete build and the same local storage.
|
|
435
|
+
if (!child || child.exitCode !== null) return;
|
|
436
|
+
restart = g.id;
|
|
437
|
+
const stopped = new Promise(resolve => child.once('close', resolve));
|
|
438
|
+
child.kill('SIGTERM');
|
|
439
|
+
await stopped;
|
|
440
|
+
} });
|
|
441
|
+
} finally { release(); }
|
|
428
442
|
log(`games/${g.id} is rebuilt. ${before && before === hash() ? 'Local rooms resume their saved match; pages reconnect.' : 'The stored shape changed: local rooms reset to a fresh match; pages reconnect.'}`);
|
|
429
443
|
} catch (error) {
|
|
430
444
|
log(`games/${g.id} did not build, so the local site still runs what it had:\n${String(error?.message ?? error)}`);
|
|
@@ -461,6 +475,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
461
475
|
writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
|
|
462
476
|
settle().catch(() => {});
|
|
463
477
|
await new Promise((done) => { child.on('close', done); child.on('error', done); });
|
|
478
|
+
await published;
|
|
464
479
|
if (!restart || ending) break;
|
|
465
480
|
restart = null;
|
|
466
481
|
}
|
package/lib/lab-check.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
25
25
|
import { join, relative } from 'node:path';
|
|
26
26
|
import { chromeArgs, findChrome, noChrome, SOFTWARE_GL } from './chrome.mjs';
|
|
27
27
|
import { labDir, startLabServer } from './lab.mjs';
|
|
28
|
-
import { listGames } from './studio.mjs';
|
|
28
|
+
import { isRulesGame, listGames } from './studio.mjs';
|
|
29
29
|
|
|
30
30
|
const round = (v, d = 2) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
|
|
31
31
|
const pct = (xs, p) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] : null; };
|
|
@@ -58,6 +58,11 @@ export function summarise(pane, { fps, units }) {
|
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** A rules take must actually advance its local runtime without joining a room. */
|
|
62
|
+
export function rulesRun(s) {
|
|
63
|
+
return Boolean(s?.hosting && !s.connected && s.tick > 0 && s.status === 'playing');
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
/** Where two runs of one build first differ (frame number), or null when every frame matches. */
|
|
62
67
|
export function firstDifference(a, b) {
|
|
63
68
|
const n = Math.max(a?.length ?? 0, b?.length ?? 0);
|
|
@@ -137,8 +142,18 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
|
|
|
137
142
|
new: first.new.available ? firstDifference(first.new.frames, second.new.frames) : null,
|
|
138
143
|
today: first.today.available ? firstDifference(first.today.frames, second.today.frames) : null,
|
|
139
144
|
};
|
|
145
|
+
const rules = isRulesGame(games.find((g) => g.id === game)) ? await page.evaluate(() => {
|
|
146
|
+
const out = {};
|
|
147
|
+
for (const pane of ['new', 'today']) {
|
|
148
|
+
const frame = [...document.querySelectorAll('iframe')].find((f) => f.src.includes('/' + pane + '/'));
|
|
149
|
+
const n = frame?.contentWindow?.__homieNet;
|
|
150
|
+
out[pane] = n ? { format: typeof n.probe?.hosted === 'function', hosting: n.rulesHosting, connected: n.connected, tick: n.probe?.tick?.(), status: n.probe?.status?.() } : null;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}) : null;
|
|
154
|
+
if (rules && (!rulesRun(rules.new) || (first.today.available && rules.today?.format && !rulesRun(rules.today)))) pageErrors.push('A rules pane did not play on its local host runtime.');
|
|
140
155
|
const summary = {
|
|
141
|
-
ok:
|
|
156
|
+
ok: !rules || pageErrors.length === 0, rules, game, take: first.take, note: first.note, fps: first.fps, frames: first.frames, device: first.device, seed: first.seed, inputs: first.inputs,
|
|
142
157
|
newBuild: first.newBuild, todayBuild: first.todayBuild, overrides: first.overrides, renderer, software: SOFTWARE_GL.test(String(renderer ?? '')),
|
|
143
158
|
deterministic, new: summarise(first.new, first), today: summarise(first.today, first), pageErrors,
|
|
144
159
|
};
|
|
@@ -182,7 +197,7 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
|
|
|
182
197
|
};
|
|
183
198
|
if (!out) writeFileSync(join(labDir(root, game), 'latest.json'), `${JSON.stringify(latest, null, 2)}\n`);
|
|
184
199
|
return {
|
|
185
|
-
ok:
|
|
200
|
+
ok: summary.ok, rules: summary.rules, command: 'lab check', game, take: summary.take, out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath),
|
|
186
201
|
frames: summary.frames, fps: summary.fps, deterministic, renderer, software: summary.software,
|
|
187
202
|
phases: { new: summary.new.phases?.map((p) => `${p.name} ${p.frames}f`) ?? [], today: summary.today.available ? summary.today.phases.map((p) => `${p.name} ${p.frames}f`) : null },
|
|
188
203
|
timeline: { new: (summary.new.phases ?? []).map(({ name, from, to }) => ({ name, from, to })), today: summary.today.available ? summary.today.phases.map(({ name, from, to }) => ({ name, from, to })) : null },
|
package/lib/lab.mjs
CHANGED
|
@@ -67,6 +67,20 @@ export function readTunables(dir) {
|
|
|
67
67
|
return json;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Rules keep public movement knobs grouped on disk; sliders name them public.speed, public.jump, and so on. */
|
|
71
|
+
export function flatTunables(spec) {
|
|
72
|
+
if (!spec) return null;
|
|
73
|
+
const { public: pub, ...rest } = spec;
|
|
74
|
+
return { ...rest, ...Object.fromEntries(Object.entries(pub ?? {}).map(([k, v]) => [`public.${k}`, v])) };
|
|
75
|
+
}
|
|
76
|
+
export function setLabTunables(spec, values) {
|
|
77
|
+
const r = setTunables(flatTunables(spec), values);
|
|
78
|
+
if (!spec?.public) return r;
|
|
79
|
+
const out = { ...r.spec, public: { ...spec.public } };
|
|
80
|
+
for (const [k, v] of Object.entries(r.spec)) if (k.startsWith('public.')) { out.public[k.slice(7)] = v; delete out[k]; }
|
|
81
|
+
return { ...r, spec: out };
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
/** `homie-studio lab set <game> name=value ...`: values written into tunables.json, one tunable a line. */
|
|
71
85
|
export function labSet(root, id, pairs) {
|
|
72
86
|
const g = gameOf(root, id);
|
|
@@ -74,12 +88,12 @@ export function labSet(root, id, pairs) {
|
|
|
74
88
|
if (!spec) return { ok: false, command: 'lab set', why: `games/${g.id} has no tunables.json yet: the lab skill says how a game gets one (lab.tunables)` };
|
|
75
89
|
const values = {};
|
|
76
90
|
for (const p of pairs) {
|
|
77
|
-
const m = /^([A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
|
|
91
|
+
const m = /^((?:public\.)?[A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
|
|
78
92
|
if (!m) return { ok: false, command: 'lab set', why: `"${p}" is not name=value` };
|
|
79
93
|
values[m[1]] = m[2];
|
|
80
94
|
}
|
|
81
95
|
if (!Object.keys(values).length) return { ok: false, command: 'lab set', why: 'usage: homie-studio lab set <game> <name>=<value> ...' };
|
|
82
|
-
const r =
|
|
96
|
+
const r = setLabTunables(spec, values);
|
|
83
97
|
if (r.refused.length) return { ok: false, command: 'lab set', why: r.refused.join('; ') };
|
|
84
98
|
writeFileSync(join(g.dir, 'tunables.json'), formatTunables(r.spec));
|
|
85
99
|
return { ok: true, command: 'lab set', game: g.id, file: `games/${g.id}/tunables.json`, changed: r.changed };
|
|
@@ -179,7 +193,7 @@ class Builds {
|
|
|
179
193
|
const t0 = Date.now();
|
|
180
194
|
this.new = { ...this.new, building: true };
|
|
181
195
|
try {
|
|
182
|
-
const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', log: this.log });
|
|
196
|
+
const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', lab: true, log: this.log });
|
|
183
197
|
this.new = { ok: true, building: false, error: null, dir: out, at: new Date().toISOString(), ms: Date.now() - t0, bytes: sizeOf(join(out, 'assets', 'main.js')), metafile: r.metafile, mode: r.mode, dirty: dirtyOf(this.root, this.id) };
|
|
184
198
|
} catch (error) {
|
|
185
199
|
this.new = { ok: false, building: false, error: error.message, dir: out, at: new Date().toISOString() };
|
|
@@ -209,7 +223,7 @@ class Builds {
|
|
|
209
223
|
const g = listGames(dir).find((x) => x.id === this.id);
|
|
210
224
|
if (!g) throw new Error(`games/${this.id}/game.json is not in ${c.short}`);
|
|
211
225
|
this.esbuild ??= await studioEsbuild(this.root);
|
|
212
|
-
await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', log: this.log });
|
|
226
|
+
await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', lab: true, log: this.log });
|
|
213
227
|
let tunables = null;
|
|
214
228
|
try { tunables = readTunables(g.dir); } catch { tunables = null; }
|
|
215
229
|
const at = new Date().toISOString();
|
|
@@ -350,7 +364,7 @@ export async function startLabServer(root, { port = LAB_PORT, today = 'HEAD', lo
|
|
|
350
364
|
if (what === 'tunables') {
|
|
351
365
|
const spec = readTunables(dir);
|
|
352
366
|
if (!spec) return send(res, 400, { ok: false, why: `games/${id} has no tunables.json` });
|
|
353
|
-
const r =
|
|
367
|
+
const r = setLabTunables(spec, json.values ?? {});
|
|
354
368
|
if (r.refused.length) return send(res, 400, { ok: false, why: r.refused.join('; ') });
|
|
355
369
|
writeFileSync(join(dir, 'tunables.json'), formatTunables(r.spec));
|
|
356
370
|
log(`kept in games/${id}/tunables.json: ${r.changed.map((c) => `${c.name} ${c.from} -> ${c.to}`).join(', ') || 'nothing changed'}`);
|
|
@@ -422,7 +436,7 @@ function stateOf(root, b) {
|
|
|
422
436
|
let tunablesError = null;
|
|
423
437
|
try { tunables = readTunables(g.dir); } catch (error) { tunablesError = error.message; }
|
|
424
438
|
const pane = (p) => ({ ok: p.ok, building: p.building, error: p.error ?? null, none: p.none ?? null, at: p.at ?? null, ms: p.ms ?? null, bytes: p.bytes ?? null });
|
|
425
|
-
const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(b.today.tunables).map(([k, s]) => [k, tunableValue(s)])) : null;
|
|
439
|
+
const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(flatTunables(b.today.tunables)).map(([k, s]) => [k, tunableValue(s)])) : null;
|
|
426
440
|
return {
|
|
427
441
|
ok: true,
|
|
428
442
|
version: STUDIO_VERSION,
|
|
@@ -431,7 +445,7 @@ function stateOf(root, b) {
|
|
|
431
445
|
new: { ...pane(b.new), dirty: b.new.dirty ?? null, mode: b.new.mode ?? null },
|
|
432
446
|
today: { ...pane(b.today), ref: b.todayRef, commit: b.today.commit ?? null },
|
|
433
447
|
takes: { default: takes.default, takes: takes.takes, problems: takes.problems, file: `games/${g.id}/lab.json`, exists: takes.exists },
|
|
434
|
-
tunables: { file: `games/${g.id}/tunables.json`, spec: tunables, today: todayValues, error: tunablesError },
|
|
448
|
+
tunables: { file: `games/${g.id}/tunables.json`, spec: flatTunables(tunables), today: todayValues, error: tunablesError },
|
|
435
449
|
};
|
|
436
450
|
}
|
|
437
451
|
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
|
|
|
28
28
|
import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
|
|
29
29
|
import { newStudio, slugify } from './scaffold.mjs';
|
|
30
30
|
import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
31
|
-
import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
|
|
31
|
+
import { GAME_ID, PACKAGE_ROOT, findStudio, isRulesGame, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
|
|
32
32
|
import { runningDev } from './dev.mjs';
|
|
33
33
|
import { deployWords } from './deploy-state.mjs';
|
|
34
34
|
import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
|
|
@@ -1018,6 +1018,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1018
1018
|
if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to check yet');
|
|
1019
1019
|
const url = a.url ? String(a.url) : await devUrl(ctx, root);
|
|
1020
1020
|
return startRun(ctx, root, { kind: 'check', game, title: `${games.find((g) => g.id === game)?.name ?? game}: two-browser check`, steps: [
|
|
1021
|
+
...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
|
|
1021
1022
|
{ label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
|
|
1022
1023
|
] });
|
|
1023
1024
|
},
|