@homie-rocks/studio 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -1
- package/README.md +21 -9
- package/app-starters/welcome/app.json +8 -1
- package/app-starters/welcome/map/main.json +1 -0
- package/app-starters/welcome/src/move.ts +3 -0
- package/app-starters/welcome/src/rules.ts +21 -0
- package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +31 -12
- package/lib/codex.mjs +1 -1
- package/lib/mcp-tools.mjs +10 -7
- package/lib/mcp.mjs +2 -1
- package/lib/port-check.mjs +7 -4
- package/lib/port.mjs +2 -2
- package/lib/rules-build.mjs +6 -6
- package/lib/rules-guard.mjs +9 -3
- package/lib/scaffold.mjs +42 -12
- package/lib/studio.mjs +3 -3
- package/lib/template-history.json +108 -5
- package/lib/trailer.mjs +7 -3
- package/lib/typecheck.mjs +10 -8
- package/netplay/NETPLAY.md +8 -0
- package/package.json +1 -1
- package/rules/host.ts +2 -2
- package/rules/view.ts +1 -0
- package/starters/coin-dash/src/move.ts +2 -0
- package/starters/coin-dash/src/rules.ts +2 -0
- package/starters/coin-dash/src/view.ts +2 -0
- package/starters/ember-vale/src/move.ts +2 -0
- package/starters/ember-vale/src/rules.ts +2 -0
- package/starters/ember-vale/src/view.ts +2 -0
- package/starters/gem-rush/src/move.ts +2 -0
- package/starters/gem-rush/src/rules.ts +2 -0
- package/starters/gem-rush/src/view.ts +2 -0
- package/starters/gem-rush-3d/src/move.ts +2 -0
- package/starters/gem-rush-3d/src/rules.ts +2 -0
- package/starters/gem-rush-3d/src/view.ts +2 -0
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/starters/hero-rush-3d/src/rules.ts +2 -0
- package/starters/hero-rush-3d/src/view.ts +2 -0
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,32 @@ 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.43.0] - 2026-10-09
|
|
21
|
+
|
|
22
|
+
**Plugin 0.43.0**
|
|
23
|
+
|
|
24
|
+
Ask for a game and its rules run on your server.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
- 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.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Builds explain the exact script tag a game page needs, check rules types once, and keep guarded builds working through a temporary-directory alias.
|
|
39
|
+
- 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.
|
|
40
|
+
|
|
41
|
+
### Upgrade notes
|
|
42
|
+
|
|
43
|
+
- Existing games keep working as they are. A rewrite happens only when requested. Building a game does not ask for publication or deploy it.
|
|
44
|
+
- Rooms still hold at most 32 seats. Larger rooms are a later milestone; Cloudflare capacity and billing remain unmeasured.
|
|
45
|
+
|
|
20
46
|
## [0.42.0] - 2026-10-09
|
|
21
47
|
|
|
22
48
|
**Plugin 0.42.0**
|
|
@@ -46,7 +72,7 @@ Customers and other businesses can use a studio's tools, buy its goods and servi
|
|
|
46
72
|
|
|
47
73
|
## [0.40.0] - 2026-10-09
|
|
48
74
|
|
|
49
|
-
**Plugin 0.40.0**
|
|
75
|
+
**Plugin 0.40.0** · [#79](https://github.com/homie-rocks/homie/pull/79)
|
|
50
76
|
|
|
51
77
|
New 3D games keep their matches on the server and answer your controls immediately.
|
|
52
78
|
|
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`
|
|
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
|
|
202
|
-
|
|
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
|
-
- **
|
|
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
|
|
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.
|
|
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-…`):
|
|
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
|
|
@@ -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/
|
|
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,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 {
|
|
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
|
|
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();
|
|
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
|
-
|
|
65
|
-
net.on('snapshot', (s) => { light = s.d.light; });
|
|
66
|
+
room.on('refresh', () => { void refresh(); });
|
|
66
67
|
net.on('link', () => { void refresh(); });
|
|
67
|
-
setInterval(() => {
|
|
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);
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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:
|
|
61
|
-
*
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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:
|
|
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}
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
|
package/lib/port-check.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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,
|
|
273
|
-
|
|
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
|
};
|
package/lib/rules-build.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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}
|
|
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 =
|
|
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
|
}
|
package/lib/rules-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
765
|
-
|
|
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/
|
|
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,
|
|
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 (
|
|
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
|
-
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
- **
|
|
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
|
-
'
|
|
1022
|
-
'
|
|
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
|
|
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.
|
|
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`:
|
|
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": "
|
|
2278
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2217
2279
|
"## Apps: one screen, roles and parts": "be6f8fa45f93211a",
|
|
2218
|
-
"## Commands (all through the pinned CLI in node_modules)": "
|
|
2280
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2219
2281
|
"## The site": "0cb8dd9a6f8a0096",
|
|
2220
|
-
"## Making games": "
|
|
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": "
|
|
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)": "
|
|
2298
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2237
2299
|
}
|
|
2238
2300
|
}
|
|
2239
2301
|
}
|
|
@@ -2282,6 +2344,47 @@
|
|
|
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
|
+
}
|
|
2285
2388
|
}
|
|
2286
2389
|
}
|
|
2287
2390
|
}
|
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`):
|
|
5
|
-
*
|
|
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); ${
|
|
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(
|
|
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(
|
|
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 '
|
|
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(
|
|
154
|
-
if (res.status !== 0 && !res.stdout) throw new Error(
|
|
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
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.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
|
-
*
|
|
6
|
-
*
|
|
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.
|
package/rules/view.ts
CHANGED
|
@@ -672,6 +672,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
672
672
|
net.expose({
|
|
673
673
|
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
674
674
|
peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
|
|
675
|
+
movement: () => { const kinds = schema.kinds.filter(k => k.player); return kinds.length && kinds.every(k => k.maxSpeed === 0) ? 'stationary' : 'spatial'; },
|
|
675
676
|
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
|
|
676
677
|
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
677
678
|
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// This same guarded step runs on the server and immediately for the local player.
|
|
2
|
+
// Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
|
|
1
3
|
/*
|
|
2
4
|
* COIN DASH — how a runner moves. Its own file, because two places run it: the server, for every body it moves, and
|
|
3
5
|
* each player's browser, for that player's own body. So it reads only what both have: the body's own position,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
|
|
2
|
+
// Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
|
|
1
3
|
/*
|
|
2
4
|
* COIN DASH — the rules: the example of a game whose rules run on the server.
|
|
3
5
|
*
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
|
|
2
|
+
// Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
|
|
1
3
|
import { defineRules, f, type GameWorld, type GameSelf } from '@homie-rocks/studio/rules';
|
|
2
4
|
import { move } from './move';
|
|
3
5
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Presentation only: openRoom gives predicted own movement and interpolated other bodies.
|
|
2
|
+
// Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
|
|
1
3
|
/** Ember Vale: server-run hunts, with the hero save and memorials owned by its player. */
|
|
2
4
|
import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
|
|
3
5
|
// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
|
|
2
|
+
// Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
|
|
1
3
|
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
2
4
|
import { move } from './move';
|
|
3
5
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Presentation only: openRoom gives predicted own movement and interpolated other bodies.
|
|
2
|
+
// Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
|
|
1
3
|
/** Gem Rush: the canvas draws the room; rules.ts decides the game. Coordinates here are pixels (50 per metre). */
|
|
2
4
|
import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
|
|
3
5
|
import type rules from './rules';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// This same guarded step runs on the server and immediately for the local player.
|
|
2
|
+
// Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
|
|
1
3
|
import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
|
|
2
4
|
|
|
3
5
|
import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
|
|
2
|
+
// Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
|
|
1
3
|
import { defineRules, f, type GameWorld } from '@homie-rocks/studio/rules';
|
|
2
4
|
import { move } from './move';
|
|
3
5
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Presentation only: openRoom gives predicted own movement and interpolated other bodies.
|
|
2
|
+
// Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
|
|
1
3
|
/** Gem Rush 3D: the room runs the rules; this view draws the meadow and its animals. */
|
|
2
4
|
import {
|
|
3
5
|
AmbientLight, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// This same guarded step runs on the server and immediately for the local player.
|
|
2
|
+
// Only pose/motion, input, ctx map/public tune/math/clocks: no scores, queries or random.
|
|
1
3
|
import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
|
|
2
4
|
|
|
3
5
|
import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Game truth lives here: declared fields, legal inputs, next-tick events and the server clock.
|
|
2
|
+
// Read the game skill RULES.md before changing the mechanic; keep presentation in view.ts.
|
|
1
3
|
import { defineRules, f, type GameWorld } from '@homie-rocks/studio/rules';
|
|
2
4
|
import { move } from './move';
|
|
3
5
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Presentation only: openRoom gives predicted own movement and interpolated other bodies.
|
|
2
|
+
// Send intent; rules decide outcomes. Keep rendered obstacles aligned with room.map.
|
|
1
3
|
/** Hero Rush 3D: server rules, predicted jumps, and the original meadow and heroes. */
|
|
2
4
|
import {
|
|
3
5
|
AmbientLight, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.43.0';
|