@homie-rocks/studio 0.57.0 → 0.58.1

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 (52) hide show
  1. package/CHANGELOG.md +56 -4
  2. package/docs/toolkit-place-patterns.md +16 -0
  3. package/guides/game/RULES.md +15 -1
  4. package/lib/agent-seat.mjs +10 -3
  5. package/lib/template-history.json +84 -0
  6. package/netplay/NETPLAY.md +10 -0
  7. package/netplay/netplay.ts +30 -9
  8. package/package.json +1 -1
  9. package/parts/README.md +28 -0
  10. package/parts/catalogue.json +236 -0
  11. package/parts/owner-action-checklist/LICENSE +202 -0
  12. package/parts/owner-action-checklist/NOTICE +3 -0
  13. package/parts/owner-action-checklist/README.md +68 -0
  14. package/parts/owner-action-checklist/part.json +95 -0
  15. package/parts/owner-action-checklist/preview/index.html +16 -0
  16. package/parts/owner-action-checklist/src/handler.mjs +44 -0
  17. package/parts/owner-action-checklist/src/index.mjs +96 -0
  18. package/parts/owner-action-checklist/src/style.css +3 -0
  19. package/parts/owner-action-checklist/tuning.json +3 -0
  20. package/parts/terrain-route-map/LICENSE +202 -0
  21. package/parts/terrain-route-map/NOTICE +3 -0
  22. package/parts/terrain-route-map/README.md +67 -0
  23. package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
  24. package/parts/terrain-route-map/part.json +133 -0
  25. package/parts/terrain-route-map/preview/demo.js +4127 -0
  26. package/parts/terrain-route-map/preview/heightmap.png +0 -0
  27. package/parts/terrain-route-map/preview/index.html +1 -0
  28. package/parts/terrain-route-map/preview/main.mjs +4 -0
  29. package/parts/terrain-route-map/preview/route.json +1 -0
  30. package/parts/terrain-route-map/src/data.mjs +81 -0
  31. package/parts/terrain-route-map/src/index.mjs +131 -0
  32. package/parts/terrain-route-map/src/style.css +3 -0
  33. package/parts/terrain-route-map/tuning.json +5 -0
  34. package/rules/browser.ts +2 -0
  35. package/rules/core.ts +3 -0
  36. package/rules/host.ts +170 -16
  37. package/rules/rules.ts +16 -3
  38. package/rules/view.ts +39 -7
  39. package/starters/coin-dash/game.json +2 -1
  40. package/starters/ember-vale/game.json +2 -1
  41. package/starters/gem-rush/game.json +2 -1
  42. package/starters/gem-rush-3d/game.json +2 -1
  43. package/starters/hero-rush-3d/game.json +2 -1
  44. package/tools/tools.ts +1 -1
  45. package/worker/agents.mjs +20 -4
  46. package/worker/booking-calendar.mjs +15 -2
  47. package/worker/build-id.mjs +1 -1
  48. package/worker/limits.mjs +3 -1
  49. package/worker/room-sessions.mjs +1 -1
  50. package/worker/room.mjs +64 -9
  51. package/worker/table.mjs +51 -18
  52. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,15 +17,67 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
- ## [Unreleased]
20
+ ## [0.58.1] - 2026-10-17
21
+
22
+ **Plugin 0.58.1**
23
+
24
+ A room nobody is playing rests, and the next touch wakes it with that input still applied.
25
+
26
+ ### Changed
27
+
28
+ - Idle rooms sleep. A room whose people have all stopped playing now rests: nothing held, pressed or sent for a
29
+ minute (`room.restSeconds`), or every page in the background for ten seconds. Its clock stops with everybody still
30
+ connected and the room hibernates, so it is not charged for duration while it sleeps. It still wakes about once a
31
+ minute for a liveness alarm (one request, and the CPU of that short wake). A deliberate rest is restored as the
32
+ same world, under the same epoch, without rewriting the save. The first touch, command, AI command, returning page
33
+ or new arrival starts it again on the tick it stopped on, and a phone that reconnects keeps its seat. By default
34
+ (`room.rest: "quiet"`) a room rests only when its world has nothing of its own to do: no round clock, no event,
35
+ timer, spawn or ask waiting, nothing moving, and no AI command for that minute. `"idle"` rests anyway and pauses
36
+ round clocks, timers, bots and AI with it; Coin Dash, Ember Vale, Gem Rush, Gem Rush 3D and Hero Rush 3D use it.
37
+ `"never"` keeps a room going while anybody is there (a turn timer, a show). Browser-hosted rules games rest the
38
+ same way. A spectator or agent on an older build keeps pinging the room itself, so the object stays in memory
39
+ while that socket is open. Rebuild your games to get it: a room rests only when every seated browser is on this
40
+ version.
21
41
 
