@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.
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS app_roles (
19
19
  PRIMARY KEY (app, role, player)
20
20
  );
21
21
  CREATE TABLE IF NOT EXISTS app_role_links (
22
- app TEXT NOT NULL, role TEXT NOT NULL, token TEXT NOT NULL,
22
+ app TEXT NOT NULL, role TEXT NOT NULL, token TEXT NOT NULL, expires_at INTEGER,
23
23
  PRIMARY KEY (app, role)
24
24
  );
25
25
  `;
@@ -35,8 +35,37 @@ async function bodyOf(request) {
35
35
  for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.length; }
36
36
  return JSON.parse(new TextDecoder().decode(bytes) || '{}');
37
37
  }
38
+ const ROLE_LINK_YEAR = 366 * 24 * 60 * 60 * 1000;
39
+ const roleLinkReady = new WeakSet();
40
+ /** Old studios have no expires_at. One ALTER per database; a duplicate column is the column already being there. */
41
+ async function ensureRoleLinkExpiry(db) {
42
+ if (!db || roleLinkReady.has(db)) return true;
43
+ try {
44
+ await db.prepare('ALTER TABLE app_role_links ADD COLUMN expires_at INTEGER').run();
45
+ roleLinkReady.add(db);
46
+ return true;
47
+ } catch (error) {
48
+ if (/duplicate column/i.test(String(error?.message ?? error))) { roleLinkReady.add(db); return true; }
49
+ return false;
50
+ }
51
+ }
52
+ async function roleLink(env, app, role) {
53
+ if (await ensureRoleLinkExpiry(env.DB)) {
54
+ try { return await env.DB.prepare('SELECT token, expires_at FROM app_role_links WHERE app = ?1 AND role = ?2').bind(app, role).first(); } catch { /* column still absent */ }
55
+ }
56
+ const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(app, role).first();
57
+ return row ? { token: row.token, expires_at: null } : null;
58
+ }
59
+ const linkLive = (row) => !!row && (row.expires_at == null || Number(row.expires_at) > Date.now());
60
+ const linkUrl = (url, meta, role, token) => `${url.origin.replace('127.0.0.1', 'localhost')}/${meta.id}/open?role=${role}&access=${token}`;
61
+ function expiryOf(value) {
62
+ if (value == null) return null;
63
+ const n = typeof value === 'number' ? value : Number(value);
64
+ if (!Number.isSafeInteger(n) || n <= Date.now() || n > Date.now() + ROLE_LINK_YEAR) return undefined;
65
+ return n;
66
+ }
38
67
  /** Private links conceal staff addresses; a signed-in grant is still required on every request. */
39
- export async function appAccess(request, env, meta, url) {
68
+ export async function appAccess(request, env, meta, url, options = {}) {
40
69
  const role = appRole(meta, url);
41
70
  const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
42
71
  if (!declared) return { ok: false, status: 404, error: 'unknown role' };
@@ -54,8 +83,10 @@ export async function appAccess(request, env, meta, url) {
54
83
  }
55
84
  const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
56
85
  if(handoff&&await appAssetTicket(env,meta.id,handoff,{meta,purpose:'handoff',expectedRole:role,launch:launchOf(meta,await settingsOf(env),env)}))return {ok:true,role,can:declared.can};
57
- const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
58
- if (!link || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
86
+ // Opening the app: the signed-in studio owner needs no link. hasRole passes owner:false so a custom route still requires that role's own link.
87
+ if (options.owner !== false && await isOwner(request, env)) return { ok: true, role, can: declared.can };
88
+ const link = await roleLink(env, meta.id, role);
89
+ if (!linkLive(link) || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
59
90
  if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
60
91
  const person = await players.of(request, env);
61
92
  if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
@@ -72,6 +103,17 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
72
103
  if (request.method !== 'POST') return fail(405, 'POST required');
73
104
  if (!key && !sameOrigin(request, url)) return fail(403, 'same origin required');
74
105
  const b = await bodyOf(request);
106
+ await ensureRoleLinkExpiry(env.DB);
107
+ if (b.list === true) {
108
+ const roles = [];
109
+ for (const [role, declared] of Object.entries(meta.roles ?? {})) {
110
+ if (!declared?.signIn) continue;
111
+ const row = await roleLink(env, meta.id, role);
112
+ const live = linkLive(row);
113
+ roles.push({ role, expiresAt: row?.expires_at == null ? null : Number(row.expires_at), link: live ? linkUrl(url, meta, role, row.token) : null });
114
+ }
115
+ return json({ ok: true, roles });
116
+ }
75
117
  if (!Object.hasOwn(meta.roles, b.role) || !meta.roles[b.role].signIn) return fail(400, 'name a sign-in role');
76
118
  if (b.player !== undefined) {
77
119
  if (!ID.test(b.player)) return fail(400, 'invalid account');
@@ -79,11 +121,21 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
79
121
  if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
80
122
  await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(b.studio === true ? '*' : meta.id, b.role, b.player).run();
81
123
  }
124
+ if (b.revokeLink === true) {
125
+ await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
126
+ return json({ ok: true, role: b.role, revoked: true });
127
+ }
128
+ let expires;
129
+ if (Object.hasOwn(b, 'expires')) {
130
+ expires = expiryOf(b.expires);
131
+ if (expires === undefined) return fail(400, 'expiry must be a future time within 366 days');
132
+ }
82
133
  const token = crypto.randomUUID().replaceAll('-', '');
83
134
  if (b.rotate === true) await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
84
135
  await env.DB.prepare('INSERT OR IGNORE INTO app_role_links (app, role, token) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, token).run();
85
- const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).first();
86
- return json({ ok: true, link: `${url.origin.replace('127.0.0.1','localhost')}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
136
+ if (expires !== undefined) await env.DB.prepare('UPDATE app_role_links SET expires_at = ?3 WHERE app = ?1 AND role = ?2').bind(meta.id, b.role, expires).run();
137
+ const row = await roleLink(env, meta.id, b.role);
138
+ return json({ ok: true, link: linkUrl(url, meta, b.role, row.token), role: b.role, expiresAt: row.expires_at == null ? null : Number(row.expires_at) });
87
139
  }
