@homie-rocks/studio 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +35 -9
  3. package/app-starters/welcome/app.json +8 -1
  4. package/app-starters/welcome/map/main.json +1 -0
  5. package/app-starters/welcome/src/move.ts +3 -0
  6. package/app-starters/welcome/src/rules.ts +21 -0
  7. package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
  8. package/bin/homie-studio.mjs +3 -3
  9. package/lib/build.mjs +31 -12
  10. package/lib/codex.mjs +1 -1
  11. package/lib/mcp-tools.mjs +10 -7
  12. package/lib/mcp.mjs +2 -1
  13. package/lib/port-check.mjs +7 -4
  14. package/lib/port.mjs +2 -2
  15. package/lib/rules-build.mjs +6 -6
  16. package/lib/rules-guard.mjs +9 -3
  17. package/lib/scaffold.mjs +42 -12
  18. package/lib/studio.mjs +3 -3
  19. package/lib/template-history.json +149 -5
  20. package/lib/trailer.mjs +7 -3
  21. package/lib/typecheck.mjs +10 -8
  22. package/netplay/NETPLAY.md +8 -0
  23. package/netplay/netplay.ts +3 -0
  24. package/package.json +1 -1
  25. package/rules/host.ts +4 -2
  26. package/rules/interest.mjs +79 -0
  27. package/rules/rules.ts +13 -5
  28. package/rules/view.ts +4 -1
  29. package/starters/coin-dash/src/move.ts +2 -0
  30. package/starters/coin-dash/src/rules.ts +2 -0
  31. package/starters/coin-dash/src/view.ts +2 -0
  32. package/starters/ember-vale/src/move.ts +2 -0
  33. package/starters/ember-vale/src/rules.ts +2 -0
  34. package/starters/ember-vale/src/view.ts +2 -0
  35. package/starters/gem-rush/src/move.ts +2 -0
  36. package/starters/gem-rush/src/rules.ts +2 -0
  37. package/starters/gem-rush/src/view.ts +2 -0
  38. package/starters/gem-rush-3d/src/move.ts +2 -0
  39. package/starters/gem-rush-3d/src/rules.ts +2 -0
  40. package/starters/gem-rush-3d/src/view.ts +2 -0
  41. package/starters/hero-rush-3d/src/move.ts +2 -0
  42. package/starters/hero-rush-3d/src/rules.ts +2 -0
  43. package/starters/hero-rush-3d/src/view.ts +2 -0
  44. package/worker/room.mjs +15 -5
  45. package/worker/version.mjs +1 -1
@@ -53,9 +53,9 @@ function readMap(g) {
53
53
  const dir = join(g.dir, 'map');
54
54
  const names = existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.json')).sort() : [];
55
55
  const name = names.includes('main.json') ? 'main.json' : names[0];
56
- if (!name) throw new Error(`games/${g.id}/map/ needs a map: map/main.json with "bounds", and the "boxes", "circles" and named "spots" the rules use`);
56
+ if (!name) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/map/ needs a map: map/main.json with "bounds", and the "boxes", "circles" and named "spots" the rules use`);
57
57
  const raw = readJson(join(dir, name));
58
- if (!raw || typeof raw !== 'object') throw new Error(`games/${g.id}/map/${name} is not JSON`);
58
+ if (!raw || typeof raw !== 'object') throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/map/${name} is not JSON`);
59
59
  return { ...raw, name: name.replace(/\.json$/, '') };
60
60
  }
61
61
 
@@ -142,7 +142,7 @@ export function smokeRun(H, compiled, id, { timer = () => { const t = process.cp
142
142
  */
143
143
  export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false } = {}) {
144
144
  const guarded = await guardRules(esbuild, root, g.dir);
145
- 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` : ''}`);
145
+ if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : '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` : ''}`);
146
146
  const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
147
147
  const map = readMap(g);
148
148
  const seats = Math.max(1, Math.min(32, Math.floor(Number(g.players?.max)) || 8));
@@ -151,12 +151,12 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
151
151
  // The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
152
152
  await typecheckRules(root, g, await runRulesWorker(bundle, data, { declarationsOnly: true }), tune, { log });
153
153
  const checked = await runRulesWorker(bundle, data, { allowance: longCheck ? LONG_ALLOWANCE : ALLOWANCE });
154
- for (const p of checked.problems) log(`warning: games/${g.id}/game.json: ${p}`);
154
+ for (const p of checked.problems) log(`warning: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/${g.kind === 'app' ? 'app' : 'game'}.json: ${p}`);
155
155
  for (const line of checked.information) log(`info: ${line}`);
156
156
  const { settings, schema, publicTune, rounds, stateHash } = checked;
157
157
  const trial = RULES_CAPACITY_TRIAL;
158
158
  log(`info: rules runtime reference trial: ${trial.game}, ${trial.seats} players at ${trial.tickHz} Hz for ${trial.minutes} minutes on ${trial.host} (${trial.platform}). Cloudflare capacity and billing are unmeasured.`);
159
- if (settings.tickHz > trial.tickHz) log(`info: games/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
159
+ if (settings.tickHz > trial.tickHz) log(`info: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
160
160
  const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
161
161
  const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
162
162
  const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
@@ -168,7 +168,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
168
168
  ...(t !== 'snap' && rate > rateCap ? [`${t} ${rate}/s (cap ${rateCap}/s; over by ${rate - rateCap}/s)`] : [])];
169
169
  });