22
42
  ### Fixed
23
43
 
24
- - The MCP Events workerd conformance test now parses SSE frames across HTTP chunks, including combined event/heartbeat frames and split UTF-8. Its former one-chunk-per-event assumption caused the intermittent 0.57.0 publish-gate JSON parse failure; production delivery and webhook formats are unchanged. PR CI adds a path-filtered Events/webhook gate covering protocol, push, durable delivery and signed studio events.
44
+ - A browser-hosted rest stays asleep when the hosting tab is hidden or sends no snapshots. The next browser takes
45
+ the same checkpoint and does not run until somebody plays. A backgrounded tab no longer ends the rest while people
46
+ are still in the room. Waking writes a new checkpoint, so a later handoff does not put the room back to sleep.
47
+
48
+ ### Upgrade notes
49
+
50
+ - Rebuild your games. Set `room.rest` to `"never"` (or `restSeconds` to 0) for a turn timer or a show that must keep
51
+ going while people are present. A lasting world never rests.
52
+
53
+ ## [0.58.0] - 2026-10-16
54
+
55
+ **Plugin 0.58.0**
56
+
57
+ Two new shared parts — an owner action checklist and a 3D terrain route map — and booking calendars that can skip or change single dates in a weekly series.
58
+
59
+ ### Added
60
+
61
+ - Two Apache-2.0 shared building blocks: an owner action checklist with visible consequences, one-tap confirmation, server-authorized undo and existing studio role/origin checks; and a three.js terrain route map with a local heightmap coastline, route tube, markers and phone/TV camera controls. Both include standard part manifests, catalogue entries, AI integration docs, self-contained previews and fast logic checks. No tile service is required.
62
+ - Weekly booking calendars can omit individual dates or override an occurrence's time, capacity and duration. Occurrence IDs stay stable when a start time changes; undefined overrides, off-series or invalid dates and ambiguous DST times are refused, and a date that already has reservations cannot be silently removed.
63
+
64
+ ### Changed
65
+
66
+ - Shared terrain map: smooth high-resolution coast, lit terrain with cavity shading, animated depth-coloured water and foam, sky/haze, flowing elevated route, collision-aware leader labels, intro camera and measured-frame lite fallback. Owner checklist distinguishes pending, confirming, done and undone, announces actual consequences, labels irreversible actions and restores fresh pending actions after undo.
67
+
68
+ ### Fixed
69
+
70
+ - Public checklist proposals require per-action staff authorization, validate tokens, retain retry keys and use separate expiring undo grants. Terrain heightmaps restrict origins and PNG types and enforce byte/dimension budgets before decoding.
71
+ - The MCP Events workerd conformance test parses SSE frames across HTTP chunks, including combined event/heartbeat frames and split UTF-8 (the intermittent 0.57.0 publish-gate failure); production delivery and webhook formats are unchanged. PR CI adds a path-filtered Events/webhook gate.
72
+ - CI Chrome launches keep each caller's protocol timeout.
73
+
74
+ ### Upgrade notes
75
+
76
+ - No migration and nothing to change. Studios use the new parts by asking their AI to add them from the catalogue; existing weekly calendars keep working, and dated exceptions are optional (see "Weekly calendar exceptions" in `packages/studio/docs/toolkit-place-patterns.md`).
25
77
 
26
78
  ## [0.57.0] - 2026-10-16
27
79
 
28
- **Plugin 0.57.0**
80
+ **Plugin 0.57.0** · [release-2026-10-16-studio-0.57.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-16-studio-0.57.0)
29
81
 
30
82
  Studios can book at each other on behalf of a person, with proven studio identity and signed events, and friends joining a browser-hosted game at the same moment always get their seat.
31
83
 
@@ -52,7 +104,7 @@ Studios can book at each other on behalf of a person, with proven studio identit
52
104
 
53
105
  ## [0.56.0] - 2026-10-15
54
106
 
