@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.
- package/CHANGELOG.md +85 -2
- package/README.md +34 -9
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +13 -4
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/standalone-cli.mjs +30 -2
- package/lib/standalone-tools.mjs +20 -2
- package/lib/standalone.mjs +215 -9
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/standalone/STANDALONE.md +61 -6
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
1
|
+
# Homie netplay contract, v1 (revision 10)
|
|
2
2
|
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
version is `v: 1`. Everything revisions 2 to
|
|
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
|
|
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.
|
package/netplay/netplay.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
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> {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|