@homie-rocks/studio 0.32.0 → 0.33.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 (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. package/worker/version.mjs +1 -1
@@ -1,10 +1,30 @@
1
- # Homie netplay contract, v1 (revision 9)
1
+ # Homie netplay contract, v1 (revision 10)
2
2
 
3
- Status: **v1, revision 9** (2026-10-06, `@homie-rocks/studio` 0.31.0). The wire
4
- version is `v: 1`. Everything revisions 2 to 9 added is either an optional field, a new message type,
3
+ Status: **v1, revision 10** (2026-10-07, `@homie-rocks/studio` 0.33.0). The wire
4
+ version is `v: 1`. Everything revisions 2 to 10 added is either an optional field, a new message type,
5
5
  a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
6
6
  not know. A change to the contract bumps `v` and keeps v1 working.
7
7
 
8
+ **What revision 10 added** (a game that knows none of it plays exactly as before, hosted by a player's
9
+ browser; nothing here changes a frame such a game sends or receives):
10
+ - **The server as host** (section 29): a game written as rules plus view (`src/rules.ts`, `src/move.ts`,
11
+ `src/view.ts`) has no browser host. Its rules run in the room's `Table`, in a host runtime
12
+ (`rules/host.ts`) that plays the host's part on this wire. The relay gains one kind of host, the
13
+ server: it is not a client, it is never elected away, and stall detection, yield and the caps
14
+ written for a host that is somebody's phone do not apply to it.
15
+ - **Input as steps** (section 29): for such a game `in` carries `e`, `k`, `s` and `r` (the room's epoch,
16
+ the first tick the frame covers, one or more entries `[o, ...fields]`, and the placement counter),
17
+ and `snap` carries `e` and one control row a seat, `[seat, r, ack, lead]`. There is no separate
18
+ sequence number: an entry is named by its tick.
19
+ - **The life of a server-hosted room** (section 29): it pauses when the last person's socket closes,
20
+ resumes on the tick it paused on, and ends when nobody returns within a minute. The whole room is
21
+ saved once a second by default and restored after a restart, under a new epoch (section 29.6).
22
+ - The refusal `host-failed` (the rules could not start: the room says why and lets nobody in) and
23
+ the error `room-over` (the room ended because its rules kept failing); `welcome.host` and the watch
24
+ feed's `host` are `{ id: 'server', seat: null }` for such a room, and the feed says `hosted: 'server'`
25
+ with the room's tick figures (`ticks`); `NETPLAY_REVISION` 10 and the build mark
26
+ `homie-netplay-rev:10`.
27
+
8
28
  **What revision 9 added** (a game that knows none of it plays as before; a revision-8 helper and a
9
29
  revision-8 relay ignore every new field and frame):
10
30
  - **The link, said out loud** (section 22): `net.link` and `on('link')` (`online`, `reconnecting`,
@@ -1172,11 +1192,16 @@ it at `/games/<id>/agents.json`, where the Table reads it once.
1172
1192
  | `k` | From → to | `d` | The relay |
1173
1193
  |---|---|---|---|
1174
1194
  | `agent:view` | host → one AI's seat (`to: seat`) | the game's view of that guide (below) | Only the host, only to an AI's seat, under 2 KB, at most one every 2 s per AI; kept as that AI's latest view. |
1195
+ | `agent:offer` | host → everyone | `{ slot, view }` | The bounded declared guide view, at most once every 2 s. Replicas accept it only from the current host. |
1196
+ | `agent:goal` | host → everyone | `{ slot, goal, prev, askAt }` | A goal change for UI listeners; `goal` and `prev` are helper goals or null, timestamps in milliseconds. Host only. |
1197
+ | `agent:ask` | host → everyone | `{ slot, k, args, from, at }` | A validated person's guide request for UI listeners; `at` is milliseconds. Host only. |
1175
1198
  | `agent:do` | AI → host | `{ goal, args }` | A goal of the vocabulary whose arguments fit the AI's latest view and the seats people hold; at most one every 3 s. |
1176
1199
  | `say:<lineId>` | AI → host | `{ args }` | A line of the vocabulary with fitting arguments, on a server whose AI may talk (`policy.brain` is `workers-ai` or `owner-key`), at most one every 4 s and 8 a minute. The host renders it and relays it to everyone as `say:<lineId>` `{ slot, seat, args, ai: true }`, which the relay checks again (a line of the vocabulary; a view argument from the speaking AI's latest view, or only an id for a guide no AI holds): a host cannot put its own words in an AI's line. |
1177
1200
  | `ask:<askId>` | a person → host | `{ slot, seat, args }` | Copied to the AI in `seat` (an ask id of the vocabulary). |
1178
1201
  | anything else from an AI with no game client, `chat`, `emote`, `agent:view` | | | Dropped and counted (`stats.agentDrops`). An AI never types. |
1179
1202
 
1203
+ Only the host emits `ai: true`. A server host removes `ai`, `slot` and `seat` from relayed person or watcher speech. Replicas accept guide lines only from the current host (whose seat is null for a server host). Vocabulary membership uses own keys, never inherited properties.
1204
+
1180
1205
  The lite feed (hands `host`) carries `join`/`leave`, `roster`, `round`, `policy`, `vote`, events
1181
1206
  addressed to its seat, the party's `say:`/`emote:` lines (`chat` only on a `speech: game`
1182
1207
  server), and the asks made of it; never `snap`, `ckpt` or `state`.
@@ -1204,7 +1229,7 @@ agents.ask(slot, 'ask_help', { quest: 'king-slime' }); // a person's button
1204
1229
  | Call | Meaning |
1205
1230
  |---|---|
1206
1231
  | `useAgents(net, vocab, { view, decide, roles?, holdMs?, askWaitMs? })` | Every browser. On the host: sends each AI-held guide its view every 2 s, runs the floor for the rest (once a second), takes `agent:do` and AI lines, answers asks. |
1207
- | `goalOf(slot)` | `{ goal, args, from: 'brain' \| 'floor', at, state, asked? }` or null (the game's own bot code drives). An AI's decision holds for `holdMs` (45 s) or until it is done; a goal that answered a person's ask (`asked`) is carried through until done (or 60 s), whatever a brain says. A brain's decision that does not answer an open ask leaves it open, and the floor answers it after `askWaitMs`. |
1232
+ | `goalOf(slot)` | `{ goal, args, from: 'brain' \| 'floor', at, state, asked? }` or null (the game's own bot code drives). An AI's decision holds for `holdMs` (45 s) or until it is done; a goal that answered a person's ask (`asked`) is carried through until done (or 60 s), whatever a brain says. A brain's decision that does not answer an open ask leaves it open, and the floor attempts it once after `askWaitMs`. That attempt closes the ask even if it declines, returns nothing, or throws. Ordinary browser helpers keep their one-second floor cadence, including after an accepted ask. The rules host sets `carryFloor: true` to carry an accepted ask through its scripted floor for 60 s or until done; that is the rules contract in section 29. |
1208
1233
  | `done(slot, ok?)` | The goal finished (or failed): the next view says so (a brain thinks again); a guide no AI holds decides at once. |
1209
1234
  | `ask(slot, k, args)`, `askButtons(slot, offer)`, `asksFor(slot)` | A person's ask (a button); the buttons to draw (one per value its argument may take); the asks made of a guide in the last 30 s. An ask of a held guide waits `askWaitMs` (4 s) for its brain, then the floor answers it. |
1210
1235
  | `on('say' \| 'goal' \| 'ask', fn)` | A guide's line (text from the vocabulary; never on a browser with Quiet AI on), a goal change (with `askAt`, the ask that led to it), an ask. |
@@ -1213,6 +1238,16 @@ agents.ask(slot, 'ask_help', { quest: 'king-slime' }); // a person's button
1213
1238
  A view adds `goal` (`{ goal, args, state }`) and `asks` itself. The brain also thinks again when
1214
1239
  the view's `zone` or `danger` changes.
1215
1240
 
1241
+ The helper compares both the goal and its arguments when answering an ask. A carried goal also
1242
+ blocks a brain from substituting other arguments to that same goal. Without a `decide` callback,
1243
+ the helper closes an ask on its next floor beat. A floor that declines an ask does not mark its
1244
+ standing goal as `asked`. These rules also apply to browser-hosted games.
1245
+
1246
+ Reopening a verified agent pass resumes its existing seat and replaces the previous socket,
1247
+ in both browser-hosted and server-hosted rooms. After one replacement, the same stay can reopen
1248
+ at most once every four seconds and eight times a minute. Earlier retries receive `agent-pace` with `retryMs` and leave
1249
+ the current socket, body and goal intact.
1250
+
1216
1251
  ### House guides and their brains
1217
1252
 
1218
1253
  On a beginner server whose AI may talk, with a vocabulary, a person seated and a host whose game
@@ -2014,3 +2049,213 @@ room.standing; // { state: 'joining' | 'playing' | 'solo' | 'watching' | 'wait
2014
2049
  again on `leave` and at a round's rollover (`seatWaiting()`). Everyone: test
2015
2050
  `net.link === 'reconnecting'` for your own "Reconnecting…", read `net.full`, and say where the
2016
2051
  player stands with `net.line('…')`.
2052
+
2053
+ ## 29. The server as host (revision 10)
2054
+
2055
+ Until revision 10 a room's host was always a player's browser. A game **written as rules plus
2056
+ view** moves its rules onto the server: what is true in the game (who took which coin, every score,
2057
+ when a round ends) is decided in the room's `Table` on the studio's own Cloudflare, and a browser
2058
+ sends what its player presses and draws what it is told. `@homie-rocks/studio/rules` is the format
2059
+ (`defineRules`, `defineMove`, `f`), `rules/host.ts` the runtime, and the starter `coin-dash` the
2060
+ example. This section is what the wire and the relay do for such a game. Everything else in this
2061
+ document (seats and tokens, chat, votes, watchers, the owner's controls, the arrival, the link)
2062
+ applies to it unchanged.
2063
+
2064
+ **A game written the old way is not touched by any of this.** A game with no `src/rules.ts` builds
2065
+ and runs exactly as before, hosted by a player's browser, with the election, the yield and the
2066
+ checkpoint of sections 2 and 9. It cannot ask for the server: `"room": { "host": "server" }` on
2067
+ such a game fails the build with the reason. So does a ported game.
2068
+
2069
+ ### 29.1 Who is the host
2070
+
2071
+ - game.json `"room": { "host": "server" }` (the default for a game that has a `src/rules.ts`). The
2072
+ build checks the rules, guards them (section 29.5) and puts them into the studio's Worker
2073
+ (`site/src/rules/`, imported by `site/src/worker.mjs` and handed over with `hostRules`).
2074
+ - When a room of such a game opens, its `Table` starts the host runtime **before the first socket
2075
+ is attached**. The relay (`worker/room.mjs`) is told `setServerHost(runtime)`.
2076
+ - **The server host is not a client.** It is not in the room's clients and has no socket. The relay
2077
+ calls it with parsed frames (`runtime.frame(m)`) and fans out each frame it hands back
2078
+ (`room.hostFrame(m, text)`), once, already encoded. An empty room is therefore still empty.
2079
+ - **A browser never becomes host in such a room.** Every `welcome` says `role: 'replica'` (or
2080
+ `'screen'`) and `host: { id: 'server', seat: null }`. Nobody is elected, nobody is deposed, no
2081
+ `role` or `host` frame is sent, and `yield` does nothing. A `snap`, `state`, `round`, `roster`,
2082
+ `caps` or `ckpt` frame from a client is dropped, as it is from any client that is not the host:
2083
+ a changed browser cannot say what a host says.
2084
+ - Stall detection does not apply (there is no host socket to go quiet), and neither do `LIMITS` and
2085
+ `RATES`, which were written for a host that is somebody's phone. A client's own frames are capped
2086
+ as ever (`in`: 2 KB and 60 a second).
2087
+ - **If the rules cannot start** (this build of the site does not hold them, or they do not fit the
2088
+ contract) the room refuses every hello with the final error `host-failed` and the reason in
2089
+ `message`. It does not fall back to a browser by itself.
2090
+
2091
+ ### 29.2 Frames, for a rules game
2092
+
2093
+ The frames are section 5's. For a rules game two of them carry different fields.
2094
+
2095
+ | Frame or field | For a rules game |
2096
+ |---|---|
2097
+ | `in` (a seat to the host) | `e`, `k`, `s`, `r` |
2098
+ | `e` | The room epoch the sender has adopted (32 bits). A frame with another epoch is dropped |
2099
+ | `k` | The first room tick the frame covers (32 bits) |
2100
+ | `s` | One or more entries `[o, ...fields]`, at most 64. `o` is a tick offset from `k`, rising; the entry is stamped `k + o`. The fields are the kind's declared `input` fields in order, each a number (a `press` is 1 or 0). For an owner-moved body an entry also carries its claimed position, velocity and heading: nine numbers, `x y z` each |
2101
+ | `r` | The placement counter the sender has adopted (16 bits). A claim made with an older one is ignored |
2102
+ | `snap` (the host to everyone) | `from: null`, `e`, `k` (the tick just run), `st`, `d` (the packed state), `c` |
2103
+ | `d` | `[[round n, phase (1 live, 0 over), endsAt (a tick)], entities]`. An entity is `[id, kind, r, pos, vel, heading, grounded, fields, motion]`, and for a player's body `seat, driver (0 person, 1 bot, 2 ai), away, owner` after. `kind` is the kind's index in the rules, `fields` and `motion` its declared fields in order. A room and its players share one build, so the declarations are not on the wire |
2104
+ | `c` | One row a person's seat: `[seat, r, ack, lead]` |
2105
+ | `ack` | The stamp of the newest entry from that seat the server had when it ran tick `k`, among entries stamped `k` or earlier |
2106
+ | `lead` | How early that seat's frames arrived, in sixteenths of a tick, signed. For one frame: the time from its arrival to the moment tick `k` of that frame was due. The row carries the smallest value since the last snapshot. Negative is late. -128: no frame arrived |
2107
+ | `ev`, kind `fx` (the host to everyone) | `d: [tick, [[effect, at, data]…]]`: the effects the rules emitted on that tick. `effect` is its index in the rules' `shapes.effects`, `at` a position or an entity id, `data` its declared fields in order. Reliable, as every `ev` is; a view plays each when it draws that tick |
2108
+ | `ev`, kind `cmd` (a seat to the host) | `d: [command, data]`: a reliable one-shot request to the sender's own body, run on the next tick |
2109
+ | `state`, key `shared` (the host) | The rules' `shared` fields, in order, sent when they change |
2110
+ | `round`, `roster`, `caps` | As today. The roster's `slot` is the body's seat; a bot's `seat` is null. Results are read from the `score: true` field of every player's body, two ticks after a round's time is up; ties share a place |
2111
+
2112
+ The optional final player field `goal` is null or `{ goal, args, from, at, asked }`, with `at` in ticks. Older snapshots without it read as null.
2113
+
2114
+ The relay also sends two internal frames to its rules host; no client may send either:
2115
+
2116
+ | Frame | Fields | Meaning |
2117
+ |---|---|---|
2118
+ | `vocabulary` | `vocab` | The build's validated `agents.json`, or null. Installed before joins. |
2119
+ | `decided` | `n, ok, by?, picks?, why?` | The answer to a host's `decide`. Only a matching pending request is settled, once, on a tick. Picks are validated against the declaration; `why` is a word of at most 32 characters. |
2120
+
2121
+ A failed game-decision floor still settles once: `by: 'floor'`, `why: 'floor-error'`, and declared picks (false for yes/no, zero for score, the first declared choice). Its failure is counted once. Late replies are ignored. Reserved companions occupy the relay's top `aiSeats + guides` seats; guides take the highest seats. Plain practice bots and away people have no agent goal, floor or ask buttons. An AI's role comes from its admitted peer facts or the reservation policy.
2122
+
2123
+ **One step a tick, on both sides.** A seat's input is a value that holds from one entry to the
2124
+ next. The step for tick `t` is the values of the newest entry stamped `t` or earlier, plus the
2125
+ presses of an entry stamped exactly `t`. A tick with no entry repeats the held values with every
2126
+ press cleared. What the server does with an entry stamped `j`, when the last tick it ran is `K`:
2127
+
2128
+ | Case | Test | What happens |
2129
+ |---|---|---|
2130
+ | Other epoch | The frame's `e` is not the room's | The frame is dropped |
2131
+ | Duplicate | `j` is not above the newest stamp it has from that seat | Ignored. It still counts as the player being there |
2132
+ | On time, or early | `K < j`, and `j` is at most one second ahead | Kept, and applied on tick `j` |
2133
+ | Too early | `j` is more than one second ahead | This entry and the rest of the frame are dropped |
2134
+ | Late | `j <= K` | Its values hold from tick `K + 1`. Its presses fire on tick `K + 1` if that is at most a quarter of a second after `j`. Otherwise they are dropped. Nothing is applied in the past |
2135
+ | Missing | No entry for a tick | The held step |
2136
+ | Silence | No frame from the seat for one second | Its input is neutral until the next entry |
2137
+
2138
+ The server runs one step for each body each tick, whatever arrives: more frames, or frames stamped
2139
+ ahead, buy no extra step. Every input value is held to its declared type.
2140
+
2141
+ **What a browser sends** (`net.steps(e, k, entries, r)`; a view calls `room.input(sample)` and the
2142
+ view library does this). It steps on its own copy of the room's clock, a little ahead of the server,
2143
+ and holds that lead from `lead`. A step whose sample (or claim) changed, or that holds a press,
2144
+ makes an entry. A period with no entry sends nothing; after 250 ms without a frame the held values
2145
+ are sent again as an ordinary entry. A hidden tab sends one neutral entry and stops stepping.
2146
+
2147
+ **In this release a person's body is owner-moved** (`body: { move: 'owner' }` in the rules): its
2148
+ browser runs the game's `move` code and claims a position, as `netplay.movement: 'owner'` does
2149
+ today, and the server holds each claim to the body's `maxSpeed` (a tick of it every tick, a quarter
2150
+ of a second banked at most). It does not stop a changed browser walking through a wall. Bots, and a
2151
+ body whose player is away, are moved by the server with the same `move` code. Scores, pickups,
2152
+ rounds and placements are the server's in either case.
2153
+
2154
+ ### 29.3 The life of a server-hosted room
2155
+
2156
+ | Moment | What happens |
2157
+ |---|---|
2158
+ | The first person joins | The `Table` has made the relay room and the host runtime. The epoch is a fresh random number. `room.start` has run; the tick loop starts |
2159
+ | People are playing | A timer chain inside the object runs one tick at a time, each timed against the moment it is due. One snapshot a tick |
2160
+ | A player's socket closes | The seat is held 60 s (`holdMs`). The body stays and is `away`: its input is neutral, or its kind's `think` steers it |
2161
+ | The seat's hold runs out | The body is removed, or stays as a bot (`player: { leave: 'bot' }`) |
2162
+ | The last person's socket closes | **The room pauses.** The tick timer is cleared and the clock stops. Watchers and AI seats never keep it ticking |
2163
+ | A person returns within 60 s | The room resumes at the tick it paused on. No tick is skipped and none runs for the gap |
2164
+ | Nobody returns within 60 s | **The room ends.** The relay forgets it and its stored seats are deleted (`office` and `recorded` are kept). The next visitor gets a fresh room, with a new epoch |
2165
+ | A restart or a deploy | The whole room restores at its saved tick, under a new epoch. Seats keep their tokens. A deploy that changed the state shape ends the match instead (section 29.6) |
2166
+ | The rules fail again and again | Every tick failing for two seconds of the clock (a handler that used its whole share, or a fault in the runtime itself), or ticks that ran slow for five: the room ends, its players get the error `room-over`, and the log names the game, its build, the kind and the handler (`why`: `budget`, `fault`, `overrun`). Both are seconds on the clock, not a count of ticks |
2167
+ | Anything is thrown in a tick | It is caught where the tick began, written to the log as `host-fault` (game, build, tick, the part of the tick, at most once a second) and counted. The tick after it runs: a room that is running always has exactly one timer pending, and a room that is paused or ended has none |
2168
+
2169
+ ### 29.4 What the testing tools read
2170
+
2171
+ A room with no browser host is a valid room. `window.__homieNet` and `window.__homiePort` are
2172
+ published by the view library with the same names a game written the old way publishes (own
2173
+ position, score, `busy`), so `check`, `shoot`, `perf` and the playtest judge read a rules game as
2174
+ they read today's; every browser's role is `replica`. The watch feed (`facts()`) says
2175
+ `hosted: 'server'` and carries the room's tick figures in `ticks`: the tick it is on, ticks run,
2176
+ late ticks, input frames and late entries, handler errors, budget stops, and the handler that used
2177
+ the most units. The relay's log has the same figures as one `ticks` line every ten seconds.
2178
+
2179
+ ### 29.5 What keeps a rules module inside its room
2180
+
2181
+ The rules of a studio's own games run in the same isolate as its site, accounts and shop. The wall
2182
+ is made of language restrictions, checked and rewritten at build (`lib/rules-guard.mjs`), plus the
2183
+ fact that rules are handed a `world` object and nothing else. It is built against a careless module
2184
+ written by the studio's own AI; it is not a sandbox against code written to attack, and no rules
2185
+ from outside the studio run on its server.
2186
+
2187
+ - **At build**, with the line named: globals, methods and syntax are allowlists (no `Date`, no
2188
+ `Math.sin` or `**`, no `JSON`, `fetch`, timers, `class`, `this`, `try`, `async`, regular
2189
+ expressions, `delete`, getters; no `constructor`, `prototype`, `__proto__`; no `==` or `!=` except
2190
+ against `null`; no whole number of any size such as `10n`; no function under the name `valueOf`,
2191
+ `toString` or `toJSON`); a rules module has no state of its own (no module-level `let` or `var`,
2192
+ nothing module-level assigned to, nothing of the game's run at load); it imports only
2193
+ `@homie-rocks/studio/rules` and the game's own files.
2194
+ - **Rewritten**: every loop and function counts a unit against the running handler's budget; every
2195
+ computed key and method call goes through a guard that never reaches a prototype, a constructor
2196
+ or a method read as a value; every value an operator would turn into a number or a text (an
2197
+ operand of `-`, `<` or `+`, a part of a template, an argument of `Number`, `String` or `Math.max`)
2198
+ is checked to be one already, so a list or an object there stops the handler instead of being
2199
+ walked uncounted; a write to a property is checked not to land on a function, so nothing is kept
2200
+ on one; text made by `+` or a template, a spread and the list methods are charged by their size,
2201
+ and before they run; no list, text, `Map` or `Set` passes 65,536 entries.
2202
+ - **What comes back from rules is read as plain data.** What a handler returns, every argument of a
2203
+ `world` call and everything written to a field, a list, `motion`, an event or an effect is read by
2204
+ `typeof` and by its own data properties, inside the try that catches the handler and inside its
2205
+ budget. The runtime never calls a `valueOf`, a `toString`, a getter or an iterator of the rules,
2206
+ nothing in the reading can throw, and what is not a number, a text, true, false, a list or an
2207
+ object of the declared shape becomes the zero of that shape. Stored state is frozen, so a query
2208
+ (`world.near`) hands it out without copying it.
2209
+ - **At run time**: a handler that has used a quarter of `room.budget.tick` is stopped and abandoned,
2210
+ as one that throws is. Rules cannot catch it, because rules cannot contain `try`. The default is
2211
+ 500,000 units a tick at 20 ticks a second or fewer, and 10,000,000 divided by the tick rate
2212
+ above that. When a tick has used all of `budget.tick` it ends early: `move` has run for every
2213
+ body (once the budget is gone with a small share each, and not at all once the tick has used half
2214
+ its budget again), handlers not yet run are skipped, and
2215
+ events not yet run stay queued in order. The tick pays first for the state it sends: 4 units for
2216
+ every value in its snapshot.
2217
+ - **Bounds a game is written inside**: a room holds 2,048 entities, 16,384 events and timers
2218
+ waiting, 64 area events a tick and 256 effects a tick; a declared field holds 16,384 values when
2219
+ it is full and all the fields of one kind of entity 65,536 (a list of lists multiplies);
2220
+ `world.sweep` reads the first 16 ids of `ignore`; a game's own answer to `world.ask` holds 256
2221
+ values, four levels deep. `world.math` takes numbers and vectors of numbers.
2222
+ - The view's bundle holds the same guarded `move` code the server runs, and the rules'
2223
+ declarations as data. It holds none of the rules' handlers.
2224
+
2225
+ ### 29.6 Saves, restarts and deploys
2226
+
2227
+ The Table writes the whole room to its SQLite `save` table in one synchronous transaction:
2228
+ entities, motion, shared state, rounds, seats and their tokens, pending events, timers and
2229
+ inputs, RNG state, tick, epoch, state hash and build hash. Saves larger than 2 MB use several
2230
+ rows in that transaction. The default interval is `room.durability.movementSeconds: 1`;
2231
+ round end, `world.finish()` and pause save immediately. Both elapsed ticks and elapsed wall
2232
+ time bound the interval, including ticks that catch up after a delay. Cloudflare's output
2233
+ gate holds outgoing frames until preceding writes are confirmed.
2234
+
2235
+ A restore starts paused at the saved tick. No ticks run for the outage. Its epoch is one
2236
+ more than the larger of the saved epoch and the separately stored `boots.epoch`; that epoch
2237
+ is stored before ticking. Clients reconnect with their seat tokens and jitter capped at two
2238
+ seconds. An unreconnected player's body is away when ticking resumes.
2239
+
2240
+ | Deploy | Room | Page |
2241
+ |---|---|---|
2242
+ | This game's build hash is unchanged | Restore and resume | Reconnect without reloading |
2243
+ | Build changed, state hash unchanged | Restore under the new rules | Refuse the old build with `stale`, `immediate: true`; reload the game and reclaim the seat |
2244
+ | State hash changed | Delete the save and end the room | `room-over`, `rematch: true`; load a fresh game in another room |
2245
+
2246
+ The state hash includes ordered declarations and handler names/presence, body/player settings,
2247
+ field options, shared state, shapes, bots, asks, map, dimensions, tick rate, rounds and contract.
2248
+ Handler implementations, tunables and view code do not change it. The build hash includes
2249
+ built rules, view, tunables and map, excluding build time, commit and other games. It replaces
2250
+ `netplay.version` for rules games. Browser-hosted games keep the existing version and checkpoint
2251
+ behaviour. An old standalone app must update before joining a rules game's live room.
2252
+
2253
+ A restored run increments `boots.count` at its first tick and clears it after ten seconds of
2254
+ ticking. The second successive restored run waits a deterministic room-specific delay of up
2255
+ to thirty seconds. The third ends instead of restoring and logs `restore loop`, the game and
2256
+ build, and that another room sharing the isolate may have caused the resets.
2257
+
2258
+ After sixty seconds paused without a returning person, the room ends even if watchers remain.
2259
+ Ending deletes `save`, `boots` and `net`, preserves `office` and `recorded`, and keeps an `ended`
2260
+ marker so a late reconnect cannot reopen that room. The Lobby forgets it. The stored alarm
2261
+ also cleans up a room whose object restarted with nobody returning.
@@ -73,6 +73,13 @@
73
73
  * with `net.prefs.number(key, fallback)`, never a `null` to multiply by), `net.params`
74
74
  * (the address's switches the page passed in), `net.shell` (where the page's
75
75
  * own buttons sit over the game), and `guardGestures()` for a touch game.
76
+ * - Rules on the server (revision 10, NETPLAY.md section 29): a game written as
77
+ * rules plus view has no browser host. `createNetplay({ rules: true })` is what
78
+ * its view library (`@homie-rocks/studio/rules/view`) starts: this browser never
79
+ * hosts, its input goes out as steps stamped with the room's tick
80
+ * (`net.steps(e, k, entries, r)`), and each snapshot names the room's epoch
81
+ * (`snapshot.e`) and carries one row a seat, `[seat, r, ack, lead]`. A game
82
+ * written the old way uses none of it and runs exactly as before.
76
83
  *
77
84
  * WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
78
85
  * is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
@@ -81,9 +88,9 @@
81
88
 
82
89
  export const NETPLAY_VERSION = 1;
83
90
  /** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
84
- export const NETPLAY_REVISION = 9;
91
+ export const NETPLAY_REVISION = 10;
85
92
  /** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
86
- export const NETPLAY_MARK = 'homie-netplay-rev:9';
93
+ export const NETPLAY_MARK = 'homie-netplay-rev:10';
87
94
 
88
95
  export type Role = 'host' | 'replica' | 'screen';
89
96
  export type Device = 'phone' | 'desk' | 'tv';
@@ -169,7 +176,7 @@ export interface LinkChange {
169
176
  downMs: number;
170
177
  }
171
178
  /** A newer build of the game is live (`on('stale')`): this tab still runs `mine`; `final`: it was kept out of a room for it. */
172
- export interface StaleNotice { ver: string | null; mine: string | null; final: boolean }
179
+ export interface StaleNotice { ver: string | null; mine: string | null; final: boolean; immediate?: boolean }
173
180
  /** One of the play page's own controls drawn over the game, in CSS pixels of the game's own viewport. */
174
181
  export interface ShellRect {
175
182
  /** `room` (the room button), `server` (the server pill), `chip` (the "3 playing" line), `chat`, `join` (the big screen's QR card), `banner`, `results`. */
@@ -416,7 +423,13 @@ export interface Peer {
416
423
 
417
424
  /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
418
425
  export type ControlWire = [seat: number, rs: number, own: number, ack: number];
419
- export interface Snapshot<D = unknown> { k: number; st: number; d: D; from?: number | null; c?: ControlWire[]; /** Revision 9: a heartbeat: the host's last state again, while its frames hitch. */ hb?: 1 }
426
+ export interface Snapshot<D = unknown> {
427
+ k: number; st: number; d: D; from?: number | null; c?: ControlWire[]; /** Revision 9: a heartbeat: the host's last state again, while its frames hitch. */ hb?: 1;
428
+ /** Revision 10, a rules game: the room's epoch. Its `c` rows are `[seat, r, ack, lead]` (the placement counter, the newest input stamp the server had, and how early this seat's input arrived, in sixteenths of a tick; -128: none arrived). */
429
+ e?: number;
430
+ }
431
+ /** Revision 10: one input entry of a rules game, `[o, ...fields]`: a tick offset from the frame's first tick, then the kind's declared input fields in order (and, for an owner-moved body, its claimed position, velocity and heading: nine numbers). */
432
+ export type StepEntry = number[];
420
433
  export interface Checkpoint<D = unknown> { k: number; st: number; d: D; c?: ControlWire[] }
421
434
  export interface InputFrame<A = unknown> {
422
435
  /** Sequence number per sender. */
@@ -568,6 +581,12 @@ export interface NetplayOptions<C = unknown> {
568
581
  watchKeys?: boolean;
569
582
  /** May this browser be elected host? Default true. */
570
583
  canHost?: boolean;
584
+ /**
585
+ * Revision 10: this game is written as rules plus view, and its room's host is the server (NETPLAY.md section 29).
586
+ * This browser is never elected host, sends its input with `net.steps`, and reads the control table as rows of
587
+ * `[seat, r, ack, lead]`. Set by the view library (`openRoom`), not by a game.
588
+ */
589
+ rules?: boolean;
571
590
  /** Game id, for the relay's logs. */
572
591
  game?: string;
573
592
  /** Seats in a room (the manifest's players.max). The relay takes it from the first visitor of an empty room. */
@@ -897,6 +916,12 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
897
916
  input(a: A, held?: Iterable<string> | Record<string, boolean>): void;
898
917
  /** Player: an explicit press edge (for buttons you do not report as held). */
899
918
  press(id: string): void;
919
+ /**
920
+ * Revision 10, a rules game: one input frame. `e` is the room's epoch this browser has adopted, `k` the first room
921
+ * tick the frame covers, `entries` its steps (`[o, ...fields]`, offsets rising) and `r` the placement counter it has
922
+ * adopted. False when not sent (not seated, not connected). The view library calls it; a game calls `room.input`.
923
+ */
924
+ steps(e: number, k: number, entries: StepEntry[], r: number): boolean;
900
925
  /** Event. Host: to everyone, or to one seat (number) or one peer id (string, e.g. a screen). Others: to the host. */
901
926
  send(kind: string, data?: unknown, to?: number | string): void;
902
927
  /** Host: latest input frame from a seat (intents, buttons) whatever the body's control. */
@@ -1454,7 +1479,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1454
1479
  // A watcher (section 16) is a screen that never takes a seat, whatever else it is told.
1455
1480
  const watching = Boolean(opts.watch ?? cfg?.watch);
1456
1481
  const want: Want = watching ? 'screen' : (opts.want ?? cfg?.want ?? 'play');
1457
- const canHost = opts.canHost ?? true;
1482
+ const rulesGame = opts.rules === true;
1483
+ const canHost = rulesGame ? false : opts.canHost ?? true;
1458
1484
  const defaultOwn = (opts.movement ?? 'owner') === 'owner';
1459
1485
 
1460
1486
  // Where parent notifications go: the shell page around a framed game.
@@ -1767,14 +1793,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1767
1793
  emit('link', e);
1768
1794
  }
1769
1795
  /** A newer build is live (section 23): the relay's `stale` frame (still playing) or its `stale` refusal (kept out). */
1770
- function noteStale(ver: unknown, final: boolean): void {
1796
+ function noteStale(ver: unknown, final: boolean, immediate = false): void {
1771
1797
  const next = cleanVersion(ver) ?? '';
1772
1798
  const first = staleVer === null;
1773
1799
  staleVer = next;
1774
1800
  if (!first && !final) return;
1775
- post?.({ what: 'stale', ver: next || null, mine: version, final });
1801
+ post?.({ what: 'stale', ver: next || null, mine: version, final, ...(immediate ? { immediate: true } : {}) });
1776
1802
  if (!final) showOverlay('stale', inApp ? APP_STALE_LINE : 'A new version is ready. Tap to reload.', 10_000);
1777
- emit('stale', { ver: next || null, mine: version, final });
1803
+ emit('stale', { ver: next || null, mine: version, final, ...(immediate ? { immediate: true } : {}) });
1778
1804
  }
1779
1805
 
1780
1806
  /* ------------------------------------------------------------ the page around the frame (revision 9, section 24) */
@@ -2266,7 +2292,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2266
2292
  }
2267
2293
  case 'stale': {
2268
2294
  // A newer build of the game is live (section 23): this tab keeps playing in its own room, and is told.
2269
- noteStale(m['ver'], false);
2295
+ noteStale(m['ver'], false, m['immediate'] === true);
2270
2296
  return;
2271
2297
  }
2272
2298
  case 'watch': {
@@ -2301,6 +2327,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2301
2327
  }
2302
2328
  case 'error': {
2303
2329
  const code = String(m['code'] ?? '');
2330
+ if (code === 'room-over' && m['rematch'] === true) {
2331
+ closed = true; closedWhy = code;
2332
+ post?.({ what: 'rematch', room: cfg?.room ?? null });
2333
+ setLink('closed', code);
2334
+ return;
2335
+ }
2304
2336
  // Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
2305
2337
  if ((code === 'rate' && m['of'] === 'in') || code === 'flood') inSlowUntil = wall() + IN_SLOW_MS;
2306
2338
  // An AI alone in a room (nobody seated): it knocks again only after a while (section 17).
@@ -2309,7 +2341,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2309
2341
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
2310
2342
  // The room still runs another build of the game (section 23): not final. Its players were told to reload; this
2311
2343
  // browser keeps knocking and is let in when they have. Said once, not at every knock.
2312
- if (code === 'room-stale') { lastRefusal = code; warnOnce('room-stale', m['message']); if (link === 'alone') paintLink(); return; }
2344
+ if (code === 'room-stale') { post?.({ what: 'wait', final: false }); lastRefusal = code; warnOnce('room-stale', m['message']); if (link === 'alone') paintLink(); return; }
2313
2345
  console.warn('[netplay] relay refused:', code, m['message']);
2314
2346
  // The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
2315
2347
  // would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
@@ -2318,7 +2350,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2318
2350
  closedWhy = code;
2319
2351
  post?.({ what: 'closed', why: code, room: cfg?.room ?? null, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
2320
2352
  // Kept out of a room for running an older build (section 23): the page reloads the game; the game may too.
2321
- if (code === 'stale') noteStale(m['ver'], true);
2353
+ if (code === 'stale') noteStale(m['ver'], true, m['immediate'] === true);
2322
2354
  setLink('closed', code);
2323
2355
  }
2324
2356
  return;
@@ -2335,6 +2367,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2335
2367
  }
2336
2368
 
2337
2369
  function onWelcome(m: Record<string, unknown>): void {
2370
+ post?.({ what: 'build', ver: version });
2338
2371
  clearTimeout(connectTimer);
2339
2372
  const next = m['role'] as Role;
2340
2373
  const wasOffline = offline;
@@ -2453,6 +2486,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2453
2486
  rebase = false;
2454
2487
  lastArrival = 0;
2455
2488
  }
2489
+ if (rulesGame && buf.length && buf[buf.length - 1].e !== s.e) { buf.length = 0; ages.length = 0; lastArrival = 0; }
2456
2490
  const newest = buf[buf.length - 1];
2457
2491
  if (newest && s.st <= newest.st) return false;
2458
2492
  const arrival = wall();
@@ -2498,7 +2532,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2498
2532
 
2499
2533
  /** Seated replica: read my entry of the control table in the newest snapshot. */
2500
2534
  function controlFrom(s: Snapshot<S>): void {
2501
- if (seat === null || role === 'host' || !Array.isArray(s.c)) return;
2535
+ // A rules game's rows are [seat, r, ack, lead], read by the view library from the snapshot itself.
2536
+ if (seat === null || role === 'host' || rulesGame || !Array.isArray(s.c)) return;
2502
2537
  const e = s.c.find((x) => x[0] === seat);
2503
2538
  if (!e) return;
2504
2539
  const [, rs, own, ack] = e;
@@ -2516,6 +2551,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2516
2551
  const s: Snapshot<S> = { k: Number(m['k']) || 0, st: Number(m['st']), d: m['d'] as S, from: typeof m['from'] === 'number' ? m['from'] : null };
2517
2552
  if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
2518
2553
  if (m['hb'] === 1) s.hb = 1;
2554
+ if (typeof m['e'] === 'number') s.e = m['e'];
2519
2555
  if (acceptSnap(s, bytes, true)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
2520
2556
  }
2521
2557
 
@@ -2769,7 +2805,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2769
2805
  if (closed) return;
2770
2806
  // A hidden tab does not play and its timers crawl: reconnect when it is looked at again.
2771
2807
  if (hidden()) { waitingVisible = true; return; }
2772
- const wait = Math.max(LADDER[Math.min(attempt, LADDER.length - 1)] as number, aloneUntil - wall());
2808
+ const wait = Math.max(rulesGame ? (attempt < 3 ? Math.random() * Math.min(2000, 500 * 2 ** attempt) : (0.5 + Math.random() * 0.5) * Math.min(60_000, 2000 * 2 ** Math.min(attempt - 2, 5))) : LADDER[Math.min(attempt, LADDER.length - 1)] as number, aloneUntil - wall());
2773
2809
  attempt += 1;
2774
2810
  reconnects += 1;
2775
2811
  setTimeout(open, wait);
@@ -3137,6 +3173,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3137
3173
  if (role === 'host' || seat === null || offline || !connected) { pendingPresses = {}; return; }
3138
3174
  scheduleInput(edge);
3139
3175
  },
3176
+ steps(e, k, entries, r) {
3177
+ if (role === 'host' || seat === null || offline || !connected || !entries.length) return false;
3178
+ if (!raw({ t: 'in', e, k, s: entries, r })) return false;
3179
+ inputOut.hit(wall());
3180
+ return true;
3181
+ },
3140
3182
  press(idp) {
3141
3183
  if (role === 'host' || seat === null || offline || !connected) return;
3142
3184
  pendingPresses[idp] = (pendingPresses[idp] ?? 0) + 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",
@@ -58,6 +58,22 @@
58
58
  "types": "./port/powerups.ts",
59
59
  "default": "./port/powerups.ts"
60
60
  },
61
+ "./rules": {
62
+ "types": "./rules/rules.ts",
63
+ "default": "./rules/rules.ts"
64
+ },
65
+ "./rules/view": {
66
+ "types": "./rules/view.ts",
67
+ "default": "./rules/view.ts"
68
+ },
69
+ "./rules/guard": {
70
+ "types": "./rules/guard.ts",
71
+ "default": "./rules/guard.ts"
72
+ },
73
+ "./rules/host": {
74
+ "types": "./rules/host.ts",
75
+ "default": "./rules/host.ts"
76
+ },
61
77
  "./worker": "./worker/index.mjs",
62
78
  "./package.json": "./package.json"
63
79
  },
@@ -66,6 +82,7 @@
66
82
  "lib/",
67
83
  "netplay/",
68
84
  "port/",
85
+ "rules/",
69
86
  "worker/",
70
87
  "mcp/",
71
88
  "saves/",
@@ -89,9 +106,14 @@
89
106
  },
90
107
  "dependencies": {
91
108
  "@anthropic-ai/sdk": "0.131.0",
109
+ "@babel/generator": "8.0.6",
110
+ "@babel/parser": "8.0.7",
111
+ "@babel/traverse": "8.0.7",
112
+ "@babel/types": "8.0.6",
92
113
  "@gltf-transform/core": "4.5.1",
93
114
  "@gltf-transform/extensions": "4.5.1",
94
115
  "@gltf-transform/functions": "4.5.1",
116
+ "@jridgewell/trace-mapping": "0.3.31",
95
117
  "esbuild": "0.28.1",
96
118
  "gltf-validator": "2.0.0-dev.3.10",
97
119
  "meshoptimizer": "1.3.0",