88
140
  const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
89
141
  if(!match && /^api\/app\/records\/[^/]+\//.test(sub))return fail(400,'record id: 1–64 letters, digits, underscore or hyphen');
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.58.0","sha256":"c8953383f91877ce12a615ce9c730f7d5fd4859acf285efe5132404ef70c8895"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.58.2","sha256":"5186ba06317a7de1677209dbbba72ee69d4e1cf79a08b46c74e9c20bd92e74a7"});
package/worker/limits.mjs CHANGED
@@ -20,13 +20,15 @@ export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
20
20
  /** The keyed state channel: at most this many keys and bytes per room. */
21
21
  export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
22
22
  /** Runtime output only. Player allowances above remain unchanged. */
23
- export const RULES_FRAMES = Object.freeze(['snap', 'ckpt', 'state', 'ev', 'round', 'roster', 'decide', 'caps', 'rules-end']);
23
+ export const RULES_FRAMES = Object.freeze(['snap', 'ckpt', 'state', 'ev', 'round', 'roster', 'decide', 'caps', 'rules-end', 'rest']);
24
24
  export const rulesRates = (tickHz = 20, seats = 8) => ({
25
25
  // A snapshot a tick, as the server sends, and a quarter over for the ticks a late timer runs together.
26
26
  snap: Math.max(30, Math.ceil(tickHz * 1.25)), ckpt: 4, state: tickHz + 64,
27
27
  ev: Math.ceil((tickHz + seats * EV_RATES.replica + 32) / 0.8), round: 8,
28
28
  // A decision a tick is what one declared ask can make (the next waits for the answer), as on a server host.
29
29
  roster: seats * 2 + 8, decide: Math.ceil((tickHz + seats * 5 + 8) / 0.8), caps: 4, 'rules-end': 4,
30
+ // A browser host rests its room and wakes it: at most a few times a second.
31
+ rest: 4,
30
32
  });
31
33
  export const rulesCaps = seats => Object.fromEntries(RULES_FRAMES.map(t => [t, capOf(t, seats)]));
32
34
  /** Two seconds of headroom absorb timer stalls and transport batching. */
@@ -3,7 +3,7 @@
3
3
  */
4
4
  // Only protocol identity, admission, controls and round deadlines survive here.
5
5
  // World snapshots/checkpoints belong to the existing host/room save, not sessions.