170
170
  if (exceeded.length) {
171
- 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.`;
171
+ const message = `${g.kind === 'app' ? 'apps' : '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.`;
172
172
  if (settings.host === 'browser') throw new Error(message);
173
173
  log(`warning: ${message}`);
174
174
  }
@@ -42,7 +42,7 @@ import babelTraverse from '@babel/traverse';
42
42
  import * as babelGenerator from '@babel/generator';
43
43
  import * as t from '@babel/types';
44
44
  import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
45
- import { existsSync, readFileSync, statSync } from 'node:fs';
45
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
46
46
  import { dirname, join, relative, resolve } from 'node:path';
47
47
 
48
48
  const traverse = babelTraverse.default ?? babelTraverse;
@@ -757,12 +757,18 @@ export async function guardFiles(esbuild, root, gameDir, entry) {
757
757
 
758
758
  /** An esbuild plugin that serves a game's guarded files in place of its sources (`stub`: files a bundle must not hold at all). */
759
759
  export function guardedPlugin(files, { stub = [] } = {}) {
760
+ // esbuild resolves filesystem aliases (including macOS /tmp -> /private/tmp).
761
+ // Match those paths to the exact sources already checked and rewritten.
762
+ const canonical = (file) => existsSync(file) ? realpathSync(file) : file;
763
+ const guarded = new Map([...files].map(([file, code]) => [canonical(file), code]));
764
+ const omitted = new Set(stub.map(canonical));
760
765
  return {
761
766
  name: 'homie-rules-guard',
762
767
  setup(b) {
763
768
  b.onLoad({ filter: /\.(?:ts|js|mjs)$/ }, (a) => {
764
- if (stub.includes(a.path)) return { contents: 'export default {};\n', loader: 'js' };
765
- const code = files.get(a.path);
769
+ const file = canonical(a.path);
770
+ if (omitted.has(file)) return { contents: 'export default {};\n', loader: 'js' };
771
+ const code = guarded.get(file);
766
772
  return typeof code === 'string' ? { contents: code, loader: 'js' } : null;
767
773
  });
768
774
  },
package/lib/scaffold.mjs CHANGED
@@ -126,7 +126,7 @@ directory lists its games; homie.rocks does not host them.
126
126
 
127
127
  | Path | What it is |
128
128
  | --- | --- |
129
- | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
129
+ | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/rules.ts\`, \`src/move.ts\`, \`src/view.ts\` and shared \`map/\` data; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
130
130
  | \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
131
131
  | \`games/<id>/assets/\` | Every model and texture a game ships, with where it came from and its licence (\`manifest.json\`), and \`RIGHTS.md\` in plain words; \`games/<id>/codex/decisions.json\` (private, like the codex) is its look as decisions, and \`style.json\` the palette, fonts, light and camera the game draws with. \`art/<slug>/\` holds art jobs (concepts, receipts; \`raw/\` is git-ignored). |
132
132
  | \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
@@ -171,7 +171,7 @@ studio's pinned copy, never a registry lookup of the bare name.
171
171
  game: copy a starter only when the person asks for one, or once their game is planned.
172
172
  - \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
173
173
  how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
174
- owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
174
+ owner tests (real touch, a late joiner, one browser leaving, two browsers finishing a round). The Homie
175
175
  plugin's \`port\` skill does the whole job.
176
176
  - \`npm run build\` — bundle every game into \`site/dist\`. It builds in a folder of its own and puts the result
177
177
  in place only when all of it is there: a game that does not build fails the command (a non-zero exit) and
@@ -197,7 +197,7 @@ studio's pinned copy, never a registry lookup of the bare name.
197
197
  - \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
198
198
  land in the same room and finish a round. Run it before you say a game works.
199
199
  - \`npx --no-install homie-studio perf <id> --url <site>\` — how fast a game runs, on a computer and an emulated
200
- phone, with two browsers in a room (the host and a replica): frame times, the game's JavaScript and the main thread
200
+ phone, with two browsers in a room (two replicas when the server hosts): frame times, the game's JavaScript and the main thread
201
201
  per frame, time to playable, what it downloads, the heap, netplay messages a second (files under \`.perf/\`). The
202
202
  Homie plugin's \`perf\` skill runs the whole loop: one change at a time, kept only when it is better beyond the noise
203
203
  and \`check\` still passes, and a report in \`perf/<id>/\`.
@@ -279,11 +279,37 @@ studio's pinned copy, never a registry lookup of the bare name.
279
279
 
280
280
  ## Making games
281
281
 
282
- - Every game is multiplayer on the web through the netplay contract
283
- (\`node_modules/@homie-rocks/studio/netplay/NETPLAY.md\`): every browser renders the game
284
- itself, one browser hosts the rules, strangers meet in public rooms, bots fill empty
285
- seats, anyone arriving takes a bot's place, rounds end and restart on their own.
286
- - Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
282
+ - **New games are rules plus view, hosted by the server.** Read the Homie game skill and its
283
+ RULES.md before writing code (MCP: studio_guide game, then studio_guide game file RULES.md).
284
+ Start with coin-dash, gem-rush, ember-vale, gem-rush-3d or hero-rush-3d; all five follow this contract.
285
+ In games/<id>/src/rules.ts, defineRules declares entities, fields, handlers, events, rounds and companions.
286
+ The view calls openRoom and sends input/commands; it draws, plays sound and handles controls. It never
287
+ decides a hit, score, spawn, turn or result. Keep game.json room.host set to server, up to 32 seats.
288
+ - **Movement feels immediate through prediction.** Put the shared defineMove callback in src/move.ts;
289
+ it runs authoritatively on the server and immediately for your own view, then reconciles. Other bodies
290
+ interpolate. Use the starter's sweep/slide and room.me/room.each poses; no second browser physics loop,
291
+ and no owner-movement workaround. Discrete actions wait for the server's decision.
292
+ - **Truth is declared and deterministic.** Entity handlers write their own fields and send next-tick events;
293
+ room handlers write shared fields. Use world.tick/dt/ticks/after/random and world.math (ctx.math in move),
294
+ never clocks, Math.random, hidden module state, async/network calls or browser APIs in rules.
295
+ Read RULES.md for the bounded query and execution budget, declared commands/effects/asks, and message fixes.
296
+ - **Level data is shared.** map/main.json describes collision and bounds in metres. A 3D rules body's z is
297
+ height at its feet; Three.js draws x,z,y. Floors, walls and platforms must agree in rules and view.
298
+ Public tunables describe movement. Bots and companions use the same inputs and declared goals, with a
299
+ scripted floor that plays without AI. Asks declare bounded state, questions and a deterministic floor.
300
+ - **Choose sensible defaults yourself.** Do not ask the person for a hosting mode, movement mode, tick rate
301
+ or serialization scheme. Preserve the requested mechanic. Turn-based games and live apps may use manual
302
+ rounds. Shared live app state uses these same rooms; durable records and authentication use the app APIs.
303
+ A build-only request ends with a working local result; publish only within the owner's requested scope.
304
+ - **Check changes honestly.** Build first, repair each source-located guard/type/play-check error using RULES.md,
305
+ then check with two browsers and play the requested mechanic with network delay. Info is not a failure.
306
+ Never remove rules, weaken checks, forge a probe or switch hosting to get past a message.
307
+ Rules hashes and room saves are automatic; character saves are player-owned, untrusted input. Room state
308
+ is replicated, not secret. Bigger rooms and private state are not delivered by milestone 1.
309
+ - **Existing games:** change rules truth in rules and presentation in view. Browser hosting is for offline,
310
+ local development and private friends games. Untouched older createNetplay/createRoom games still work;
311
+ when asked to rewrite one, read the game skill's REWRITE.md and preserve its art, view and feel. There is
312
+ no automatic converter. NETPLAY.md section 29 is the rules contract; earlier sections describe older APIs.
287
313
  - Phones and computers: touch controls on phones only, keys on computers; keep the
288
314
  centre of the screen clear during play.
289
315
  - The play page's small room button (Invite, Big screen, the room code) sits top right, with the Chat pill
@@ -433,7 +459,10 @@ studio's pinned copy, never a registry lookup of the bare name.
433
459
  \`--policy beginner --guides 2 [--kids]\`, \`--policy humans-only\`) makes one at once; \`servers set <id> <server>
434
460
  --level-max 3 …\` changes one; \`servers close <id> <server>\`. A change that narrows who may come in (humans-only,
435
461
  a stricter door) and closing one only ASK, with the owner's one-tap link, like \`office kick\`.
436
- - **The skill dial:** every room has a level, 1 Rookie, 2 Steady, 3 Fair, 4 Strong, 5 Maxed, each \`{ reactionMs,
462
+ - **Rules companions:** use world.level, world.guideLevel, world.guideSeats and world.kids; guide.view,
463
+ guide.floor, think and goalDone follow the rules contract. The old BotBrain/useAgents/decide recipes below
464
+ apply only to unchanged browser-hosted games. Read RULES.md for a new game.
465
+ - **The skill dial (older browser-hosted games):** every room has a level, 1 Rookie, 2 Steady, 3 Fair, 4 Strong, 5 Maxed, each \`{ reactionMs,
437
466
  aimNoise, aggression, positioning }\`. The party sets it by voting on a card in the play page (the middle vote
438
467
  wins, capped by the server's ceiling). Make a game's bots honour it: \`net.skillOf(slot)\` in their step (the
439
468
  snippet is in \`NETPLAY.md\` section 17; \`BotBrain\` from the port kit reads it with a rebuild), and declare
@@ -1018,10 +1047,11 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
1018
1047
  const q = JSON.stringify(realpathSync(dir));
1019
1048
  return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: [
1020
1049
  `cd ${q}${install ? '' : ' && npm install'}`,
1021
- 'see a working game first, with nothing copied here: npx --no-install homie-studio demo (a live game on Homie Arcade)',
1022
- 'a copy of a starter only when the person asks for one: npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
1050
+ 'read the game skill and RULES.md; choose sensible defaults and make the requested game as server rules plus view',
1051
+ 'optional live example, without copying a game here: npx --no-install homie-studio demo',
1052
+ 'only when the person asks for a game: start from a rules starter and implement the requested mechanic: npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
1023
1053
  'npm run dev (the home page, "first game coming soon", at http://127.0.0.1:8787/; with a game: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
1024
- 'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
1054
+ 'when the owner asks to publish: npm run deploy (then studio_publish, or: npx --no-install homie-studio publish)',
1025
1055
  ], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
1026
1056
  }
1027
1057
 
package/lib/studio.mjs CHANGED
@@ -108,7 +108,7 @@ function listKind(root, kind) {
108
108
  /**
109
109
  * RULES ON THE SERVER (NETPLAY.md section 29). A game written as rules plus view says so in game.json with a `"room"`
110
110
  * object (where its rules run, its tick rate…; rules/rules.ts `roomSettings` checks them at build) and has a
111
- * src/rules.ts. Any other game is hosted by a player's browser, as before. The file alone decides nothing: games made
111
+ * src/rules.ts. Other games need the browser-hosted netplay contract checked by the build. The file alone decides nothing: games made
112
112
  * before this had their own src/rules.ts, with whatever they liked in it, and they build exactly as they did.
113
113
  */
114
114
  export const isRulesGame = (g) => g?.room != null && typeof g.room === 'object' && existsSync(join(g.dir, 'src', 'rules.ts'));
@@ -129,7 +129,7 @@ export function starters() {
129
129
  const dir = join(PACKAGE_ROOT, 'starters');
130
130
  return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
131
131
  const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
132
- // `example`: a starter that shows how something is written (coin-dash: rules on the server) and is not yet offered as the start of a new game.
132
+ // `example`: marks the smallest reference (coin-dash); all starters use server rules and can start a new game.
133
133
  return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds, ...(meta.example === true ? { example: true } : {}) };
134
134
  });
135
135
  }
@@ -309,7 +309,7 @@ export async function newApp(root, id, { name } = {}) {
309
309
  const meta = JSON.parse(readFileSync(file, 'utf8'));
310
310
  meta.id = id; meta.name = String(name ?? 'Welcome together').slice(0, 60);
311
311
  writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
312
- const main = join(dest, 'src/main.ts');
312
+ const main = join(dest, meta.entry ?? 'src/main.ts');
313
313
  writeFileSync(main, readFileSync(main, 'utf8').replaceAll("game: 'welcome'", `game: '${id}'`));
314
314
  return { ok: true, command: 'app new', id, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
315
315
  }
@@ -2199,6 +2199,68 @@
2199
2199
  }
2200
2200
  },
2201
2201
  "0.40.0": {
2202
+ "files": {
2203
+ "CLAUDE.md": "ddf2b2a0376202f6",
2204
+ "README.md": "684b3cca8001673c",
2205
+ "HANDOFF.md": "29337e2ace95b2e7",
2206
+ "games/README.md": "5ad32fe80c61f117",
2207
+ "music/README.md": "a3dc042dadd39df8",
2208
+ "videos/README.md": "16babc3437d5545b",
2209
+ "posts/README.md": "37841aae476bd540",
2210
+ "site/README.md": "732894273a32dc3f",
2211
+ "site/src/worker.mjs": [
2212
+ "34444a6ca4b14437",
2213
+ "fc90f3609cad18ba"
2214
+ ]
2215
+ },
2216
+ "sections": {
2217
+ "AGENTS.md": {
2218
+ "(intro)": "214aa46eb3a895d5",
2219
+ "## Layout": [
2220
+ "c18362ed28975d81",
2221
+ "f3a280b8c96cd068"
2222
+ ],
2223
+ "## Apps: one screen, roles and parts": [
2224
+ "4db0abe4cdfac09f",
2225
+ "be6f8fa45f93211a"
2226
+ ],
2227
+ "## Commands (all through the pinned CLI in node_modules)": [
2228
+ "a7f290579585368c",
2229
+ "c920ac84d44c872a"
2230
+ ],
2231
+ "## The site": "0cb8dd9a6f8a0096",
2232
+ "## Making games": [
2233
+ "2da7fa74c8884834",
2234
+ "9e44559de535d842"
2235
+ ],
2236
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2237
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2238
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2239
+ "## Servers and AI seats": [
2240
+ "98d08705087a33ec",
2241
+ "d0028361fe567add"
2242
+ ],
2243
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2244
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2245
+ "## Rules": "2e6b29afb85dcf7e",
2246
+ "## Telling Homie": "80b46fdd9e578db8",
2247
+ "## Beta": "9d02638207a26e73"
2248
+ }
2249
+ },
2250
+ "named": {
2251
+ "Homie Arcade": {
2252
+ "sections": {
2253
+ "AGENTS.md": {
2254
+ "## Commands (all through the pinned CLI in node_modules)": [
2255
+ "7cad8fd10f62fef1",
2256
+ "cae1df6235f51d17"
2257
+ ]
2258
+ }
2259
+ }
2260
+ }
2261
+ }
2262
+ },
2263
+ "0.41.0": {
2202
2264
  "files": {
2203
2265
  "CLAUDE.md": "ddf2b2a0376202f6",
2204
2266
  "README.md": "684b3cca8001673c",
@@ -2213,15 +2275,15 @@
2213
2275
  "sections": {
2214
2276
  "AGENTS.md": {
2215
2277
  "(intro)": "214aa46eb3a895d5",
2216
- "## Layout": "c18362ed28975d81",
2278
+ "## Layout": "f3a280b8c96cd068",
2217
2279
  "## Apps: one screen, roles and parts": "be6f8fa45f93211a",
2218
- "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2280
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2219
2281
  "## The site": "0cb8dd9a6f8a0096",
2220
- "## Making games": "2da7fa74c8884834",
2282
+ "## Making games": "9e44559de535d842",
2221
2283
  "## The Game Codex and progress": "0738ef719cef3a4c",
2222
2284
  "## Art direction and models": "a48fff7bf0a5f2fc",
2223
2285
  "## Running live games (the back office)": "51ebf2ab756f6e4d",
2224
- "## Servers and AI seats": "98d08705087a33ec",
2286
+ "## Servers and AI seats": "d0028361fe567add",
2225
2287
  "## Selling things (the shop)": "9e020d69dd6a954e",
2226
2288
  "## Continuing a build from the Claude app": "0d68081e924ca419",
2227
2289
  "## Rules": "2e6b29afb85dcf7e",
@@ -2233,7 +2295,7 @@
2233
2295
  "Homie Arcade": {
2234
2296
  "sections": {
2235
2297
  "AGENTS.md": {
2236
- "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2298
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2237
2299
  }
2238
2300
  }
2239
2301
  }
@@ -2282,6 +2344,88 @@
2282
2344
  }
2283
2345
  }
2284
2346
  }
2347
+ },
2348
+ "0.43.0": {
2349
+ "files": {
2350
+ "CLAUDE.md": "ddf2b2a0376202f6",
2351
+ "README.md": "684b3cca8001673c",
2352
+ "HANDOFF.md": "29337e2ace95b2e7",
2353
+ "games/README.md": "5ad32fe80c61f117",
2354
+ "music/README.md": "a3dc042dadd39df8",
2355
+ "videos/README.md": "16babc3437d5545b",
2356
+ "posts/README.md": "37841aae476bd540",
2357
+ "site/README.md": "732894273a32dc3f",
2358
+ "site/src/worker.mjs": "34444a6ca4b14437"
2359
+ },
2360
+ "sections": {
2361
+ "AGENTS.md": {
2362
+ "(intro)": "214aa46eb3a895d5",
2363
+ "## Layout": "f3a280b8c96cd068",
2364
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2365
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2366
+ "## The site": "0cb8dd9a6f8a0096",
2367
+ "## Making games": "9e44559de535d842",
2368
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2369
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2370
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2371
+ "## Servers and AI seats": "d0028361fe567add",
2372
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2373
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2374
+ "## Rules": "2e6b29afb85dcf7e",
2375
+ "## Telling Homie": "80b46fdd9e578db8",
2376
+ "## Beta": "9d02638207a26e73"
2377
+ }
2378
+ },
2379
+ "named": {
2380
+ "Homie Arcade": {
2381
+ "sections": {
2382
+ "AGENTS.md": {
2383
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2384
+ }
2385
+ }
2386
+ }
2387
+ }
2388
+ },
2389
+ "0.44.0": {
2390
+ "files": {
2391
+ "CLAUDE.md": "ddf2b2a0376202f6",
2392
+ "README.md": "684b3cca8001673c",
2393
+ "HANDOFF.md": "29337e2ace95b2e7",
2394
+ "games/README.md": "5ad32fe80c61f117",
2395
+ "music/README.md": "a3dc042dadd39df8",
2396
+ "videos/README.md": "16babc3437d5545b",
2397
+ "posts/README.md": "37841aae476bd540",
2398
+ "site/README.md": "732894273a32dc3f",
2399
+ "site/src/worker.mjs": "34444a6ca4b14437"
2400
+ },
2401
+ "sections": {
2402
+ "AGENTS.md": {
2403
+ "(intro)": "214aa46eb3a895d5",
2404
+ "## Layout": "f3a280b8c96cd068",
2405
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2406
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2407
+ "## The site": "0cb8dd9a6f8a0096",
2408
+ "## Making games": "9e44559de535d842",
2409
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2410
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2411
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2412
+ "## Servers and AI seats": "d0028361fe567add",
2413
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2414
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2415
+ "## Rules": "2e6b29afb85dcf7e",
2416
+ "## Telling Homie": "80b46fdd9e578db8",
2417
+ "## Beta": "9d02638207a26e73"
2418
+ }
2419
+ },
2420
+ "named": {
2421
+ "Homie Arcade": {
2422
+ "sections": {
2423
+ "AGENTS.md": {
2424
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2425
+ }
2426
+ }
2427
+ }
2428
+ }
2285
2429
  }
2286
2430
  }
2287
2431
  }
package/lib/trailer.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `homie-studio trailer <id>`: a trailer of a game, in one command.
3
3
  *
4
- * The work is not here. It is the Homie plugin's video skill (`video.mjs trailer`): the game rendered frame by frame
5
- * on a virtual clock, its sound rebuilt from its own files, the highlights picked from what it played, an end card,
4
+ * The work is not here. It is the Homie plugin's video skill (`video.mjs trailer`): live footage for server rules,
5
+ * or a virtual clock and rebuilt sound for older browser games, selected highlights, an end card,
6
6
  * and 16:9, 1:1 and 9:16 deliveries. This finds that script and hands the command over, so a person at a terminal
7
7
  * and an agent with the plugin run the same thing. The video goes to videos/<id>-trailer/ (or --slug <slug>).
8
8
  *
@@ -36,10 +36,14 @@ export function trailerCommand(root, id, rest, { skills = null, slug = null } =
36
36
  }
37
37
 
38
38
  export function trailerLines(r) {
39
+ const live = r.capture?.mode === 'live';
40
+ const audio = r.capture?.audio;
41
+ const sound = audio?.rebuilt ? `${audio.placed} sounds rebuilt from the game's own files`
42
+ : audio ? 'game audio captured live' : live ? 'no audio captured' : 'the game logged no sound';
39
43
  return [
40
44
  ...(r.problems ?? []).map((x) => `PROBLEM: ${x}`),
41
45
  `${r.seconds} s trailer of ${r.game}: ${Object.values(r.outputs ?? {}).map((o) => o.file).join(', ')}`,
42
- ` filmed ${r.capture?.seconds} s frame by frame (${r.capture?.frames} frames, ${r.capture?.heldFrames} held); ${r.capture?.audio ? `${r.capture.audio.placed} sounds rebuilt from the game's own files` : 'the game logged no sound'}`,
46
+ ` filmed ${r.capture?.seconds} s ${live ? 'live' : 'frame by frame'} (${r.capture?.frames} frames, ${r.capture?.heldFrames} held); ${sound}`,
43
47
  ` ${r.edit?.shots} shots of ${r.edit?.shotSeconds} s, picked ${r.edit?.picked}`,
44
48
  ...(r.warnings ?? []).map((w) => ` warning: ${w}`),
45
49
  ...(r.next ? [` next (the video skill's script): ${r.next}`] : []),
package/lib/typecheck.mjs CHANGED
@@ -93,12 +93,12 @@ export function typecheck(root, games, { log = () => {} } = {}) {
93
93
  }
94
94
  // An error with no file is the project itself (a tsconfig.json that does not read): the game's to fix.
95
95
  const bare = /^error (TS\d+): (.*)$/.exec(line);
96
- if (bare) mine.push(`games/${g.id}${own ? '/tsconfig.json' : ''} ${bare[1]} ${bare[2]}`);
96
+ if (bare) mine.push(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}${own ? '/tsconfig.json' : ''} ${bare[1]} ${bare[2]}`);
97
97
  }
98
98
  rows.push({ id: g.id, checked: true, with: own ? 'its tsconfig.json' : 'the build\'s own settings', errors: mine.length, elsewhere });
99
- if (elsewhere) log(`types: games/${g.id}: ${elsewhere} ${elsewhere === 1 ? 'error' : 'errors'} outside the game's own files (a package it imports as source); not the game's, so not counted`);
99
+ if (elsewhere) log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: ${elsewhere} ${elsewhere === 1 ? 'error' : 'errors'} outside the game's own files (a package it imports as source); not the game's, so not counted`);
100
100
  if (mine.length) found.push(...mine);
101
- else log(`types: games/${g.id} has no type errors (TypeScript ${ts.version ?? '?'}, ${own ? 'its tsconfig.json' : 'the build\'s own settings'})`);
101
+ else log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id} has no type errors (TypeScript ${ts.version ?? '?'}, ${own ? 'its tsconfig.json' : 'the build\'s own settings'})`);
102
102
  }
103
103
  if (found.length) {
104
104
  const shown = found.slice(0, 20);
@@ -125,7 +125,7 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
125
125
  '@homie-rocks/studio/*': [join(PACKAGE_ROOT, '*')],
126
126
  } }, files: [join(g.dir, 'src/rules.ts'), join(g.dir, g.entry ?? 'src/view.ts'), join(dir, 'files.d.ts')] }, null, 2)}\n`);
127
127
  const res = await runRulesCompiler(compiler, ['-p', project, '--pretty', 'false']);
128
- if (res.error) throw new Error(`games/${g.id}: the rules type check could not finish: ${res.error.message}`);
128
+ if (res.error) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: the rules type check could not finish: ${res.error.message}`);
129
129
  const messages = [];
130
130
  for (const line of `${res.stdout ?? ''}\n${res.stderr ?? ''}`.split(/\r?\n/)) {
131
131
  const at = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
@@ -134,7 +134,9 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
134
134
  // Imported game helpers count too. Only the toolkit's own implementation is outside the author's remit.
135
135
  if (!file.startsWith(`${PACKAGE_ROOT}${sep}`) && !file.includes(`${sep}node_modules${sep}`) || file.startsWith(`${g.dir}${sep}`)) {
136
136
  const sourceLine = readFileSync(file,'utf8').split(/\r?\n/)[Number(at[2])-1] ?? '';
137
- const advice = /Property 'ask'.*type 'never'/.test(at[5]) ? 'This handler scope never calls world.ask, so no answer can arrive here. Handle the answer in the scope that asks.'
137
+ const advice = /Property 'bot'.*RosterRow/.test(at[5]) ? "Rules roster rows use driver === 'bot' or 'ai' (not row.bot); preserve the visible AI label."
138
+ : /'[^']*\.at' is possibly 'undefined'/.test(at[5]) ? 'Effects carry either at (position) or id (entity). Check e.at before reading coordinates, or resolve e.id with room.get.'
139
+ : /Property 'ask'.*type 'never'/.test(at[5]) ? 'This handler scope never calls world.ask, so no answer can arrive here. Handle the answer in the scope that asks.'
138
140
  : /world\.round\.end/.test(sourceLine) && at[4] === 'TS2339' ? 'Only room handlers end a round. Send a declared event to a room handler that calls world.round.end().'
139
141
  : /'check'.*does not exist/.test(at[5]) ? 'Remove the obsolete check key. Generated observations require no exemption.'
140
142
  : /=> (?:GameDecision|GuideDecision)'/.test(at[5]) ? 'A floor returns goal and say names from agents.json with exactly the args and sayArgs each declares, or {} to decline. Forward a request as { goal: ask.k, args: ask.args }.'
@@ -150,7 +152,7 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
150
152
  } else if (/^error TS/.test(line)) messages.push(line);
151
153
  else if (/^\s+\S/.test(line) && messages.length) messages[messages.length - 1] += `\n${line.length > 360 ? line.slice(0, 220) + ' … ' + line.slice(-100) : line}`;
152
154
  }
153
- if (messages.length) throw new Error(`games/${g.id}: its rules have type errors:\n${messages.slice(0, 30).map((m) => ` ${m}`).join('\n')}`);
154
- if (res.status !== 0 && !res.stdout) throw new Error(`games/${g.id}: TypeScript stopped without diagnostics (${res.signal ?? res.status})`);
155
- log(`types: games/${g.id}: rules, move and view checked with the toolkit's strict compiler`);
155
+ if (messages.length) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules have type errors:\n${messages.slice(0, 30).map((m) => ` ${m}`).join('\n')}`);
156
+ if (res.status !== 0 && !res.stdout) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: TypeScript stopped without diagnostics (${res.signal ?? res.status})`);
157
+ log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: rules, move and view checked with the toolkit's strict compiler`);
156
158
  }
