@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
package/CHANGELOG.md CHANGED
@@ -17,6 +17,48 @@ 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.44.0] - 2026-10-10
21
+
22
+ **Plugin 0.44.0** · [#83](https://github.com/homie-rocks/homie/pull/83)
23
+
24
+ Send each player the part of the room near their character.
25
+
26
+ ### Added
27
+
28
+ - Server-hosted rules games can choose `room.view.radiusM` in game.json. Views receive nearby entities and their own body, with compact state updates and fresh state on reconnect. Omit it to keep the whole-room view.
29
+ - Seeded tests cover spatial visibility, delta recovery and 300 simulated clients through the host and relay. The longer capacity workload runs in the extended suite.
30
+
31
+ ### Upgrade notes
32
+
33
+ - `room.each`, `enter` and `leave` describe visible entities when a radius is set. Dispose of departed meshes and recreate them when they return. Rules and movement need no changes; existing starters keep their whole-room views.
34
+ - This is the first bigger-rooms slice. The public build/join path still has 32 seats until Gates ship in the next slice. The 300-client proof is local, not a Cloudflare capacity measurement. Interest filtering does not hide secrets; private fields and tells follow later.
35
+
36
+ ## [0.43.0] - 2026-10-09
37
+
38
+ **Plugin 0.43.0** · [release-2026-10-10-studio-0.43.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-10-studio-0.43.0)
39
+
40
+ Ask for a game and its rules run on your server.
41
+
42
+ ### Added
43
+
44
+ - The game skill teaches the whole rules-and-view workflow: shared truth, immediate movement, levels, companions, saving and the checks that prove the requested game plays. Your AI chooses the engineering defaults.
45
+ - A guided rewrite keeps an older game's look, controls and feel while moving its rules to the server. It preserves the studio's other games and existing tests; there is no automatic converter.
46
+
47
+ ### Changed
48
+
49
+ - Apps that need a shared live screen now start with the same server rooms. A quiz, queue or turn-based app gets real actions and shared state without pretend movement or compulsory game rounds.
50
+ - Playtests explain stationary controls and intentional freezes. Performance reports measure both players when the server hosts. Covers and trailers use a live rules room and explain when an empty spectator room is paused.
51
+
52
+ ### Fixed
53
+
54
+ - Builds explain the exact script tag a game page needs, check rules types once, and keep guarded builds working through a temporary-directory alias.
55
+ - Builds refuse a game with neither rules plus view nor a browser-hosted netplay contract, instead of silently calling an unconnected page multiplayer. Existing netplay games keep their compatibility path.
56
+
57
+ ### Upgrade notes
58
+
59
+ - Existing games keep working as they are. A rewrite happens only when requested. Building a game does not ask for publication or deploy it.
60
+ - Rooms still hold at most 32 seats. Larger rooms are a later milestone; Cloudflare capacity and billing remain unmeasured.
61
+
20
62
  ## [0.42.0] - 2026-10-09
21
63
 
22
64
  **Plugin 0.42.0**
@@ -46,7 +88,7 @@ Customers and other businesses can use a studio's tools, buy its goods and servi
46
88
 
47
89
  ## [0.40.0] - 2026-10-09
48
90
 
49
- **Plugin 0.40.0**
91
+ **Plugin 0.40.0** · [#79](https://github.com/homie-rocks/homie/pull/79)
50
92
 
51
93
  New 3D games keep their matches on the server and answer your controls immediately.
52
94
 
package/README.md CHANGED
@@ -38,6 +38,14 @@ A new studio has no game: its home page says "First game coming soon" until the
38
38
  `homie-studio demo` names a live game on Homie Arcade to try meanwhile. A starter is copied in only when the person
39
39
  asks (`game new <id> --from gem-rush`, or `--from ember-vale` for a hero who lasts, with cloud saves).
40
40
 
41
+ New games are rules plus view: `src/rules.ts` declares state and server decisions,
42
+ `src/move.ts` supplies shared movement and local prediction, and `src/view.ts` draws
43
+ and sends input through `openRoom`. All five starters declare `room.host: server`.
44
+ Follow the game skill and `RULES.md`; `build` checks rules, movement and view types
45
+ and refuses a game with neither rules nor a browser-hosted netplay contract.
46
+ The same rules support browser hosting for offline, local and private play.
47
+ Existing browser-hosted games remain supported; rewriting one is an explicit task.
48
+
41
49
  ## In one chat: `homie-studio mcp`
42
50
 
43
51
  ```sh
@@ -143,7 +151,7 @@ Anyone can **watch** a live room from any player's view at `/<game>/watch?room=<
143
151
  Join on every room): the game itself, drawn by the watcher's own browser as a watcher that never takes a seat,
144
152
  with a strip of the players to switch between (a tap, keys 1-9, Auto, the whole room). A game draws the followed
145
153
  player's camera and HUD from `net.viewSeat` (Gem Rush and Ember Vale do; a game that does not is watched as its
146
- overview), and game.json `"watch": "overview"` or `false` keeps hidden hands hidden. Private and invite-only games
154
+ overview), and game.json `"watch": "overview"` or `false` changes the watch UI. Rules state is public to replicas; these settings do not secure hidden hands. Private and invite-only games
147
155
  are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
148
156
 
149
157
  ## Upgrading a studio