55
- **Plugin 0.56.0**
107
+ **Plugin 0.56.0** · [release-2026-10-15-studio-0.56.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-15-studio-0.56.0)
56
108
 
57
109
  Studios scale with hibernating rooms, add custom routes with built-in access checks, and deploy reliably with background queue handlers.
58
110
 
@@ -66,3 +66,19 @@ Tokens are bound to this origin, limited to the exact named tools, expire after
66
66
  ## Deployment
67
67
 
68
68
  Cloudflare's official MCP request interface cannot authorize new static-asset uploads with an upload JWT; deploy with Wrangler, Cloudflare's own CLI using browser OAuth.
69
+
70
+ ## Weekly calendar exceptions
71
+
72
+ `calendarSlots` (also exposed by `ctx.bookings.calendar`) accepts `exceptions`,
73
+ keyed by local `YYYY-MM-DD`. Use `null` to omit a holiday or an object containing
74
+ `time`, `capacity` and/or `durationMinutes` to override that occurrence. Dates
75
+ must belong to the declared range and weekdays. The occurrence ID remains based
76
+ on the original series time when an exception changes its start time. DST gaps
77
+ and ambiguous times refuse rather than silently changing the booking. Publishing
78
+ new calendar data does not itself move existing reservations or change payments.
79
+
80
+ A `durationMinutes` exception replaces the series `days` duration for that date.
81
+ Explicit `undefined` override values are invalid. Removing a published slot with
82
+ active reservations is refused by the database (`slot has active bookings`).
83
+ Release or cancel those reservations through the payment-aware booking flow before
84
+ deleting the slot; omitting a generated date alone does not delete a published row.
@@ -780,7 +780,21 @@ score and other shared outcomes still arrive with the network. The studio choose
780
780
  Room state is saved automatically (default movementSeconds: 1, plus round end,
781
781
  pause and finish; a lasting world instead journals its durable fields at once and
782
782
  checkpoints the rest every 30 seconds). After 60 seconds with no person the room ends even if screens or
783
- AI remain. Use browser createSaves for player-owned character progress; it is not
783
+ AI remain. A room whose people have all stopped playing rests: its clock stops with everybody still
784
+ connected and the room hibernates, so it is not charged for duration while it sleeps. It still wakes about once a
785
+ minute for a liveness alarm (one request, and the CPU of that short wake). A deliberate rest is restored as the same
786
+ world, under the same epoch, without rewriting the save. The first input, command, AI command, returning page
787
+ or new arrival starts it again on the tick it rested on. "Stopped playing" is nothing held, pressed or sent for
788
+ `room.restSeconds` (default 60), or ten seconds with every page in the background. An AI command counts as playing
789
+ and wakes a room that is already resting. `room.rest` says what else must be true. `"quiet"` (the default): the
790
+ world has nothing of its own to do, meaning no round clock running, no event, timer, spawn or ask waiting, nothing
791
+ moving, and no AI command for `restSeconds`. `"idle"`: rest anyway, pausing round clocks, timers, bots and AI with
792
+ it; Coin Dash, Ember Vale, Gem Rush, Gem Rush 3D and Hero Rush 3D use it, as an away-from-keyboard pause.
793
+ `"never"` (or `restSeconds: 0`): never while somebody is here, for a world that must keep going untouched, such as a
794
+ turn timer that skips an absent player or a show people watch. A lasting world never rests. A spectator or agent on
795
+ an older build keeps pinging the room itself, so the object stays in memory while that socket is open. Browser-hosted
796
+ rules games rest the same way. A resting host's silence is not a stall. If its tab is hidden, another browser takes
797
+ the same rest and stays asleep until somebody plays. Use browser createSaves for player-owned character progress; it is not
784
798
  server-verified money. Entity fields default to public. Declare visibility explicitly for private information (below); shared state and motion remain public. Use declared server round records for verified match history.
785
799
 
786
800
 
