@homie-rocks/studio 0.58.0 → 0.58.2

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 CHANGED
@@ -17,7 +17,53 @@ 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.2] - 2026-10-17
21
+
22
+ **Plugin 0.58.2**
23
+
24
+ The studio owner opens a private app role. A staff link can expire, and it can be listed or revoked on its own.
25
+
26
+ ### Added
27
+
28
+ - A signed-in studio owner opens a private app role with the owner session. Everyone else still needs that role's link.
29
+ - `homie-studio app role <id> --list` lists each private role's link and expiry. `--expires` takes a day count (`30d`) or a unix time in milliseconds. `--revoke-link` revokes the link and leaves a player's own grant as it was.
30
+
31
+ ### Upgrade notes
32
+
33
+ - Upgrade the studio to pick this up. A link issued before this version has no expiry until one is set.
34
+
35
+ ## [0.58.1] - 2026-10-17
36
+
37
+ **Plugin 0.58.1**
38
+
39
+ A room nobody is playing rests, and the next touch wakes it with that input still applied.
40
+
41
+ ### Changed
42
+
43
+ - Idle rooms sleep. A room whose people have all stopped playing now rests: nothing held, pressed or sent for a
44
+ minute (`room.restSeconds`), or every page in the background for ten seconds. Its clock stops with everybody still
45
+ connected and the room hibernates, so it is not charged for duration while it sleeps. It still wakes about once a
46
+ minute for a liveness alarm (one request, and the CPU of that short wake). A deliberate rest is restored as the
47
+ same world, under the same epoch, without rewriting the save. The first touch, command, AI command, returning page
48
+ or new arrival starts it again on the tick it stopped on, and a phone that reconnects keeps its seat. By default
49
+ (`room.rest: "quiet"`) a room rests only when its world has nothing of its own to do: no round clock, no event,
50
+ timer, spawn or ask waiting, nothing moving, and no AI command for that minute. `"idle"` rests anyway and pauses
51
+ round clocks, timers, bots and AI with it; Coin Dash, Ember Vale, Gem Rush, Gem Rush 3D and Hero Rush 3D use it.
52
+ `"never"` keeps a room going while anybody is there (a turn timer, a show). Browser-hosted rules games rest the
53
+ same way. A spectator or agent on an older build keeps pinging the room itself, so the object stays in memory
54
+ while that socket is open. Rebuild your games to get it: a room rests only when every seated browser is on this
55
+ version.
56
+
57
+ ### Fixed
58
+
59
+ - A browser-hosted rest stays asleep when the hosting tab is hidden or sends no snapshots. The next browser takes
60
+ the same checkpoint and does not run until somebody plays. A backgrounded tab no longer ends the rest while people
61
+ are still in the room. Waking writes a new checkpoint, so a later handoff does not put the room back to sleep.
62
+
63
+ ### Upgrade notes
64
+
65
+ - Rebuild your games. Set `room.rest` to `"never"` (or `restSeconds` to 0) for a turn timer or a show that must keep
66
+ going while people are present. A lasting world never rests.
21
67
 
22
68
  ## [0.58.0] - 2026-10-16
23
69
 
package/apps/APPS.md CHANGED
@@ -26,7 +26,7 @@ Use existing netplay events for refresh notices and shared animation. A missed o
26
26
 
27
27
  ## Staff
28
28
 
29
- `homie-studio app role welcome staff --url <origin>` returns an unpredictable role address for the owner. Add `--player <account-id>` to grant an existing non-guest account, or `--revoke` with that player to remove it. The command reuses the office's short-lived owner key. Accounts/passkeys and the office owner's existing session are reused. An anonymous browser with the link goes to `/account/` to sign in; a signed-in account without a grant receives 403. Every record call rechecks the grant, so revocation applies to open tablets too.
29
+ `homie-studio app role welcome staff --url <origin>` returns an unpredictable role address for the owner. Add `--player <account-id>` to grant an existing non-guest account, or `--revoke` with that player to remove it. `--list` lists each sign-in role's link and expiry. `--revoke-link` deletes that role's link, so the old address answers 404. `--expires <ms>` or `--expires 7d` sets an expiry within 366 days; omit it for a link that does not expire. The command reuses the office's short-lived owner key. A signed-in studio owner opens their own private roles without a link. Accounts/passkeys and the office owner's existing session are reused. An anonymous browser with the link goes to `/account/` to sign in; a signed-in account without a grant receives 403. An expired or revoked link answers 404. Every record call rechecks the grant, so revocation applies to open tablets too.
30
30
 