@@ -198,12 +206,16 @@ game its studio chooses to share, each with its own licence ("Game parts", below
198
206
  Every room of a game has the seats its netplay manifest names: game.json's `netplay`
199
207
  block (`maxPlayers`), or a `netplay.json` beside game.json or in the game's build
200
208
  (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8; at most 32.
201
- Bots, a late joiner taking a bot's body and a new host after the old one leaves work
202
- the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
209
+ Bots and a late joiner taking a bot's body work the same at 32 as at 4.
210
+ Server rules keep running when a browser leaves; older browser-hosted games elect a new host. One address may hold every seat plus four sockets, so a party
203
211
  on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
204
212
 
205
213
  ## A game's netplay manifest
206
214
 
215
+ For rules games the build derives the revision from the rules and view; do not bump
216
+ `netplay.version` or tune browser host election. `room` sets hosting and tick rate.
217
+ The version and stall settings below describe existing browser-hosted games.
218
+
207
219
  game.json `"netplay"` says more than the room's size. All of it is optional, and `homie-studio build`
208
220
  writes it to the catalogue for the site's rooms (`netplay/NETPLAY.md` sections 22 to 24):
209
221
 
@@ -364,7 +376,8 @@ npx homie-studio agents pass night-rush --label Claude
364
376
  account) or invite (`office invite <id> --server <server>`).
365
377
  - **AI is always marked AI**, by the room's relay, not the game: every agent is named "<label> · AI", and the
366
378
  relay rewrites a host's roster and results so a game cannot hide one.
367
- - **The skill dial:** 1 Rookie to 5 Maxed (`{ reactionMs, aimNoise, aggression, positioning }`). The party votes it
379
+ - **Rules authoring:** use `world.level`, `world.guideLevel`, `world.guideSeats` and `world.kids`; declare `guide.view`, `guide.floor`, `think` and `world.goalDone` for companions, and `asks` with `world.ask` / `on.answer` for decisions. The following `net.skillOf`, `BotBrain`, `useAgents` and `net.decide` recipes apply to existing browser-hosted games.
380
+ - **The skill dial (browser-hosted games):** 1 Rookie to 5 Maxed (`{ reactionMs, aimNoise, aggression, positioning }`). The party votes it
368
381
  on a card in the play page (the middle vote wins); `net.skillOf(slot)` is what a bot reads (`NETPLAY.md` section
369
382
  17), and `BotBrain` reads it with a rebuild. In the starters, Gem Rush's Rookie bots collect far fewer gems than
370
383
  its Maxed ones.
@@ -372,7 +385,7 @@ npx homie-studio agents pass night-rush --label Claude
372
385
  (humans-only, a stricter door), closing it, removing a member, and the first time AI guides may talk are asked
373
386
  for, and the owner confirms with one tap.
374
387
  - **AI guides that talk** (0.17.0): a game's `agents.json` is its guides' vocabulary (goals, lines, the asks a
375
- player taps), and `useAgents` from `@homie-rocks/studio/agents` its host side (Ember Vale is the reference). The
388
+ player taps), and `useAgents` from `@homie-rocks/studio/agents` its browser-host side (the pre-rules Ember Vale is the reference). The
376
389
  brain is the server's: `agents brain night-rush first-light workers-ai` (the studio's own Workers AI; deploy binds
377
390
  it; 8,000 neurons a day by default) or `owner-key` (the owner's own key, `agents brain key`, capped in dollars a
378
391
  day); with none, the game's scripted floor. The local MCP's `agent_sit` puts the owner's own Claude in a guide's
@@ -390,7 +403,7 @@ npx homie-studio agents pass night-rush --label Claude
390
403
  - **A game's own decisions** (0.24.4, NETPLAY.md section 20): `net.decide(state, questions, { floor })` on the host,
391
404
  for a game whose game.json says `"decide": true`: tactics for its opponents, an NPC's reaction, a director's call,
392
405
  a turn's move, answered by Clef in the room in about a quarter of a second, per beat (never per frame), within the
393
- AI brains' day; the floor answers whenever it cannot. Ember Vale's slimes pick their tactics this way (opt-in).
406
+ AI brains' day; the floor answers whenever it cannot. Rules-based Ember Vale declares asks instead (opt-in).
394
407
  - Everything is in the studio's own Worker and D1 (migration `0006_studio_servers.sql`). The site's side is in
395
408
  [site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) sections 17, 18 and 20.
396
409
 
@@ -745,11 +758,10 @@ Web Workers, a live server) and what a game must do for its sound to be in the f
745
758
  ```sh
746
759
  npx homie-studio perf crown-thief --url http://127.0.0.1:8787 [--device computer,phone] [--runs 3] [--profile]
747
760
  npx homie-studio perf sizes crown-thief # every built file, raw and gzipped; with build --maps, the bundle's modules
748
- npx homie-studio perf compare .perf/crown-thief/<before> .perf/crown-thief/<after> --goal phone.host.frame.p95
761
+ npx homie-studio perf compare .perf/crown-thief/<before> .perf/crown-thief/<after> --goal phone.replica.frame.p95
749
762
  ```
750
763
 