@@ -1,5 +1,13 @@
1
1
  # Homie netplay contract, v1 (revision 11)
2
2
 
3
+ **Authoring now:** New games use rules plus view and the server as host. Read the
4
+ Homie game skill's RULES.md and section 29 below. Sections 1–28 preserve the
5
+ browser-hosted helper contract for existing games and describe shared shell
6
+ features; their createNetplay/host-loop examples are not a recipe for new games.
7
+ Use the skill's REWRITE.md for an owner-requested rewrite. All five starters use
8
+ server rules. Room seats are capped at 32; larger rooms are future work.
9
+
10
+
3
11
  Status: **v1, revision 11** (2026-10-08, `@homie-rocks/studio` 0.33.1). The wire
4
12
  version is `v: 1`. Everything revisions 2 to 11 added is either an optional field, a new message type,
5
13
  a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
@@ -2628,6 +2628,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2628
2628
  setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
2629
2629
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
2630
2630
  }
2631
+ // A reconnect to the same role emits no role event, but its welcome is a new
2632
+ // connection's keyframe. Rules views must adopt it before the first delta.
2633
+ if (rulesGame && snap && next !== 'host') emit('snapshot', snap);
2631
2634
  if (continuing) { rulesHost?.sync(rulesPeers()); reannounce(); catchUp(had, hadSeat); }