@@ -57,6 +57,8 @@ export class AgentSeat {
57
57
  this.errors = [];
58
58
  this.vocab = null;
59
59
  this.ping = null;
60
+ /** The room said it is resting (welcome or `{ t: 'rest' }`). Pings then stay at the edge and do not wake it. */
61
+ this.resting = false;
60
62
  /** brain 'local': Clef on this computer ({ base, model }) decides; its last decisions, and who said no thanks. */
61
63
  this.brain = brain === 'local' && local ? 'local' : null;
62
64
  this.local = local;
@@ -97,7 +99,7 @@ export class AgentSeat {
97
99
  this.ws = ws;
98
100
  const welcome = await new Promise((done) => {
99
101
  const timer = setTimeout(() => done({ t: 'timeout' }), 12_000);
100
- ws.onopen = () => ws.send(JSON.stringify({ ...sat.hello, device: 'desk' }));
102
+ ws.onopen = () => ws.send(JSON.stringify({ ...sat.hello, device: 'desk', rest: 1 }));
101
103
  ws.onmessage = (e) => {
102
104
  let m; try { m = JSON.parse(String(e.data)); } catch { return; }
103
105
  if (m.t === 'welcome' || (m.t === 'error' && !this.seat)) { clearTimeout(timer); done(m); }
@@ -107,7 +109,9 @@ export class AgentSeat {
107
109
  ws.onerror = () => {};
108
110
  });
109
111
  if (welcome.t !== 'welcome') { this.close(); await this.revoke(); return { ok: false, why: welcome.message ?? `no seat (${welcome.code ?? welcome.t})`, error: welcome.code ?? welcome.t }; }
110
- this.ping = setInterval(() => { try { ws.send(JSON.stringify({ t: 'ping', c: Date.now() })); } catch { /* closed */ } }, 2000);
112
+ this.ping = setInterval(() => {
113
+ try { ws.send(this.resting ? 'homie-ping' : JSON.stringify({ t: 'ping', c: Date.now() })); } catch { /* closed */ }
114
+ }, 2000);
111
115
  // The host shows a guide the game every 2 s: wait for the first look (up to 6 s).
112
116
  for (let i = 0; i < 30 && !this.view && !this.closed; i += 1) await wait(200);
113
117
  if (this.brain === 'local') this.autopilot();
@@ -122,6 +126,8 @@ export class AgentSeat {
122
126
  autopilot() {
123
127
  const tick = async () => {
124
128
  if (this.closed) { this.stopAuto(); return; }
129
+ // A resting room paused its AI with the people. The next decision waits until somebody plays.
130
+ if (this.resting) return;
125
131
  const now = Date.now();
126
132
  const a = this.auto;
127
133
  const asks = this.asks.filter((x) => now - x.at < 30_000);
@@ -162,7 +168,8 @@ export class AgentSeat {
162
168
 
163
169
  onFrame(m) {
164
170
  switch (m.t) {
165
- case 'welcome': this.seat = m.seat; this.name = m.name; this.policy = m.policy ?? null; return;
171
+ case 'welcome': this.seat = m.seat; this.name = m.name; this.policy = m.policy ?? null; this.resting = m.rest === true; return;
172
+ case 'rest': this.resting = m.on === true; return;
166
173
  case 'policy': this.policy = m.policy ?? this.policy; return;
167
174
  case 'error': this.errors = [...this.errors, { code: m.code, message: m.message, at: Date.now() }].slice(-6); if (['agents-alone', 'agents-off', 'kicked', 'room-closed', 'agent-yield'].includes(m.code)) this.closed = m.code; return;
168
175
  case 'ev': {
@@ -4134,6 +4134,90 @@
4134
4134
  }
4135
4135
  }
4136
4136
  }
4137
+ },
4138
+ "0.58.0": {
4139
+ "files": {
4140
+ "CLAUDE.md": "ddf2b2a0376202f6",
4141
+ "README.md": "684b3cca8001673c",
4142
+ "HANDOFF.md": "29337e2ace95b2e7",
4143
+ "games/README.md": "5ad32fe80c61f117",
4144
+ "music/README.md": "a3dc042dadd39df8",
4145
+ "videos/README.md": "16babc3437d5545b",
4146
+ "posts/README.md": "37841aae476bd540",
4147
+ "site/README.md": "1dbdf22c03a1e505",
4148
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4149
+ },
4150
+ "sections": {
4151
+ "AGENTS.md": {
4152
+ "(intro)": "af7f2f732505d379",
4153
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4154
+ "## Layout": "f3a280b8c96cd068",
4155
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4156
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4157
+ "## The site": "0cb8dd9a6f8a0096",
4158
+ "## Making games": "bb715272fd1a615b",
4159
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4160
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4161
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4162
+ "## Servers and AI seats": "1ccfd549d2369a60",
4163
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4164
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4165
+ "## Rules": "b2234d80f6cbe71d",
4166
+ "## Telling Homie": "80b46fdd9e578db8",
4167
+ "## Beta": "9d02638207a26e73"
4168
+ }
4169
+ },
4170
+ "named": {
4171
+ "Homie Arcade": {
4172
+ "sections": {
4173
+ "AGENTS.md": {
4174
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4175
+ }
4176
+ }
4177
+ }
4178
+ }
4179
+ },
4180
+ "0.58.1": {
4181
+ "files": {
4182
+ "CLAUDE.md": "ddf2b2a0376202f6",
4183
+ "README.md": "684b3cca8001673c",
4184
+ "HANDOFF.md": "29337e2ace95b2e7",
4185
+ "games/README.md": "5ad32fe80c61f117",
4186
+ "music/README.md": "a3dc042dadd39df8",
4187
+ "videos/README.md": "16babc3437d5545b",
4188
+ "posts/README.md": "37841aae476bd540",
4189
+ "site/README.md": "1dbdf22c03a1e505",
4190
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4191
+ },
4192
+ "sections": {
4193
+ "AGENTS.md": {
4194
+ "(intro)": "af7f2f732505d379",
4195
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4196
+ "## Layout": "f3a280b8c96cd068",
4197
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4198
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4199
+ "## The site": "0cb8dd9a6f8a0096",
4200
+ "## Making games": "bb715272fd1a615b",
4201
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4202
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4203
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4204
+ "## Servers and AI seats": "1ccfd549d2369a60",
4205
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4206
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4207
+ "## Rules": "b2234d80f6cbe71d",
4208
+ "## Telling Homie": "80b46fdd9e578db8",
4209
+ "## Beta": "9d02638207a26e73"
4210
+ }
4211
+ },
4212
+ "named": {
4213
+ "Homie Arcade": {
4214
+ "sections": {
4215
+ "AGENTS.md": {
4216
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4217
+ }
4218
+ }
4219
+ }
4220
+ }
4137
4221
  }
4138
4222
  }
4139
4223
  }
@@ -27,6 +27,13 @@ welcome/promotion, explicit `rules: true` on runtime output, `hosted` relay even
27
27
  The hosting page's unmarked frames have ordinary player allowances. Old-style games
28
28
  keep revision 10's character counts and rates.
29
29
 
30
+ **Resting rooms** need no new revision, since each side ignores what it does not know: a rules
31
+ browser's hello says `rest: 1`, and a room of such browsers rests while nobody plays, sending
32
+ `{ t: 'rest', on }` when it stops and starts again, and `rest: true` in the welcome of a page that
33
+ arrives meanwhile (section 29.3). A room with an older seated browser never rests. An older watching
34
+ page or agent socket in a resting room keeps pinging the room itself, so the object stays in memory
35
+ while it is there.
36
+
30
37
  **What revision 10 added** (a game that knows none of it plays exactly as before, hosted by a player's
31
38
  browser; nothing here changes a frame such a game sends or receives):
32
39
  - **The server as host** (section 29): a game written as rules plus view (`src/rules.ts`, `src/move.ts`,
@@ -2063,6 +2070,9 @@ through a wall. New Coin Dash and Gem Rush use authoritative server movement ins
2063
2070
  | The seat's hold runs out | The body is removed, or stays as a bot (`player: { leave: 'bot' }`) |
2064
2071
  | 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 |
2065
2072
  | 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 |
2073
+ | People are here and nobody plays | **The room rests** (the game's `room.rest`). Nobody has held, pressed or sent anything (a command, a pulse, chat) and no AI has sent a command for `room.restSeconds` (60 by default; 0 never rests), or every page has been in the background for ten seconds. With `rest: 'quiet'` (the default) the world must also have nothing of its own to do: no round clock running, no event, timer, spawn or ask waiting, and nothing moving (bots, physics, tick handlers). With `rest: 'idle'` it rests regardless, and round clocks, timers, bots and AI pause with it (an away-from-keyboard pause). The host sends `{ t: 'rest', on: true }`, saves the room and clears its tick timer; the relay sleeps with everybody still connected, so the object hibernates and is not charged for duration while it sleeps. A page that arrives meanwhile is told in its welcome (`rest: true`), a spectator included. Browsers stop their input clock, ping the edge (`homie-ping`) and send nothing until a control is held or pressed. Only browsers whose hello said `rest: 1` let a room rest. A lasting world never rests. A resting room still wakes about once a minute for its liveness sweep (one alarm request, and the CPU of that short wake). That wake does not rewrite the save or change the epoch. Current agents ping the edge while the room rests. A spectator or agent on an older build keeps pinging the room itself, so the object stays in memory while that socket is open |
2074
+ | Somebody plays again | An input that holds or presses something, a command, an AI's command, a page brought to the front or a new arrival wakes the room: `{ t: 'rest', on: false }`, a retimed `round`, and ticks from the tick it rested on, on a clock started now. The first entry is stamped on that tick, so it applies. A room rebuilt from its rest's save comes back under the same epoch, so the waking frame is not dropped; any other restore takes a new one. A socket kept through hibernation is the same person, still resting; a phone that reconnects with its token gets its own seat and wakes the room |
2075
+ | A browser hosts the rules (`room.host: 'browser'`) | It rests and wakes its room the same way, in the page: it sends the relay `{ t: 'rest', on }`, stops its snapshots and checkpoints, and hears which pages went to the background (`{ t: 'hidden', seat, on }`). The relay fans the rest out and sleeps. That silence is not a stall. A hidden hosting tab hands the rest to another browser with the rest's checkpoint; the new host stays asleep until somebody plays. A handoff with no rest checkpoint ends the rest, so every clock matches a host that will run. Waking writes a new checkpoint, so a later handoff does not restore the rest |
2066
2076
  | 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 |
2067
2077
  | 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) |
2068
2078
  | 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 |
@@ -590,7 +590,7 @@ export interface RulesHost {
590
590
  readonly tick: number;
591
591
  facts(): Record<string, unknown>;
592
592
  }
593
- export type RulesHostFactory = (o: { now(): number; send(m: Record<string, unknown>): void; restore: unknown; peers?: Peer[]; held?: Peer[]; policy?: Policy; onEnd(why: string): void }) => RulesHost;
593
+ export type RulesHostFactory = (o: { now(): number; send(m: Record<string, unknown>): void; restore: unknown; peers?: Peer[]; held?: Peer[]; policy?: Policy; onEnd(why: string): void; onRest?(on: boolean): void }) => RulesHost;
594
594
 
595
595
  export interface RulesOutputSender {
596
596
  push(frame: Record<string, unknown>): void;
@@ -938,6 +938,11 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
938
938
  readonly chatShown: boolean;
939
939
  /** Server time in ms (relay clock). Offline: Date.now(). */
940
940
  now(): number;
941
+ /**
942
+ * A server-hosted room is resting: nobody here has played for a while (or every page is in the background), so its
943
+ * clock is stopped and it sends nothing. Send input only when it changes: the first change wakes the room.
944
+ */
945
+ resting(): boolean;
941
946
  /** Host: true when the next `snapshot()` would be sent. Build the state only then. */
942
947
  snapshotDue(): boolean;
943
948
  /** Host: broadcast fast state (live bodies, projectiles); throttled to snapshotHz (1 Hz while alone). */
@@ -1626,7 +1631,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1626
1631
  rulesPace = null;
1627
1632
  if (!opts.rulesLimits) throw new Error('Rules output limits are missing');
1628
1633
  rulesSender = opts.rulesLimits.output({ rates: opts.rulesLimits.rates, send: raw, now: () => performance.now() });
1629
- const options = { now: () => performance.now(), restore, peers: rulesPeers(), held: offline ? [] : heldPeers, policy: offline ? DEFAULT_POLICY : policy, send: rulesOut, onEnd: (why: string) => endRules(why) };
1634
+ // A browser host rests its room as the server does (rules/host.ts): its own page then pings the edge and holds
1635
+ // its clock like everybody else's, and its relay can sleep. Never offline: nobody pays for a page alone.
1636
+ const options = { now: () => performance.now(), restore, peers: rulesPeers(), held: offline ? [] : heldPeers, policy: offline ? DEFAULT_POLICY : policy, send: rulesOut, onEnd: (why: string) => endRules(why), ...(offline ? {} : { onRest: (on: boolean) => { if (on) sendCheckpoint(); resting = on; if (!on) sendCheckpoint(); } }) };
1630
1637
  try { rulesHost = make(options); }
1631
1638
  catch (error) {
1632
1639
  if (!restore) throw error;
@@ -1714,6 +1721,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1714
1721
  let heldPeers: Peer[] = [];
1715
1722
  let roundInfo: RoundInfo | null = null;
1716
1723
  let idlePing = false, lastClockPing = -Infinity;
1724
+ /** The server's room is resting (`rest` frames): pings go to the edge and silence is expected, as between rounds. */
1725
+ let resting = false;
1717
1726
  let slots: Slot[] | null = null;
1718
1727
  let lastCkpt: Checkpoint<C> | null = null;
1719
1728
 
@@ -2363,6 +2372,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2363
2372
  switch (m['t']) {
2364
2373
  case 'welcome': return onWelcome(m);
2365
2374
  case 'snap': return onSnap(m, text.length);
2375
+ case 'rest': resting = rulesGame && m.on === true; return;
2376
+ // A browser host hears which pages went to the background, as the server's host does.
2377
+ case 'hidden': rulesFrame(m); return;
2366
2378
  case 'free': if (![...peers.values()].some((p) => p.seat === m.seat)) rulesFrame(m); return;
2367
2379
  case 'in': if (rulesFrame(m)) return; return onInput(m);
2368
2380
  case 'ev': {
@@ -2585,7 +2597,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2585
2597
  }
2586
2598
 
2587
2599
  function onWelcome(m: Record<string, unknown>): void {
2588
- idlePing = m.idlePing === true; lastClockPing = -Infinity;
2600
+ // A room that rests when this page arrives (a watcher, a page back after a rest) says so in its welcome.
2601
+ idlePing = m.idlePing === true; lastClockPing = -Infinity; resting = rulesGame && m.rest === true;
2589
2602
  post?.({ what: 'build', ver: version });
2590
2603
  clearTimeout(connectTimer);
2591
2604
  const next = m['role'] as Role;
@@ -2890,7 +2903,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2890
2903
  // Dropping a healthy socket there turned every long hitch into a reconnect.
2891
2904
  const oldestPing = pingsOut.values().next().value;
2892
2905
  if (ws && oldestPing !== undefined && Date.now() - oldestPing >= 30_000 && !pingHeld) { lost(ws, 'stale'); return; }
2893
- const silence = idlePing && (roundInfo?.phase !== 'live' || wall() - lastClockPing > staleMs) ? Math.max(staleMs, 60_000) : staleMs;
2906
+ const silence = idlePing && (resting || roundInfo?.phase !== 'live' || wall() - lastClockPing > staleMs) ? Math.max(staleMs, 60_000) : staleMs;
2894
2907
  if (ws && lastMsgAt && wall() - lastMsgAt > silence) {
2895
2908
  if (!pingHeld) { lost(ws, 'stale'); return; }
2896
2909
  // Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
@@ -2899,7 +2912,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2899
2912
  }
2900
2913
  // An idle hibernatable room can answer this at the edge. Keep a regular
2901
2914
  // clock/visibility sample on visibility changes, and every heartbeat during play.
2902
- if (idlePing && roundInfo?.phase !== 'live' && !(roundInfo?.phase === 'over' && now() >= roundInfo.endsAt) && !force) {
2915
+ if (idlePing && (resting || roundInfo?.phase !== 'live' && !(roundInfo?.phase === 'over' && now() >= roundInfo.endsAt)) && !force) {
2903
2916
  try { ws?.send('homie-ping'); } catch { if(ws)lost(ws,'send'); }
2904
2917
  return;
2905
2918
  }
@@ -2912,7 +2925,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2912
2925
  }
2913
2926
 
2914
2927
  function sendCheckpoint(): void {
2915
- if (role !== 'host' || (!opts.checkpoint && !rulesHost) || offline || !connected) return;
2928
+ // A resting room's world does not change: its last checkpoint is still the room.
2929
+ if (role !== 'host' || (!opts.checkpoint && !rulesHost) || offline || !connected || resting) return;
2916
2930
  let d: C;
2917
2931
  try { d = (rulesHost ? rulesHost.save() : opts.checkpoint!()) as C; } catch (err) { console.warn('[netplay] checkpoint()', err); return; }
2918
2932
  const c: Checkpoint<C> = { k: rulesHost?.tick ?? tick, st: now(), d, c: ctlWire() };
@@ -3042,7 +3056,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3042
3056
  pingsOut.clear();
3043
3057
  raw({
3044
3058
  t: 'hello', rules: rulesGame, v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
3045
- ...(rulesGame ? { coast: 1 } : {}),
3059
+ // A browser that understands a resting room (the server's `rest` frames) says so; an older relay ignores it.
3060
+ ...(rulesGame ? { coast: 1, rest: 1 } : {}),
3046
3061
  ...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
3047
3062
  // Revision 9 (an older relay ignores both): the game's revision and what this build can do.
3048
3063
  ...(version ? { ver: version } : {}), ...(features.length ? { feat: features } : {}),
@@ -3109,11 +3124,16 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3109
3124
  try {
3110
3125
  g.document?.addEventListener('visibilitychange', () => {
3111
3126
  if (disposed) return;
3112
- if (hidden()) rulesHost?.pause(); else rulesHost?.resume();
3127
+ // A rest already stopped this clock. Hiding records the page; showing it is somebody looking, which wakes
3128
+ // the room. Pausing here would tell everyone the room is awake while the next host restores the rest and sleeps.
3129
+ if (rulesHost && resting && seat !== null) rulesHost.frame({ t: 'hidden', seat, on: hidden() });
3130
+ else if (hidden()) rulesHost?.pause();
3131
+ else rulesHost?.resume();
3113
3132
  if (!hidden() && waitingVisible) { waitingVisible = false; attempt = 0; open(); return; }
3114
3133
  if (!connected) return;
3115
3134
  if (hidden()) {
3116
- // A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
3135
+ // A hidden phone tab loses its timers and rAF: hand the round on before it stalls. A rest goes with
3136
+ // that handoff; the next host restores the checkpoint and stays asleep until somebody plays.
3117
3137
  if (role === 'host' && peers.size > 1) yieldHost();
3118
3138
  ping(true);
3119
3139
  } else ping(true);
@@ -3433,6 +3453,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3433
3453
  },
3434
3454
  stateOf<T = unknown>(key: string) { return stateMap.get(key) as T | undefined; },
3435
3455
  stateKeys() { return [...stateMap.keys()]; },
3456
+ resting: () => resting,
3436
3457
  input(a, held) {
3437
3458
  if (rulesGame) return;
3438
3459
  pendingA = a;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.57.0",
3
+ "version": "0.58.1",
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",
@@ -0,0 +1,28 @@
1
+ # Toolkit parts catalogue
2
+
3
+ These are reusable files, not new npm packages or complete apps. The catalogue entries
4
+ are the standard `part.json` records, also collected in `catalogue.json` for local browsing.
5
+
6
+ | Part | What it supplies | Source | Licence |
7
+ | --- | --- | --- | --- |
8
+ | [Owner action checklist](owner-action-checklist/README.md) | Consequence-first owner/staff action UI, server integration boundary, optional undo | Owner action checklist / Homie toolkit | Apache-2.0 |
9
+ | [Terrain route map](terrain-route-map/README.md) | Local heightmap coast, draped route, markers and phone/TV camera controls | Terrain route map / Homie toolkit | Apache-2.0 (three.js MIT) |
10
+
11
+ Each folder includes its licence, attribution, AI integration contract, tunables and small
12
+ working preview. Ryan Campbell authored both. To preview locally, serve this directory with
13
+ a static HTTP server, then open `<id>/preview/index.html`.
14
+
15
+ These public source parts are explicitly shared in their manifests. This checkout is not
16
+ a deployed studio and does not invent a catalogue host or claim a live directory listing.
17
+ To distribute from a studio, retain the original provenance/credit, place these original
18
+ source folders under that studio's `parts/`, run the existing `parts share` checks/packing,
19
+ and deploy. The standard well-known catalogue then advertises exact immutable releases;
20
+ consuming studios use `parts_find` / `part_add`, never copy another studio's release by hand.
21
+ Follow [PARTS.md](PARTS.md) for hashes, licence checks, packing and delivery. Do not serve
22
+ unpacked source as an immutable release. Later changes to a shared release need a new part
23
+ version. These initial parts are 1.0.0; no toolkit version is changed.
24
+
25
+ Maintainers: `node scripts/build-shared-parts.mjs` builds the local map preview and rewrites
26
+ hashes with `writeHashes`. `node --test packages/studio/test/shared-parts.test.mjs` checks
27
+ logic, licences and real pack/catalogue/integrity paths. Run
28
+ `node scripts/check-shared-parts.mjs` for one short browser check and 4× CPU measurements.