751
- One run is two headless Chromes on this computer's GPU in a fresh room of their own (`?room=perf-…`): the host (the
752
- rules, the bots, the snapshots) and a replica, both a computer (1280x800 at 2x) or both an emulated phone (390x844 at
764
+ One run is two headless Chromes on this computer's GPU in a fresh room of their own (`?room=perf-…`): two replicas for server rules, or a host and replica for older browser-hosted games, both a computer (1280x800 at 2x) or both an emulated phone (390x844 at
753
765
  3x, touch, Chrome's CPU throttle at 4x with `--cpu`, 4G; each run records the slow-down the throttle really gave, which
754
766
  on a fast computer is less than its rate). They play the same seeded presses through a warm-up and a measured
755
767
  window, and each run's JSON says, per browser: the time between animation frames (median, p95, p99, the share over 33
@@ -1247,3 +1259,17 @@ Homie as a short note you see and send yourself (`homie_feedback`, above).
1247
1259
  ## License
1248
1260
 
1249
1261
  Apache-2.0. See `LICENSE` and `NOTICE`.
1262
+
1263
+
1264
+ ### Spatial state delivery
1265
+
1266
+ Server-hosted rules games may set `room.view.radiusM` in game.json to send each
1267
+ player nearby entities and its own body. Omitted or null sends the whole room.
1268
+ Rules and movement stay the same; the view's `each`, `enter` and `leave` describe
1269
+ the visible set. Dispose of meshes on leave and recreate them on enter.
1270
+ Watchers, shared state, effects and rosters remain public. This is bandwidth
1271
+ filtering, not private state. Browser-hosted rooms send the whole room.
1272
+
1273
+ 0.44.0 delivers this first part of bigger rooms. Public admission above 32 seats
1274
+ arrives with Gates in the next slice. See [the milestone 2 notes](../../docs/rooms-milestone-2-notes.md)
1275
+ for the remaining slices and the local 300-client proof.
@@ -2,7 +2,7 @@
2
2
  "id": "welcome",
3
3
  "name": "Welcome together",
4
4
  "blurb": "A little place to gather. Join the queue, keep your ticket, and see when it is your turn.",
5
- "entry": "src/main.ts",
5
+ "entry": "src/view.ts",
6
6
  "roles": {
7
7
  "customer": {
8
8
  "signIn": false,
@@ -102,5 +102,12 @@
102
102
  "phone": "Join once and keep your ticket.",
103
103
  "computer": "Staff call the next person from their signed-in tablet."
104
104
  }
105
+ },
106
+ "room": {
107
+ "host": "server"
108
+ },
109
+ "players": {
110
+ "min": 1,
111
+ "max": 32
105
112
  }
106
113
  }
@@ -0,0 +1 @@
1
+ {"bounds":{"min":[-1,-1],"max":[1,1]},"boxes":[],"spots":{"start":[[0,0]]}}
@@ -0,0 +1,3 @@
1
+ // The current rules contract requires a move for a participant, even without an avatar.
2
+ import { defineMove } from '@homie-rocks/studio/rules';
3
+ export const move = defineMove({ participant() {} });
@@ -0,0 +1,21 @@
1
+ // Transient scene truth lives in the room; authorized queue records live in AppRecords.
2
+ // A phone has a logical stationary seat anchor. The wall watches without a seat.
3
+ import { defineRules, f } from '@homie-rocks/studio/rules';
4
+ import { move } from './move';
5
+ export default defineRules({
6
+ contract: 2, space: { dims: 2 }, move,
7
+ shared: { revision: f.u32() },
8
+ shapes: { events: { refresh: {} }, commands: { refresh: {} }, effects: { refresh: {} } },
9
+ entities: {
10
+ participant: {
11
+ player: true,
12
+ body: { shape: 'circle', radius: 0.1, maxSpeed: 0 },
13
+ commands: { refresh(world, self) { world.sendRoom('refresh', {}); } },
14
+ },
15
+ },
16
+ room: {
17
+ rounds: { seconds: 0, breakSeconds: 0 }, bots: { keep: 0 },
18
+ join() { return { kind: 'participant', at: { x: 0, y: 0 } }; },
19
+ on: { refresh(world) { world.shared.revision += 1; world.emit('refresh', { x: 0, y: 0 }, {}); } },
20
+ },
21
+ });
@@ -1,12 +1,14 @@
1
1
  import * as THREE from 'three';
2
2
  import { damp1 } from '@homie-rocks/camera/spring.js';
3
3
  import { chamferBox } from '@homie-rocks/geom/chamfer.js';
4
- import { createNetplay } from '@homie-rocks/studio/netplay';
4
+ import { openRoom } from '@homie-rocks/studio/rules/view';
5
+ import type rules from './rules';
5
6
  import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
6
7
  import { appLink, qrSvg, randomId } from '@homie-rocks/studio/links';
7
8
 
8
9
  type Ticket = { label: string; status: string };
9
- const net = createNetplay<{ light: number }>({ game: 'welcome', maxPlayers: 32, snapshotHz: 2 });
10
+ const room = openRoom<typeof rules>();
11
+ const net = room.net;
10
12
  const role = net.params.role ?? 'customer';
11
13
  document.body.className = role;
12
14
  const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
@@ -42,7 +44,7 @@ async function refresh() {
42
44
  }
43
45
  async function action(fn: () => Promise<unknown>) {
44
46
  pending = true; paint();
45
- try { await fn(); net.send('records-changed'); await refresh(); }
47
+ try { await fn(); room.command('refresh', {}); await refresh(); }
46
48
  catch (e) { $('#status').textContent = (e as Error).message; }
47
49
  finally { pending = false; paint(); }
48
50
  }