6
- const fields = new Set(`rawVocab vocab snapAt rates agentsAloneMs voteMs voteCooldownMs autoVoteMs policy level levelBy caps vote lastVoteOpen lastAutoVote lastHumanAt agentsOut officeDirty endedMatch code rules tickHz seatCap maxPlayers maxScreens perIp stallMs silentMs idleMs holdMs forgetMs persistMs gameVer currentVer occSeq seats hostId hostSince lastJoinAt lastRound lastRoster emptySince preferHost seatsDirty persistDirty lastPersistAt bans mutes announcement closedUntil closedWhy openedAt askedMax regate decideTimes defaultChat`.split(' '));
6
+ const fields = new Set(`rawVocab vocab snapAt rates agentsAloneMs voteMs voteCooldownMs autoVoteMs policy level levelBy caps vote lastVoteOpen lastAutoVote lastHumanAt agentsOut officeDirty endedMatch code rules tickHz seatCap maxPlayers maxScreens perIp stallMs silentMs idleMs holdMs forgetMs persistMs gameVer currentVer occSeq seats hostId hostSince lastJoinAt lastRound lastRoster emptySince preferHost seatsDirty persistDirty lastPersistAt bans mutes announcement closedUntil closedWhy openedAt askedMax regate decideTimes defaultChat resting`.split(' '));
7
7
  function urlDelta(base, value) {
8
8
  const a=new URL(base),b=new URL(value),changes=[];
9
9
  for(const key of new Set([...a.searchParams.keys(),...b.searchParams.keys()])) {
package/worker/room.mjs CHANGED
@@ -154,6 +154,10 @@ export class NetRoom {
154
154
  * room forgets everything (`onForget`: it ends the host runtime).
155
155
  */
156
156
  this.server = null;
157
+ /** The room's host has rested it (rules/host.ts): nobody is playing, its clock is stopped and it sends nothing. */
158
+ this.resting = false;
159
+ /** The Table hears a rest too (its house guides share the object's alarm). Null where nothing is listening. */
160
+ this.onRest = null;
157
161
  this.hosted = new Map();
158
162
  this.hostFailed = null;
159
163
  this.onForget = null;
@@ -162,7 +166,7 @@ export class NetRoom {
162
166
  /* ------------------------------------------------------------ the server as host (revision 10, section 29) */
163
167
 
164
168
  /** The host runtime of a server-hosted room (`{ frame(m), facts() }`), or null. Seats already here are said as joins. */
165
- setServerHost(h) {
169
+ setServerHost(h, { restored = false } = {}) {
166
170
  this.server = h;
167
171
  if (h?.rates) this.rates = h.rates;
168
172
  this.hostFailed = null;
@@ -175,7 +179,7 @@ export class NetRoom {
175
179
  for (const [seat, s] of this.seats) this.hosted.set(seat, s.occ ?? null);
176
180
  this.toServer({ t: 'policy', policy: this.policyOut() });
177
181
  if (this.rawVocab) this.toServer({ t: 'vocabulary', vocab: this.rawVocab });
178
- for (const c of this.live()) if (c.seat !== null && !c.watch) this.joinServer(c);
182
+ for (const c of this.live()) if (c.seat !== null && !c.watch) this.joinServer(c, restored);
179
183
  }
180
184
 
181
185
  /** The rules could not start: the room refuses joins and says why. It never falls back to a browser by itself. */
@@ -191,9 +195,26 @@ export class NetRoom {
191
195
  try { this.server.frame(m); } catch (error) { this.stats.failed = (this.stats.failed ?? 0) + 1; this.log({ ev: 'failed', room: this.code, op: 'host-frame', frame: m.t, error: errorLine(error) }); }
192
196
  }
193
197
 
194
- joinServer(c) {
198
+ /** `restored`: the same socket, back after the room hibernated (a resting room rests on), not somebody arriving. */
199
+ joinServer(c, restored = false) {
195
200
  this.hosted.set(c.seat, this.seats.get(c.seat)?.occ ?? null);
196
- this.toServer({ t: 'join', peer: this.peer(c) });
201
+ this.toServer({ t: 'join', peer: this.peer(c), ...(c.rest ? { rest: true } : {}), ...(c.hidden ? { hidden: true } : {}), ...(restored ? { restored: true } : {}) });
202
+ }
203
+
204
+ /**
205
+ * The host (the server's, or a browser host `c`) rested the room or woke it. Everybody hears it, and a page that
206
+ * arrives meanwhile is told in its welcome; browsers then ping the edge and send nothing until somebody plays. A
207
+ * browser host's snapshots stop while it rests, so its silence is not a stall.
208
+ */
209
+ rest(c, on) {
210
+ if (on === this.resting) return;
211
+ this.resting = on;
212
+ if (c && !on) c.lastSnapAt = this.now();
213
+ const line = JSON.stringify({ t: 'rest', on });
214
+ for (const o of this.live()) if (o !== c) this.sendText(o.conn, line);
215
+ // A browser host rests in the page, so the Table's own `onRest` (the server host) never runs. The guides still
216
+ // have to stop: their think alarm would wake the object, and the command it sent would wake the room.
217
+ try { this.onRest?.(on); } catch { /* a listener that throws does not keep the room awake */ }
197
218
  }
198
219
 
199
220
  /** A seat the host runtime knows that is no longer held (its hold ran out, the owner removed its player): its body's seat is given up. */
@@ -220,6 +241,7 @@ export class NetRoom {
220
241
  case 'roster': return this.hostRoster(null, m);
221
242
  case 'caps': return this.hostCaps(null, m);
222
243
  case 'decide': return this.onDecide(null, m, true);
244
+ case 'rest': return this.rest(null, m.on === true);
223
245
  case 'ev': {
224
246
  const kind = String(m.k ?? '').slice(0, 64);
225
247
  if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
@@ -535,7 +557,7 @@ export class NetRoom {
535
557
  peer(c) {
536
558
  // Revision 9: which stay in the seat this is (`occ`), the peer's game revision and what its build can do.
537
559
  const occ = c.seat !== null ? this.seats.get(c.seat)?.occ : undefined;
538
- return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}), ...(Number.isInteger(occ) ? { occ } : {}), ...(c.ver ? { ver: c.ver } : {}), ...(c.feat.length ? { feat: [...c.feat] } : {}) };
560
+ return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}), ...(Number.isInteger(occ) ? { occ } : {}), ...(c.ver ? { ver: c.ver } : {}), ...(c.feat.length ? { feat: [...c.feat] } : {}), ...(c.rest ? { rest: true } : {}) };
539
561
  }
540
562
  /** A hands-`host` agent (section 17): no game client of its own, so no snapshots, checkpoints or state. */
541
563
  lite(c) { return Boolean(c.agent && c.agent.hands === 'host'); }
@@ -809,11 +831,22 @@ export class NetRoom {
809
831
  const wasHidden = c.hidden;
810
832
  c.hidden = m.hid === true;
811
833
  this.send(c, { t: 'pong', c: m.c, st: now });
834
+ // The server's host rests a room whose pages are all in the background, and wakes it when one comes back.
835
+ if (c.seat !== null && c.hidden !== Boolean(wasHidden)) {
836
+ if (this.server) this.toServer({ t: 'hidden', seat: c.seat, on: c.hidden });
837
+ else if (this.rules && this.host()?.rules && this.host() !== c) this.send(this.host(), { t: 'hidden', seat: c.seat, on: c.hidden });
838
+ }
812
839
  if(this.server && (!c.probe || now-c.probe.at>10000)){c.probe={n:crypto.randomUUID(),at:now};this.send(c,{t:'probe',n:c.probe.n});}
813
- // A host whose tab went to the background will stop ticking: hand the round on now.
840
+ // A host whose tab went to the background will stop running: hand the round on now. A rest goes with
841
+ // the checkpoint, so the next host stays asleep until somebody plays.
814
842
  if (c.id === this.hostId && c.hidden && !wasHidden) this.yieldHost(c, 'host-hidden');
815
843
  return;
816
844
  }
845
+ case 'rest': {
846
+ // A browser host rested its room, or woke it (rules/host.ts, run in the page).
847
+ if (!this.server && this.rules && c.rules && c.id === this.hostId) this.rest(c, m.on === true);
848
+ return;
849
+ }
817
850
  case 'yield': {
818
851
  if (c.id === this.hostId && this.rules && c.rules && m.slow === true) {
819
852
  c.rulesSpeed = Math.max(0, Math.min(1, Number(m.speed) || 0));
@@ -918,6 +951,9 @@ export class NetRoom {
918
951
  if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
919
952
  if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
920
953
  c.rules = m.rules === true;
954
+ // A browser that understands a resting room (rules/host.ts): only a room of them rests.
955
+ // A rules browser, or an agent, that says it understands resting. An older socket keeps its own pings, and the bill.
956
+ c.rest = m.rest === 1 && (c.rules || Boolean(c.agent));
921
957
  c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
922
958
  // A watcher is a screen that never takes a seat, whatever else its hello says. An agent always plays.
923
959
  c.watch = watching;
@@ -971,6 +1007,11 @@ export class NetRoom {
971
1007
  let role = c.seat === null ? 'screen' : 'replica';
972
1008
  let why = 'joined';
973
1009
  let deposed = null;
1010
+ // An edge ping is answered without a message, so lastSeen lags it between ticks. A fresh ping is the host still here.
1011
+ for (const o of live) {
1012
+ const ping = o.conn.lastPing?.() ?? 0;
1013
+ if (ping > 0) o.lastSeen = Math.max(o.lastSeen, ping);
1014
+ }
974
1015
  const h = this.host();
975
1016
  const preferred = this.preferHost && now < this.preferHost.until ? this.preferHost.seat : undefined;
976
1017
  // A watcher hosts only a room nobody else can host (a player who can is elected first), and never deposes one;
@@ -995,6 +1036,8 @@ export class NetRoom {
995
1036
  }
996
1037
  }
997
1038
  if (role === 'host') {
1039
+ // A rest with no rest checkpoint would leave this host running and everyone else holding their clock.
1040
+ if (this.resting && !this.restSaved(this.lastCkpt)) this.rest(null, false);
998
1041
  // Welcome contains the current holders; old occupancy notices must precede it.
999
1042
  this.hosted = new Map([...this.seats].map(([seat, s]) => [seat, s.occ ?? null]));
1000
1043
  this.hostId = c.id;
@@ -1009,7 +1052,7 @@ export class NetRoom {
1009
1052
  const behind = !lite && this.currentVer !== undefined && c.ver !== this.currentVer;
1010
1053
  if (behind) c.staleTold = true;
1011
1054
  this.send(c, {
1012
- t: 'welcome', v: NET_VERSION, rev: NET_REVISION, ...(c.conn.idlePing ? {idlePing:true} : {}), id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
1055
+ t: 'welcome', v: NET_VERSION, rev: NET_REVISION, ...(c.conn.idlePing ? {idlePing:true} : {}), ...(this.resting ? { rest: true } : {}), id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
1013
1056
  ...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
1014
1057
  role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
1015
1058
  round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
@@ -1322,6 +1365,15 @@ export class NetRoom {
1322
1365
  Math.min(candidate.rulesSpeed ?? 1, candidate.readySpeed ?? 0) > (current?.rulesSpeed ?? 0) * 1.5 + 0.1;
1323
1366
  }
1324
1367
 
1368
+ /** The checkpoint a browser host sent while resting: its save says `rest.on`. */
1369
+ restSaved(ckpt) {
1370
+ const d = ckpt?.d;
1371
+ if (!d || typeof d !== 'object') return false;
1372
+ const data = d.data && typeof d.data === 'object' ? d.data : d;
1373
+ const rest = data.rest;
1374
+ return Boolean(rest && typeof rest === 'object' && rest.on === true);
1375
+ }
1376
+
1325
1377
  /**
1326
1378
  * Choose a host. Visible and responsive first; then tenure in 30 s steps (a newcomer cannot jump the queue by
1327
1379
  * calling itself a desktop); then desk > tv > phone as a tie-break; then whoever came first. The chosen browser
@@ -1357,6 +1409,9 @@ export class NetRoom {
1357
1409
  // What the room's game reads (the dial, agent bodies) is what its new host's build says.
1358
1410
  this.caps = new Set(next.caps);
1359
1411
  next.lastSnapAt = 0;
1412
+ // A rest stays with the room when the checkpoint the new host restores is that rest: it stays asleep.
1413
+ // With no such save the new host would run while everyone else still holds their clock, so the rest ends.
1414
+ if (this.resting && !this.restSaved(this.lastCkpt)) this.rest(null, false);
1360
1415
  this.stats.promotions += 1;
1361
1416
  this.stats.elections.push({ at: now, id: next.id, seat: next.seat, why });
1362
1417
  if (this.stats.elections.length > 32) this.stats.elections.shift();
@@ -1390,9 +1445,9 @@ export class NetRoom {
1390
1445
  // A loopback (the Table's own house agent) cannot be half-open: it is never closed for silence.
1391
1446
  if (now - c.lastSeen > this.idleMs && !c.conn.loopback) this.kick(c, 'silent', 4000);
1392
1447
  }
1393
- // A host that stopped sending snapshots while others are present.
1448
+ // A host that stopped sending snapshots while others are present. A resting host sends none on purpose.
1394
1449
  const h = this.host();
1395
- if (!idle && h && this.live().length > 1) {
1450
+ if (!idle && !this.resting && h && this.live().length > 1) {
1396
1451
  const since = Math.max(h.lastSnapAt, this.hostSince, this.lastJoinAt);
1397
1452
  if (now - since > this.stallMs) {
1398
1453
  const next = this.elect(h.id, 'host-stalled');
@@ -13,7 +13,7 @@ export async function hasRole(request, env, app, role) {
13
13
  const launch = launchOf(meta, await settingsOf(env, {fresh:true}), env);
14
14
  if (launch !== 'public' && !(await accessOf(request, env, app, launch)).ok) return false;
15
15
  url.searchParams.set('role', role);
16
- return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url)).ok === true;
16
+ return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url, { owner: false })).ok === true;
17
17
  }
18
18
 
19
19
  /** Return a refusal response, or null when the browser supplied same-origin evidence. */
package/worker/table.mjs CHANGED
@@ -71,6 +71,8 @@ export class Table {
71
71
  this.gateHeld = new Map();
72
72
  this.gatePromised = new Map();
73
73
  this.roomAlarmAt = 0;
74
+ /** The alarm a house guide asked for. A rest replaces it with the room's own deadline, or the guide would wake the object every few seconds. */
75
+ this.guideAlarmAt = 0;
74
76
  this.durable = null;
75
77
  this.roomSave = null;
76
78
  this.endedRoom = null;
@@ -175,6 +177,8 @@ export class Table {
175
177
  this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
176
178
  // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
177
179
  this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
180
+ // Server and browser hosts both rest through the relay. Guides stop here, including when the host is a page.
181
+ this.room.onRest = (on) => { this.house?.rest(on); };
178
182
  // A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
179
183
  this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
180
184
  if (this.isPersistent()) this.room.forgetMs = Infinity;
@@ -216,7 +220,9 @@ export class Table {
216
220
  // A restore's provisional pause is not a recorded departure. A stored alarm,
217
221
  // even one now due, has not yet confirmed expiry. The alarm handler owns that decision.
218
222
  const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
219
- const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
223
+ // A resting room was saved with its people still here, and hibernated on purpose: its age is no absence.
224
+ const rested = saved?.resting === true && saved.pausedAt === null;
225
+ const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: rested ? Date.now() : this.recoveredAlarmAt }) : Infinity;
220
226
  const expired = Date.now() >= restoredEnd;
221
227
  if (saved && persistent && saved.stateHash !== build.stateHash) throw new Error('This lasting world needs its previous state schema. Restore the compatible build; the saved world has been retained.');
222
228
  if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
@@ -234,14 +240,21 @@ export class Table {
234
240
  // The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
235
241
  room.seats.clear(); room.state.clear(); room.stateBytes = 0;
236
242
  room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
237
- this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
238
- this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
239
- saved.pausedAt = this.pausedAt;
240
- // A failed pause write must never be mistaken for an invalid host save.
241
- try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
243
+ if (rested) {
244
+ // Rebuilt after hibernating on purpose: this save is exactly the world every browser holds. It comes back under
245
+ // the same epoch, so the input that wakes it applies, and nothing is written (the room wakes about once a minute).
246
+ this.pausedAt = null; this.pauseFromRestore = false;
247
+ } else {
248
+ this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
249
+ this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
250
+ saved.pausedAt = this.pausedAt;
251
+ // A failed pause write must never be mistaken for an invalid host save.
252
+ try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
253
+ }
242
254
  }
243
- const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
244
- if (saved) this.durable.boot({ epoch, count: boot.count });
255
+ // A rested save is replayed under its own epoch; any other restore (a crash, a deploy) takes a new one.
256
+ const epoch = saved ? (rested ? saved.host.core.epoch : Math.max(saved.host.core.epoch, boot.epoch) + 1) : undefined;
257
+ if (saved && !rested) this.durable.boot({ epoch, count: boot.count });
245
258
  this.hostCreatedAt = Date.now();
246
259
  let ticks = 0;
247
260
  this.hostRt = startHost(this.game, {
@@ -257,8 +270,17 @@ export class Table {
257
270
  // Alarm-driven suspension requires the native hibernation lifecycle.
258
271
  // Legacy adapters keep their host clock, as browser hosts do.
259
272
  ...(this.ctx.acceptWebSocket ? { onRoundIdle: until => { room.tellWatchers(); this.report(); this.recordRound(); this.armRoom(until); this.ctx.waitUntil(this.sleepRoom()); } } : {}),
273
+ // A room nobody is playing rests with its people connected (rules/host.ts), and hibernates like a round break.
274
+ // A deliberate rest is a clean stop: the restore-loop count starts again, so waking it is never a crash.
275
+ ...(this.ctx.acceptWebSocket && this.ctx.storage.sql ? { onRest: on => {
276
+ // Guides stop with the room: a think alarm would wake the object, and the command it sent would wake the sim.
277
+ this.house?.rest(on);
278
+ if (!on) { this.armRoom(Date.now() + ROOM_ALARM_MS); this.start(); return; }
279
+ try { this.durable?.boot({ epoch: this.hostRt?.epoch ?? epoch, count: 0 }); this.pendingBoot = null; } catch (error) { this.storageProblem(error); }
280
+ room.tellWatchers(); this.report(); this.ctx.waitUntil(this.sleepRoom());
281
+ } } : {}),
260
282
  onTick: () => {
261
- if (!saved) return;
283
+ if (!saved || rested) return;
262
284
  ticks += 1;
263
285
  if (ticks === 1) this.pendingBoot = { epoch, count: saved.buildHash !== build.build ? 0 : boot.count + 1 };
264
286
  if (ticks === 10 * build.settings.tickHz) this.pendingBoot = { epoch, count: 0 };
@@ -315,7 +337,7 @@ export class Table {
315
337
  const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
316
338
  for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
317
339
  }
318
- const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, stateHash: build.stateHash, buildHash: build.build, host, net };
340
+ const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, ...(this.hostRt?.resting ? { resting: true } : {}), stateHash: build.stateHash, buildHash: build.build, host, net };
319
341
  const clearing = this.cleanupPending;
320
342
  if (clearing) this.durable.clear();
321
343
  if (this.isPersistent()) {
@@ -370,7 +392,7 @@ export class Table {
370
392
  if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
371
393
  this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
372
394
  !Number.isFinite(saved.at) || saved.at < 0 || saved.at > Date.now() + 5000 || saved.pausedAt !== null && (!Number.isFinite(saved.pausedAt) || saved.pausedAt < 0 || saved.pausedAt > Date.now() + 5000) ||
373
- saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' ||
395
+ saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' || saved.resting !== undefined && saved.resting !== true ||
374
396
  saved.host?.v !== 1 || !saved.net || typeof saved.stateHash !== 'string' || typeof saved.buildHash !== 'string') throw new Error('save envelope does not fit this room');
375
397
  saved.at = Math.min(saved.at, Date.now());
376
398
  if (saved.pausedAt !== null) saved.pausedAt = Math.min(saved.pausedAt, Date.now());
@@ -796,7 +818,10 @@ export class Table {
796
818
  this.house = new HouseAgents({
797
819
  room, env: this.env,
798
820
  // The room's own alarm (a paused server-hosted room ends on it) is never pushed back by a guide's.
799
- setAlarm: (at) => { this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(error => this.say({ev:'background-failed',error:errorLine(error)})); },
821
+ setAlarm: (at) => {
822
+ this.guideAlarmAt = at;
823
+ this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(error => this.say({ev:'background-failed',error:errorLine(error)}));
824
+ },
800
825
  budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
801
826
  log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
802
827
  });
@@ -1191,7 +1216,7 @@ export class Table {
1191
1216
  } else {
1192
1217
  const h = room.attach(conn,server.resume);
1193
1218
  server.remember?.(h.id);
1194
- if (server.resume && room.server) room.joinServer(room.clients.get(h.id));
1219
+ if (server.resume && room.server) room.joinServer(room.clients.get(h.id), true);
1195
1220
  // A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
1196
1221
  // seats, so it is not counted twice). One counter write per room.
1197
1222
  const fresh = room.seats.size === 0;
@@ -1281,7 +1306,8 @@ export class Table {
1281
1306
  // Older ports publish snapshots without a round protocol. Once play starts,
1282
1307
  // treat them as active until empty: persisting every frame would turn their
1283
1308
  // ordinary network traffic into a stream of SQLite writes.
1284
- const active = this.hostRt ? this.hostRt.running : this.room?.lastRound?.phase === 'live' || (!this.room?.lastRound && this.room?.lastSnap != null && this.room?.clients.size > 0);
1309
+ // A browser host that rested its room (rules/host.ts in the page) sends nothing until somebody plays: it sleeps.
1310
+ const active = this.hostRt ? this.hostRt.running : !this.room?.resting && (this.room?.lastRound?.phase === 'live' || (!this.room?.lastRound && this.room?.lastSnap != null && this.room?.clients.size > 0));
1285
1311
  return this.room && this.hibernationReady && this.sessionStore && this.ctx.storage.sql && this.ctx.acceptWebSocket && (!this.relayed || this.relays.size > 0) && (!this.hostRt || this.hostRt.roundIdleUntil !== null || !this.hostRt.running) && !active && !this.restoringSockets;
1286
1312
  }
1287
1313
  async sleepRoom() {
@@ -1304,12 +1330,19 @@ export class Table {
1304
1330
  }
1305
1331
  this.saveSessions();
1306
1332
  for(const relay of this.relays.values()){relay.session.flush();relay.socket.sleep();}
1307
- // Edge-answered heartbeats need only a minute's liveness sweep. A legacy
1308
- // client still keeps its original hello/heartbeat deadline.
1309
- const due=[...(this.hostRt?.roundIdleUntil != null ? [this.hostRt.roundIdleUntil] : []),now+(room.officeDirty?1000:60000),...[...room.clients.values()].map(c=>{const ping=c.conn.lastPing?.()??0;return c.helloed?Math.max(c.lastSeen,ping)+(ping?Math.max(room.idleMs,60000):room.idleMs):c.joinedAt+5000;})];
1333
+ // Edge-answered heartbeats need only a minute's liveness sweep: a browser that has pinged the edge, or one that
1334
+ // understands a resting room (it pings the edge from the moment it rests). A legacy client still keeps its
1335
+ // original hello/heartbeat deadline.
1336
+ const due=[...(this.hostRt?.roundIdleUntil != null ? [this.hostRt.roundIdleUntil] : []),now+(room.officeDirty?1000:60000),...[...room.clients.values()].map(c=>{const ping=c.conn.lastPing?.()??0;return c.helloed?Math.max(c.lastSeen,ping)+(ping||c.rest&&c.conn.idlePing?Math.max(room.idleMs,60000):room.idleMs):c.joinedAt+5000;})];
1310
1337
  for(const control of [room.vote?.open?room.vote:null,room.regate,room.agentsOut])if(control?.until)due.push(control.until);
1311
1338
  const at=Math.max(now+1,Math.min(...due)),alarm=await this.ctx.storage.getAlarm();
1312
- if(alarm==null||alarm>at)await this.ctx.storage.setAlarm(at);
1339
+ // A guide's think is sooner than the liveness sweep, and sleep only moves an alarm earlier. While the host rests,
1340
+ // that think must not survive: it would wake the object every few seconds and its command would start the room.
1341
+ // A spectator's own shorter deadline stays, because `at` is already the soonest real one.
1342
+ const roomResting=this.hostRt?.resting===true||this.room?.resting===true;
1343
+ const guideSoon=roomResting && this.guideAlarmAt && alarm!=null && alarm<=this.guideAlarmAt+1000;
1344
+ if(alarm==null||alarm>at||guideSoon)await this.ctx.storage.setAlarm(at);
1345
+ if(guideSoon)this.guideAlarmAt=0;
1313
1346
  }
1314
1347
  start() {
1315
1348
  if(this.restoringSockets)return;
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.58.0';
2
+ export const STUDIO_VERSION_TAG = '0.58.2';