2632
2635
  if (link === 'online') paintLink(); else setLink('online', 'welcome');
2633
2636
  // A build older than the live one, let in to its own room (section 23).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/host.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * host.ts — the host runtime: a rules module playing the host's part on the wire.
3
3
  * =============================================================================
4
4
  *
5
- * Today one player's browser is a room's host: it runs the rules and the clock and sends everybody the state. The host
6
- * runtime is that host with no browser in it. It takes a compiled rules module (rules.ts) and three things from its
5
+ * The server normally hosts the room: rules, clock and state. Browser hosting uses the same runtime
6
+ * for offline play, local development or private friends games. It takes a compiled rules module (rules.ts) and three things from its
7
7
  * caller: a way to send a frame, a clock and a store. It has no transport in it and no Cloudflare in it, in the style of
8
8
  * worker/room.mjs. Inside the `Table` (worker/index.mjs) the relay hands it parsed frames and fans out what it sends;
9
9
  * in Node the tests and the build check drive it with a clock of their own.
@@ -99,6 +99,7 @@ export interface Host {
99
99
  readonly running: boolean;
100
100
  readonly paused: boolean;
101
101
  readonly people: number;
102
+ readonly viewRadiusM: number | null;
102
103
  frame(m: Record<string, unknown>): void;
103
104
  /** Start the tick loop (the first person has joined). */
104
105
  start(): void;
@@ -767,6 +768,7 @@ export function createHost(o: HostOptions): Host {
767
768
  }
768
769
 
769
770
  return {
771
+ get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
770
772
  get tick() { return core.tick; },
771
773
  get epoch() { return epoch; },
772
774
  get running() { return running; },
@@ -0,0 +1,79 @@
1
+ /** Per-connection state delivery. Pure data, shared by the relay and the view.
2
+ * Deltas name a keyframe, never an unacknowledged previous delta. Losing a delta
3
+ * therefore loses no baseline. A new keyframe bounds recovery to one second.
4
+ * No history grows with time: one keyframe per connection, no per-frame queue.
5
+ */
6
+ export function interestSnapshot(snap, seat, radiusM) {
7
+ const [round, entities] = snap.d;
8
+ const own = seat === null ? null : entities.find(e => e[9] === seat && e[10] === 0);
9
+ const visible = own && radiusM !== null ? entities.filter(e => {
10
+ const p = e[3], q = own[3];
11
+ return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
12
+ }) : seat === null ? entities : own ? entities : [];
13
+ return { ...snap, d: [round, visible], c: (snap.c ?? []).filter(row => row[0] === seat) };
14
+ }
15
+
16
+ export function snapshotEncoder(keyframeTicks) {
17
+ let base = null;
18
+ let rows = new Map();
19
+ return {
20
+ reset() { base = null; rows.clear(); },
21
+ encode(snap) {
22
+ if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - base.k >= keyframeTicks) {
23
+ base = snap;
24
+ rows = new Map(snap.d[1].map(e => [e[0], e.map(v => JSON.stringify(v))]));
25
+ return snap;
26
+ }
27
+ const present = new Set(), changed = [];
28
+ for (const entity of snap.d[1]) {
29
+ const id = entity[0]; present.add(id);
30
+ const before = rows.get(id);
31
+ if (!before) { changed.push(entity); continue; }
32
+ let mask = 0; const values = [];
33
+ for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
34
+ if (mask) changed.push([id, mask, values]);
35
+ }
36
+ return { ...snap, d: { base: base.k, round: snap.d[0], changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
37
+ },
38
+ };
39
+ }
40
+
41
+ export function snapshotDecoder() {
42
+ let base = null;
43
+ let rows = new Map();
44
+ return {
45
+ reset() { base = null; rows.clear(); },
46
+ decode(snap) {
47
+ if (!snap || typeof snap !== 'object') return null;
48
+ if (Array.isArray(snap.d)) {
49
+ if (!Array.isArray(snap.d[0]) || !Array.isArray(snap.d[1]) ||
50
+ !snap.d[1].every(e => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string')) return null;
51
+ // A late duplicate keyframe must not replace the newer baseline.
52
+ if (!base || snap.e !== base.e || snap.k >= base.k) {
53
+ base = snap; rows = new Map(snap.d[1].map(e => [e[0], e]));
54
+ }
55
+ return snap;
56
+ }
57
+ const d = snap.d;
58
+ if (!base || !d || d.base !== base.k || snap.e !== base.e || snap.k <= base.k) return null;
59
+ if (!Array.isArray(d.round) || !Array.isArray(d.removed) || !Array.isArray(d.changed)) return null;
60
+ const next = new Map(rows);
61
+ for (const id of d.removed) next.delete(id);
62
+ for (const change of d.changed) {
63
+ if (!Array.isArray(change) || typeof change[0] !== 'string') return null;
64
+ if (change.length !== 3) {
65
+ if (change.length < 9) return null;
66
+ next.set(change[0], change); continue;
67
+ }
68
+ if (!Number.isInteger(change[1]) || change[1] < 0 || !Array.isArray(change[2])) return null;
69
+ const old = rows.get(change[0]);
70
+ if (!old) return null;
71
+ const entity = old.slice(); let j = 0;
72
+ for (let i = 1; i < entity.length; i++) if (change[1] & (1 << i)) entity[i] = change[2][j++];
73
+ if (j !== change[2].length) return null;
74
+ next.set(entity[0], entity);
75
+ }
76
+ return { ...snap, d: [d.round, [...next.values()]] };
77
+ },
78
+ };
79
+ }