@@ -61,10 +63,9 @@ next.onclick = () => action(async () => {
61
63
  if (first) await records.update(first, { ...first.data, status: 'called' });
62
64
  });
63
65
  // The room carries scene state and change notices; records always come from the authorized database.
64
- net.on('event', (e) => { if (e.k === 'records-changed') { if (net.role === 'host') net.send('records-changed'); void refresh(); } });
65
- net.on('snapshot', (s) => { light = s.d.light; });
66
+ room.on('refresh', () => { void refresh(); });
66
67
  net.on('link', () => { void refresh(); });
67
- setInterval(() => { if (net.role === 'host') { light = net.now() / 1000; net.snapshot({ light }); } $('#connection').textContent = net.link === 'online' ? 'Here together · live' : 'Reconnecting…'; }, 500);
68
+ setInterval(() => { $('#connection').textContent = net.link === 'online' ? 'Here together · live' : 'Reconnecting…'; }, 500);
68
69
  setInterval(() => void refresh(), 2000); // recover a missed notice; no dependency on a trusted browser host
69
70
  void refresh(); paint();
70
71
 
@@ -100,6 +101,7 @@ function draw(now: number) {
100
101
  const phone = innerWidth < 600;
101
102
  camera.position.set(phone ? 7 : 6, phone ? 6 : 5, distance); camera.lookAt(phone ? 1 : -.4, .6, 0);
102
103
  stage.position.x = phone ? 1.8 : 2.4;
104
+ light = net.now() / 1000;
103
105
  stage.rotation.y = reduced.matches ? 0 : Math.sin(light * .12) * .09;
104
106
  lanterns.forEach((g, i) => { const target = i < waiting ? 1 : .28; g.scale.y += (target - g.scale.y) * (1 - Math.exp(-dt * 5)); g.position.y = .18 + (reduced.matches ? 0 : Math.sin(now / 1000 + i) * .05); });
105
107
  renderer.render(scene, camera);
@@ -57,8 +57,8 @@
57
57
  * emulated (a --cpu times slower CPU, 4G) on this computer's GPU. The plugin's perf
58
58
  * skill runs the whole measure, change, compare, keep-or-revert loop)
59
59
  * homie-studio trailer <id> [--url <site>] [--seconds 40] [--length 20] [--title "…"] [--end "…"] [--skills <folder>]