31
31
  The private address hides the staff entrance; it is not a bearer permission. It is never in public metadata. Passkeys require HTTPS or localhost. `dev --lan` exposes the local site to the same Wi-Fi, but does not weaken staff authentication. On local HTTP, provision test sessions explicitly in tests or use a secure local origin for real sign-in.
32
32
 
@@ -14,7 +14,7 @@
14
14
  * extension, Claude Code or any MCP client builds in the same chat that shows
15
15
  * the cards (lib/mcp.mjs); --studios: the folder the studios live in
16
16
  * homie-studio app new <id> [--from welcome|gathering|giving|quiz|jobs] [--name "<App Name>"]
17
- * homie-studio app role <id> <role> [--player <account>] [--revoke] [--url <site>]
17
+ * homie-studio app role <id> [--list] | <id> <role> [--player <account>] [--revoke] [--revoke-link] [--expires <ms|Nd>] [--url <site>]
18
18
  * homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
19
19
  * homie-studio games
20
20
  * homie-studio build [<id>] [--maps] [--types] [--long-check] [--quick] (--maps: also keep each bundle's source map and
@@ -382,7 +382,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
382
382
  const argv = process.argv.slice(2);
383
383
  const flags = new Map();
384
384
  // build --quick keeps compilation and type checks, skips rules generated play/smoke checks.
385
- const BOOL_FLAGS = ['webgl-failure', 'quick', 'public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
385
+ const BOOL_FLAGS = ['webgl-failure', 'quick', 'public', 'lan', 'off', 'revoke', 'revoke-link', 'list', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
386
386
  const positional = [];
387
387
  for (let i = 0; i < argv.length; i++) {
388
388
  const a = argv[i];
@@ -950,8 +950,18 @@ async function main() {
950
950
  if (cmd === 'app' && sub === 'role') {
951
951
  const { withKey } = await import('../lib/office.mjs');
952
952
  const id = positional[2]; const role = positional[3];
953
- if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(id ?? '') || !/^[a-z][a-z0-9-]{0,39}$/.test(role ?? '')) throw new Error('app role <id> <role> [--player <account>] [--revoke] [--url <site>]');
954
- return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { role, studio:flags.has('studio'), ...(flags.get('player') ? { player: flags.get('player') } : {}), revoke: flags.has('revoke') }));
953
+ const usage = 'app role <id> --list | app role <id> <role> [--player <account>] [--revoke] [--revoke-link] [--expires <ms|Nd>] [--url <site>]';
954
+ if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(id ?? '')) throw new Error(usage);
955
+ if (flags.has('list')) return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { list: true }));
956
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(role ?? '')) throw new Error(usage);
957
+ let expires;
958
+ if (flags.has('expires')) {
959
+ const raw = String(flags.get('expires') ?? '');
960
+ const days = /^(\d{1,3})d$/.exec(raw);
961
+ expires = days ? Date.now() + Number(days[1]) * 86400000 : Number(raw);
962
+ if (!Number.isSafeInteger(expires) || (days && (Number(days[1]) < 1 || Number(days[1]) > 366))) throw new Error(usage);
963
+ }
964
+ return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { role, studio:flags.has('studio'), ...(flags.get('player') ? { player: flags.get('player') } : {}), revoke: flags.has('revoke'), ...(flags.has('revoke-link') ? { revokeLink: true } : {}), ...(expires !== undefined ? { expires } : {}) }));
955
965
  }
956
966
  if (cmd === 'app' && sub === 'new') return newApp(root, positional[2], { name: flags.get('name'), from: flags.get('from') ?? 'welcome' });
957
967
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
@@ -162,7 +162,7 @@ Use `createAppRecords` from `@homie-rocks/studio/apps` for lasting records. Decl
162
162
 
163
163
  Public collections reach everyone allowed to read them. A ticket number or filtering the HUD is not privacy. Keep personal/private records in a different collection whose read permission excludes customers and walls. Do not put contact details in the starter's public queue.
164
164
 