60
- * (a trailer in one command, by the plugin's video skill: the game rendered frame by
61
- * frame on a virtual clock, its sound rebuilt from its own files and what it played,
60
+ * (a trailer in one command, by the plugin's video skill: live footage for server rules,
61
+ * or a virtual clock and rebuilt sound for older browser games,
62
62
  * the highlights picked, an end card, 16:9, 1:1 and 9:16 in videos/<id>-trailer/)
63
63
  * homie-studio perf sizes <id> (what a player downloads: every built file, raw and gzipped, the biggest first; with
64
64
  * build --maps, which modules make up the bundle; each big script read for whether it
@@ -412,7 +412,7 @@ function print(result) {
412
412
  lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
413
413
  break;
414
414
  case 'game new':
415
- lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
415
+ lines.push(`games/${result.id} is a new game from the ${result.from} starter. Its server rules are src/rules.ts, shared movement src/move.ts, and browser view src/view.ts. Read the game skill and RULES.md, change the mechanic, then build and run: npx --no-install homie-studio dev`);
416
416
  if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
417
417
  for (const h of result.needsHeld ?? []) lines.push(`It was written against ${h.name} ${h.want}; this studio pins ${h.have}, which stays.`);
418
418
  if (result.models) {
package/lib/build.mjs CHANGED
@@ -210,11 +210,12 @@ export function genreOf(g, log = () => {}) {
210
210
  * bundle (netplay/netplay.ts NETPLAY_MARK). A build without it is revision 5 or older: it plays on every server, but
211
211
  * reserved AI seats stay empty and its bots do not read the dial, and the office says so. Null when nothing says.
212
212
  */
213
- export function netplayRevOf(out) {
213
+ export function netplayRevOf(out, { skipPort = false } = {}) {
214
214
  const files = [];
215
215
  const walk = (dir, depth) => {
216
216
  if (depth > 3 || !existsSync(dir)) return;
217
217
  for (const name of readdirSync(dir)) {
218
+ if (skipPort && name === 'homie-port.js') continue;
218
219
  const f = join(dir, name);
219
220
  let st;
220
221
  try { st = statSync(f); } catch { continue; }
@@ -381,6 +382,8 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
381
382
  rmSync(out, { recursive: true, force: true });
382
383
  mkdirSync(join(out, 'assets'), { recursive: true });
383
384
  const mode = g.build?.mode ?? 'bundle';
385
+ const label = `${g.kind === 'app' ? 'apps' : 'games'}/${g.id}`;
386
+ const manifest = g.kind === 'app' ? 'app.json' : 'game.json';
384
387
  let warnings = 0;
385
388
  let metafile = null;
386
389
  let bundled = null;
@@ -391,14 +394,22 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
391
394
  // browser exactly as before, and cannot ask for the server.
392
395
  const ruled = isRulesGame(g);
393
396
  if (!ruled && g.room?.host === 'server') {
394
- throw new Error(`games/${g.id}/game.json asks for "room": { "host": "server" }, but ${mode === 'bundle' ? 'this game has no src/rules.ts: its rules are inside its own code and run in a player\'s browser. Ask for it to be rewritten as rules plus view' : 'a ported game is someone else\'s browser code, which the server cannot run. It stays hosted by a player\'s browser'}.`);
397
+ throw new Error(`${label}/${manifest} asks for "room": { "host": "server" }, but ${mode === 'bundle' ? 'this game has no src/rules.ts: its rules are inside its own code and run in a player\'s browser. Ask for it to be rewritten as rules plus view' : 'a ported game is someone else\'s browser code, which the server cannot run. It stays hosted by a player\'s browser'}.`);
398
+ }
399
+ if (ruled && mode !== 'bundle') throw new Error(`${label} has a src/rules.ts and ${manifest} "build": { "mode": "${mode}" }. A game written as rules plus view is built by Homie itself: take the "build" setting out.`);
400
+ if (ruled) {
401
+ const file = join(g.dir, 'index.html');
402
+ const html = existsSync(file) ? readFileSync(file, 'utf8') : '';
403
+ const scripts = html.match(/<script\b[^>]*>/gi) ?? [];
404
+ if (!scripts.some((tag) => /\btype\s*=\s*["']module["']/i.test(tag) && /\bsrc\s*=\s*(["'])(?:\.\/)?assets\/main\.js(?:[?#][^"']*)?\1/i.test(tag))) {
405
+ throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/index.html: load the built view with <script type="module" src="./assets/main.js"></script>. The manifest entry names src/view.ts; HTML loads the bundle, not /src/view.ts. Keep the path relative so it works inside the game frame.`);
406
+ }
395
407
  }
396
- 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.`);
397
408
  const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
398
409
  const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck }) : null;
399
410
  const bundle = async (entryRel) => {
400
411
  const entry = join(g.dir, entryRel);
401
- if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
412
+ if (!existsSync(entry)) throw new Error(`${label}: entry ${entryRel} not found`);
402
413
  const options = {
403
414
  entryPoints: [rules ? 'homie:view' : entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? false,
404
415
  outdir: join(out, 'assets'), splitting: true, entryNames: hashed ? 'main-[hash]' : 'main', chunkNames: 'chunk-[hash]',
@@ -411,14 +422,14 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
411
422
  };
412
423
  const failed = (error) => {
413
424
  const first = error.errors?.[0];
414
- throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
425
+ throw new Error(`${label} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
415
426
  };
416
427
  const result = await esbuild.build(options).catch(failed);
417
428
  warnings += result.warnings.length;
418
429
  metafile = result.metafile;
419
430
  const outputs = Object.entries(result.metafile.outputs).filter(([k]) => k.endsWith('.js'));
420
431
  const main = outputs.find(([, v]) => rules ? v.entryPoint === 'homie-view:homie:view' : Boolean(v.entryPoint))?.[0];
421
- if (!main) throw new Error(`games/${g.id} did not build: esbuild wrote no bundle for ${entryRel}`);
432
+ if (!main) throw new Error(`${label} did not build: esbuild wrote no bundle for ${entryRel}`);
422
433
  bundled = `assets/${basename(main)}`;
423
434
  chunks = outputs.filter(([k]) => k !== main).map(([k]) => `assets/${basename(k)}`).sort();
424
435
  if (maps) {
@@ -442,25 +453,30 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
442
453
  writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
443
454
  if (g.entry) await bundle(g.entry);
444
455
  const html = readFileSync(join(out, 'index.html'), 'utf8');
445
- if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
456
+ if (!/homie-port\.js/.test(html)) log(`warning: ${label}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
446
457
  if (hashed && bundled) writeFileSync(join(out, 'index.html'), pointAtBundle(html, bundled));
447
458
  } else if (mode === 'command') {
448
459
  const command = String(g.build.command ?? 'npm run build');
449
460
  const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
450
- if (res.status !== 0) throw new Error(`games/${g.id}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
461
+ if (res.status !== 0) throw new Error(`${label}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
451
462
  const built = join(g.dir, String(g.build.out ?? 'dist'));
452
- if (!existsSync(join(built, 'index.html'))) throw new Error(`games/${g.id}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
463
+ if (!existsSync(join(built, 'index.html'))) throw new Error(`${label}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
453
464
  cpSync(built, out, { recursive: true });
454
465
  writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
455
466
  } else {
456
467
  await bundle(g.entry ?? (rules ? 'src/view.ts' : 'src/main.ts'));
457
468
  const html = join(g.dir, 'index.html');
458
- if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
469
+ if (!existsSync(html)) throw new Error(`${label}/index.html is missing`);
459
470
  const text = readFileSync(html, 'utf8');
460
471
  writeFileSync(join(out, 'index.html'), hashed ? pointAtBundle(text, bundled) : text);
461
472
  }
462
473
  if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
463
- if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
474
+ if (!existsSync(join(out, 'index.html'))) throw new Error(`${label}/index.html is missing`);
475
+ // Older games declare netplay v1 or carry the helper's revision marker. A plain
476
+ // browser bundle is not automatically a multiplayer game. Apps may be local-only.
477
+ if (!rules && g.kind !== 'app' && netplayOf(g, out).v !== 1 && netplayRevOf(out, { skipPort: true }) === null) {
478
+ throw new Error(`${label}/${manifest}: this game is neither rules plus view nor a browser-hosted netplay game. Start with game new, src/rules.ts, src/view.ts and "room": { "host": "server" }. Existing browser-hosted games must retain their netplay v1 manifest or netplay helper.`);
479
+ }
464
480
  if (rules) {
465
481
  // Only this game's executable view and rules data: buildInfo, the site and other games cannot reload its players.
466
482
  const digest = createHash('sha256').update(rules.build);
@@ -501,7 +517,10 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
501
517
  for (const g of games) if (g.kind === 'app') { const bad = appProblems(g); if (bad.length) throw new Error(`apps/${g.id}/app.json: ${bad.join('; ')}`); }
502
518
  // `--types`: the games' TypeScript is checked first (esbuild only strips types, it never reads them), and a type
503
519
  // error stops the build before anything is built.
504
- const typed = types ? typecheck(root, games, { log }) : null;
520
+ // Rules always use their generated capability types, including the view. The legacy
521
+ // compiler sees broad source types and can falsely reject valid rules callbacks.
522
+ const legacy = games.filter((g) => !isRulesGame(g));
523
+ const typed = types && legacy.length ? typecheck(root, legacy, { log }) : null;
505
524
  // What the build before this one made, to say which games changed.
506
525
  const before = readJson(join(live, '_site', 'build.json'))?.games ?? {};
507
526
  // Everything is built in a folder of its own and put in place only when all of it is there (lib/stage.mjs): a
package/lib/codex.mjs CHANGED
@@ -723,7 +723,7 @@ ${game.planned ? `- [ ] The plan (this codex)
723
723
  -
724
724
  `;
725
725
  writeFileSync(file, text);
726
- return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the plan interview, then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`once the plan is agreed: npx --no-install homie-studio game new ${id} --from gem-rush --name "<Name>" makes the game around it (the codex stays)`] : [])] };
726
+ return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the request and sensible defaults (interview only when asked), then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`after recording those defaults: npx --no-install homie-studio game new ${id} --from gem-rush --name "<Name>" makes the game around it (the codex stays)`] : [])] };
727
727
  }
728
728
 
729
729
  /** A sign-in link that opens one game's codex on the live site (the stats page's one-time sign-in, then the codex). */
package/lib/mcp-tools.mjs CHANGED
@@ -925,7 +925,7 @@ export function toolDefs(ctx, avail = {}) {
925
925
  },
926
926
  {
927
927
  name: 'game_make', title: 'Make a multiplayer game',
928
- description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
928
+ description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: coin-dash (the smallest rules-plus-view example), gem-rush (an arena: the server runs rules, browsers predict movement and render, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
929
929
  inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
930
930
  annotations: { title: 'Make a game', ...RW },
931
931
  run: async (a) => {
@@ -948,7 +948,7 @@ export function toolDefs(ctx, avail = {}) {
948
948
  }
949
949
  const m = r.result?.models;
950
950
  const got = m ? ` Its models: ${m.fetched} from the starter library${m.missing.length ? `; ${m.missing.length} could not be fetched (${m.why ?? m.missing[0].why}), and it draws stand-ins for those` : ''}.` : '';
951
- return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Change it in games/${a.id}/src/main.ts: file_read it once (a long file in two or three reads), decide every change, and make them in ONE file_edit with an edits list (never a call per change: twenty single edits use up a turn before the game is checked); then build and preview_run; check proves two browsers finish a round.`, { kind: 'game', ...r.result });
951
+ return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Read studio_guide topic game, then file RULES.md. Change games/${a.id}/src/rules.ts (declared state and decisions), src/move.ts (server movement and local prediction), src/view.ts (pictures and input), and map/main.json (shared geometry). Read each file once and make its changes in ONE file_edit with an edits list (never a call per change). Keep room.host server. Choose defaults yourself. Build, repair every named diagnostic, preview_run and check; then play the requested mechanic in two browsers. A green check is not a playtest.`, { kind: 'game', ...r.result });
952
952
  },
953
953
  },
954
954
  {
@@ -992,7 +992,7 @@ export function toolDefs(ctx, avail = {}) {
992
992
  const guide = a.interview === true && ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
993
993
  return ok([
994
994
  made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
995
- 'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
995
+ 'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. New games are rules plus view with room.host server; read the game guide and RULES.md. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
996
996
  '', PARTS_FIRST,
997
997
  ...(guide ? ['', 'Optional reference for a person who explicitly requests an interview:', guide] : []),
998
998
  ].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
@@ -1018,7 +1018,7 @@ export function toolDefs(ctx, avail = {}) {
1018
1018
  },
1019
1019
  {
1020
1020
  name: 'build', title: 'Build the site',
1021
- description: 'Bundle every game (or one) and the studio\'s site into site/dist. A few seconds. A running preview shows the new build on reload.',
1021
+ description: 'Bundle every game or app and the site into site/dist. Rules builds always run strict types, the guard and deterministic generated play, including save/restore and prediction checks. Read full diagnostics and repair the named handler; info lines do not fail. Never bypass checks or switch to browser hosting to escape a failure. A running preview shows the new build on reload.',
1022
1022
  inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), ...STUDIO_ARG } },
1023
1023
  annotations: { title: 'Build', ...RW },
1024
1024
  run: async (a) => {
@@ -1097,8 +1097,9 @@ export function toolDefs(ctx, avail = {}) {
1097
1097
  const url = a.url ? String(a.url) : await devUrl(ctx, root);
1098
1098
  const shots = join('.checks', `${game}-look`);
1099
1099
  return startRun(ctx, root, { kind: 'playtest', game, title: `${games.find((g) => g.id === game)?.name ?? game}: playtest`, steps: [
1100
+ ...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
1100
1101
  { label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
1101
- { label: 'pictures on a computer and a phone', soft: true, args: ['look', `/${game}/`, `/${game}/play`, '--url', url, '--only', 'computer,phone', '--shots', shots],
1102
+ { label: 'pictures on a computer and a phone', soft: true, args: ['look', `/${game}/`, `/${game}/${games.find((g) => g.id === game)?.kind === 'app' ? 'open' : 'play'}`, '--url', url, '--only', 'computer,phone', '--shots', shots],
1102
1103
  // The first screen of each page first (a whole page is a tall picture): what a person sees on arrival.
1103
1104
  after: (j, run) => { run.look = (j.result?.rows ?? []).flatMap((r) => [r.fold, r.shot]).filter(Boolean).map((f) => relative(root, resolve(root, f))).slice(0, 8); } },
1104
1105
  ] });
@@ -1456,11 +1457,13 @@ export function toolDefs(ctx, avail = {}) {
1456
1457
  if (!topics.includes(a.topic)) return fail(`guides: ${topics.join(', ')}`);
1457
1458
  const dir = join(ctx.skillsDir, a.topic);
1458
1459
  if (a.file) {
1459
- const f = join(dir, 'references', basename(String(a.file)));
1460
+ const name = basename(String(a.file));
1461
+ const direct = join(dir, name);
1462
+ const f = name.endsWith('.md') && existsSync(direct) ? direct : join(dir, 'references', name);
1460
1463
  if (!existsSync(f)) return fail(`no reference ${a.file} in ${a.topic}`);
1461
1464
  return ok(readFileSync(f, 'utf8'));
1462
1465
  }
1463
- const refs = (() => { try { return readdirSync(join(dir, 'references')); } catch { return []; } })();
1466
+ const refs = [...readdirSync(dir).filter(f => f.endsWith('.md') && f !== 'SKILL.md'), ...(() => { try { return readdirSync(join(dir, 'references')); } catch { return []; } })()];
1464
1467
  return ok(`${DESKTOP_NOTE}${ctx.desktop ? `\n\n${DESKTOP_APPROVALS}` : ''}\n\n${readFileSync(join(dir, 'SKILL.md'), 'utf8')}${refs.length ? `\n\nReferences (file): ${refs.join(', ')}` : ''}`);
1465
1468
  },
1466
1469
  });
package/lib/mcp.mjs CHANGED
@@ -35,7 +35,8 @@ export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios a
35
35
 
36
36
  Carry the person's request through to a working result without a lesson or an interview. Use their name or business name, otherwise "My Studio". Setup, dependencies, a plan, building and checking are AI work, not approval gates. Choose engineering and design defaults from the request; record them in CODEX.md so the person can change them later by asking. Phone and computer controls, a small first version, local sound and CC0 art are the defaults. No paid media unless requested with a budget. A business or charity may need an app or a site, not a game: do not make them play a demo or choose a genre.
37
37
  Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
38
- Show progress briefly and give the preview link as soon as it works. For going live, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
38
+ New games and apps needing transient shared live state use rules plus view with room.host server. Read studio_guide topic game and its file RULES.md before writing rules: declared entity/shared state and events, guarded deterministic move for immediate local prediction, and a browser view for input and rendering. Read file REWRITE.md when an owner asks to move an old game's rules to the server; no automatic converter exists and ordinary old games keep working. Repair the full build diagnostics and prove the requested behavior in two browsers. Current rooms have at most 32 seats; larger rooms and private per-seat state are not delivered. Apps use authorized app records for lasting/private business data; room hosting is not staff permission.
39
+ Show progress briefly and give the preview link as soon as it works. Only when going live was requested, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
39
40
  For a shop, stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through one Stripe browser approval. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
40
41
  For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
41
42
 
@@ -95,7 +95,7 @@ const steerable = (r) => finite(r) && !r[8];
95
95
  export function judgeHold(rows, dir, a, b, size, view) {
96
96
  const seg = rows.filter((r) => r[0] >= a && r[0] <= b + 40 && steerable(r));
97
97
  const busyFrames = rows.filter((r) => r[0] >= a && r[0] <= b + 40 && finite(r) && r[8]).length;
98
- if (seg.length < 8) return { ok: false, dir, busyFrames, why: busyFrames ? `the body was busy (knocked back, stunned, respawning) for ${busyFrames} frames of the hold` : `too few samples (${seg.length}): no body on the probe?` };
98
+ if (seg.length < 8) return { ok: false, dir, busyFrames, why: busyFrames ? `the body was busy (knocked back, stunned, respawning, or an intentional phase freeze) for ${busyFrames} frames of the hold; retry the actual controls during an active, steerable phase. Keep the mechanic and report this sample as blocked, not a controls pass` : `too few samples (${seg.length}): no body on the probe?` };
99
99
  const u = DIRV[dir]; const r0 = seg[0];
100
100
  const side = view === 'side';
101
101
  const tiIdx = Math.max(0, seg.findIndex((r) => r[0] >= a + 400));
@@ -284,7 +284,7 @@ async function webkit(pw) {
284
284
  const frameOf = (h) => h.page.frames().find((f) => f.url().includes('/__game/')) ?? null;
285
285
  async function inFrame(h, src, v = null) { const f = frameOf(h); return f ? T(f.evaluate(src), 10_000, v) : v; }
286
286
  const shell = (h) => T(h.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, results: s.results ?? [], facts: s.facts ? { counts: s.facts.counts } : null } : null; }), 8000, null);
287
- const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); i.observedAt = performance.now(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.hosted = n?.probe?.hosted?.() ?? null; i.tick = n?.probe?.tick?.() ?? null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
287
+ const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); i.observedAt = performance.now(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.movement = n?.probe?.movement?.() ?? null; i.hosted = n?.probe?.hosted?.() ?? null; i.tick = n?.probe?.tick?.() ?? null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
288
288
  const rowsSince = (h, t) => inFrame(h, `(window.__homiePort ? window.__homiePort.rows(${Number(t) || 0}) : [])`, []);
289
289
  const frameNow = (h) => inFrame(h, '(window.__homiePort ? window.__homiePort.now() : performance.now())', 0);
290
290
 
@@ -411,6 +411,7 @@ const UI_COVER = `(() => {
411
411
 
412
412
  async function ownerTests(h, how, log) {
413
413
  const i = await info(h);
414
+ if (i?.movement === 'stationary') return { ok: null, applicable: false, why: 'Rules declare all player anchors with maxSpeed 0: directional movement is not applicable. Exercise the actual buttons, taps or word/turn actions in two browsers; this is not a controls pass.' };
414
415
  const view = i?.v ?? 'top';
415
416
  const size = Number(i?.size) || 1;
416
417
  const keys = i?.keys ?? { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
@@ -565,7 +566,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
565
566
  await shot(h, 'desk-playing');
566
567
  const audio = after?.audio ?? null;
567
568
  row('owner-desk', res.ok, { ...res, seatedMs: o.ms, fps: after?.fps ?? null });
568
- if (audio) {
569
+ if (res.applicable === false) row('audio', null, { why: 'No directional input was sent to this stationary game. Test sound with its actual controls.' });
570
+ else if (audio) {
569
571
  const ctxs = audio.contexts ?? [];
570
572
  row('audio', ctxs.length ? ctxs.every((s) => s === 'running') : null, { contexts: ctxs, refusedMedia: audio.refusedMedia, why: ctxs.length ? (ctxs.every((s) => s === 'running') ? undefined : 'an AudioContext is still suspended after the first keys') : 'no Web Audio in this game (media elements only, or silent)' });
571
573
  }
@@ -591,7 +593,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
591
593
  const after = await info(h);
592
594
  await shot(h, 'phone-playing');
593
595
  row('owner-phone', res.ok, { ...res, seatedMs: o.ms, fps: after?.fps ?? null });
594
- row('ui-cover', cover ? cover.cover <= 0.12 && !cover.opaqueCentre.length : null, { ...(cover ?? {}), why: cover && (cover.cover > 0.12 || cover.opaqueCentre.length) ? `UI covers ${Math.round(cover.cover * 100)}% of a phone${cover.opaqueCentre.length ? ' and something opaque sits in the middle' : ''}` : undefined });
596
+ const stationaryDom = res.applicable === false && await inFrame(h, "!document.querySelector('canvas')", false);
597
+ row('ui-cover', stationaryDom ? null : cover ? cover.cover <= 0.12 && !cover.opaqueCentre.length : null, { ...(cover ?? {}), why: stationaryDom ? 'The stationary DOM interface is its play surface. Inspect its actual buttons and layout; a movement-world overlay limit is not applicable.' : cover && (cover.cover > 0.12 || cover.opaqueCentre.length) ? `UI covers ${Math.round(cover.cover * 100)}% of a phone${cover.opaqueCentre.length ? ' and something opaque sits in the middle' : ''}` : undefined });
595
598
  }
596
599
  collect(h); await close(h);
597
600
  } catch (e) {
package/lib/port.mjs CHANGED
@@ -269,8 +269,8 @@ export function importPort(root, folder, id, { name, mode } = {}) {
269
269
  return {
270
270
  ok: true, command: 'port import', id, dir: dest, from: src, mode: buildMode, files: copied.length, edits, plan: { grade: plan.grade, movement: plan.recommend.movement, view: plan.recommend.view },
271
271
  next: [
272
- `Write games/${id}/PORT.md: the grade and why, the movement mode, what the host owns, what each browser owns, bots, touch, camera.`,
273
- buildMode === 'static' ? 'Add the netplay layer as a script after the game\'s own (HomiePort.createRoom …), or an "entry" in game.json bundled to assets/main.js.' : 'Import \'@homie-rocks/studio/port/early\' first, then build the netplay layer with createRoom.',
272
+ `Write games/${id}/PORT.md: the grade and why, server truth, shared movement, the browser view, bots, touch and camera.`,
273
+ 'Read the port skill and game RULES.md. Preserve the imported game as the baseline, then write server rules, shared movement and a view with openRoom. Use room.host server and the bundled view entry. The imported browser-hosted format remains available for local/offline/private use or maintaining an existing port.',
274
274
  `npm run dev (in the background), then: npx --no-install homie-studio port check ${id} --url http://127.0.0.1:8787`,
275
275
  ],
276
276
  };