165
- `homie-studio app role <id> staff --player <account-id> --url <site>` grants an existing signed-in account and returns its private role address. Without `--player` it returns the owner's role address. The existing owner check protects issuance; the link alone grants nothing. `--revoke` removes a named account's grant. Staff use the studio's existing account/passkey sign-in. Keep these links private.
165
+ `homie-studio app role <id> staff --player <account-id> --url <site>` grants an existing signed-in account and returns its private role address. Without `--player` it returns the owner's role address. The existing owner check protects issuance; the link alone grants nothing. `--revoke` removes a named account's grant. `--list` lists links, `--revoke-link` deletes one, and `--expires 7d` (or a unix millisecond time) sets an expiry within 366 days. A signed-in studio owner opens their own private roles without a link. Staff use the studio's existing account/passkey sign-in. Keep these links private.
166
166
 
167
167
  The complete helper and limits are in `node_modules/@homie-rocks/studio/apps/APPS.md`. Import `appLink`, `qrSvg`, and `randomId` from `@homie-rocks/studio/links` for a ticket URL with parameters. Build customer links from a clean public address, never by copying a staff URL. `randomId` works on plain HTTP.
168
168
 
@@ -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': {
@@ -4176,6 +4176,90 @@
4176
4176
  }
4177
4177
  }
4178
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
+ }
4221
+ },
4222
+ "0.58.2": {
4223
+ "files": {
4224
+ "CLAUDE.md": "ddf2b2a0376202f6",
4225
+ "README.md": "684b3cca8001673c",
4226
+ "HANDOFF.md": "29337e2ace95b2e7",
4227
+ "games/README.md": "5ad32fe80c61f117",
4228
+ "music/README.md": "a3dc042dadd39df8",
4229
+ "videos/README.md": "16babc3437d5545b",
4230
+ "posts/README.md": "37841aae476bd540",
4231
+ "site/README.md": "1dbdf22c03a1e505",
4232
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4233
+ },
4234
+ "sections": {
4235
+ "AGENTS.md": {
4236
+ "(intro)": "af7f2f732505d379",
4237
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4238
+ "## Layout": "f3a280b8c96cd068",
4239
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4240
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4241
+ "## The site": "0cb8dd9a6f8a0096",
4242
+ "## Making games": "bb715272fd1a615b",
4243
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4244
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4245
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4246
+ "## Servers and AI seats": "1ccfd549d2369a60",
4247
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4248
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4249
+ "## Rules": "b2234d80f6cbe71d",
4250
+ "## Telling Homie": "80b46fdd9e578db8",
4251
+ "## Beta": "9d02638207a26e73"
4252
+ }
4253
+ },
4254
+ "named": {
4255
+ "Homie Arcade": {
4256
+ "sections": {
4257
+ "AGENTS.md": {
4258
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4259
+ }
4260
+ }
4261
+ }
4262
+ }
4179
4263
  }
4180
4264
  }
4181
4265
  }
@@ -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.58.0",
3
+ "version": "0.58.2",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/browser.ts CHANGED
@@ -71,6 +71,8 @@ export function browserHost(def: RulesDef, game: BrowserGame): RulesHostFactory
71
71
  stage: lab.stage ?? undefined, startPaused: false, restoreEpoch: (Math.floor(Math.random() * 4294967296) >>> 0) || 1,
72
72
  clock: { now: o.now, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) },
73
73
  onEnd: o.onEnd,
74
+ // Online, the page rests its room as the server does; offline nobody is charged for it, and it never rests.
75
+ ...(o.onRest ? { onRest: o.onRest } : {}),
74
76
  });
75
77
  if (game.vocab) host.frame({ t: 'vocabulary', vocab: game.vocab });
76
78
  const present = new Set<number>();
package/rules/core.ts CHANGED
@@ -175,6 +175,8 @@ export interface Core {
175
175
  round(): Extract<CoreOut, { t: 'round' }>;
176
176
  /** End a settled wall-clock break without simulating its idle ticks. */
177
177
  resumeRound(): boolean;
178
+ /** What the world has waiting on its own clock: events and timers on the queue, bodies yet to spawn, asks unanswered, and a running round's deadline. */
179
+ waiting(): number;
178
180
  bodies(): Body[];
179
181
  /** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
180
182
  listeners(): { seat: number; id: string; pos: Vec3 }[];
@@ -1539,6 +1541,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1539
1541
  }
1540
1542
  return {
1541
1543
  round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
1544
+ waiting: () => queue.length + spawns.length + pendingAsks.length + (round.phase === 'live' ? (round.endsAt > 0 ? 1 : 0) : (round.endsAt > tick ? 1 : 0)) + (restartAt > tick ? 1 : 0),
1542
1545
  resumeRound() {
1543
1546
  if (round.phase !== 'over' || overAt) return false;
1544
1547
  // Preserve queued events and their simulation-time deadlines. Nothing