@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/rules/rules.ts CHANGED
@@ -214,6 +214,19 @@ export interface RoomSettings {
214
214
  predict: { idleHold?: boolean; catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
215
215
  /** The studio's own pace on what one player sends, messages a second by frame kind (`in`, `ev`, ...). Absent: none. */
216
216
  rates?: Record<string, number>;
217
+ /**
218
+ * When a room with people in it rests (rules/host.ts): its clock stops, its people keep their sockets and seats, and
219
+ * the first input, command, AI command or returning page starts it again where it stopped.
220
+ * 'quiet' (absent): nobody has played for `restSeconds`, and the world has nothing of its own to do: no round clock
221
+ * running, no event, timer, spawn or ask waiting, nothing moving (bots, physics, tick handlers).
222
+ * 'idle': nobody has played for `restSeconds`, whatever the world is doing: round clocks, timers, bots
223
+ * and AI pause with it (an away-from-keyboard pause, for arcade games where that is right).
224
+ * 'never': never rests while somebody is here.
225
+ * Every page in the background counts as nobody playing after ten seconds. A lasting world never rests.
226
+ */
227
+ rest?: 'quiet' | 'idle' | 'never';
228
+ /** How long nobody plays before a room rests (`rest`). Absent: 60. 0: never rests. */
229
+ restSeconds?: number;
217
230
  }
218
231
  /**
219
232
  * The default `budget.tick`: 500,000 units at 20 ticks a second or fewer, and less at a faster rate, so a second of
@@ -247,8 +260,8 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
247
260
  if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
248
261
  return n;
249
262
  };
250
- const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters', 'rates']);
251
- for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view, rates)`);
263
+ const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters', 'rates', 'rest', 'restSeconds']);
264
+ for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view, rates, rest, restSeconds)`);
252
265
  let rates: Record<string, number> | undefined;
253
266
  if (r.rates !== undefined) {
254
267
  if (!r.rates || typeof r.rates !== 'object' || Array.isArray(r.rates)) problems.push('"room.rates" is an object of messages a second per player by kind ({ "in": 60 }); using none');
@@ -275,7 +288,7 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
275
288
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
276
289
  return {
277
290
  settings: {
278
- host, offline, tickHz, inputHz, ...(rates ? { rates } : {}), ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
291
+ host, offline, tickHz, inputHz, ...(rates ? { rates } : {}), ...(r.rest !== undefined ? { rest: ['quiet', 'idle', 'never'].includes(r.rest) ? r.rest : (problems.push('"room.rest" is "quiet", "idle" or "never"; using "quiet"'), 'quiet') } : {}), ...(r.restSeconds !== undefined ? { restSeconds: whole('restSeconds', r.restSeconds, 0, 3600, 60) } : {}), ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
279
292
  view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null), ...(r.view?.precisionM !== undefined ? { precisionM: num('view.precisionM', r.view.precisionM, 0) as number } : {}), ...(r.view?.nearM !== undefined ? { nearM: num('view.nearM', r.view.nearM, null) } : {}), ...(r.view?.farHz !== undefined ? { farHz: whole('view.farHz', r.view.farHz, 1, tickHz, tickHz) } : {}) },
280
293
  durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, r.lifetime === 'persistent' ? 30 : 1) },
281
294
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
package/rules/view.ts CHANGED
@@ -294,6 +294,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
294
294
  let controlChanges: number[][] = [];
295
295
  let sentHistory: { t: number; row: number[] }[] = [];
296
296
  let hiddenSent = false;
297
+ /** The room rests (net.resting): when this page last sent the change that wakes it, and whether its clock is held. */
298
+ let restWokeAt = -Infinity;
299
+ let restHeld = false;
297
300
  const history = new Map<number, Mine>();
298
301
  let pending: { seq?: number; applied?: number; t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number>; pulseTicks?: Record<string, number> }[] = [];
299
302
  /** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
@@ -423,7 +426,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
423
426
  leadAfter = clock() + 2 * rtt + period;
424
427
  base = { tick: continuousTick ?? k + Math.min(tickHz, Math.ceil(rtt / period + target) + sendEvery - 1), at: clock() };
425
428
  stepped = Math.max(k, Math.floor(base.tick) - (continuousTick === null ? 1 : 0));
426
- entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; leadTarget = .5; leadMedian = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
429
+ entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; leadTarget = .5; leadMedian = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead' && reason !== 'rest') dropPredictions();
427
430
  speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
428
431
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
429
432
  if (mine) {
@@ -637,14 +640,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
637
640
  /**
638
641
  * The end of a send period (`sendEvery` ticks, counted from tick 0): one frame with the entries made in it, `k` the
639
642
  * period's first tick. A period with no entry sends nothing; after 250 ms without a frame the held values are sent
640
- * again as an ordinary entry on the current tick.
643
+ * again as an ordinary entry on the current tick. A resting room (nobody played for a while) is sent nothing until
644
+ * an entry changes: that first change is what wakes it.
641
645
  */
642
646
  function flush(now: number, t: number): void {
643
647
  if (!mine) { entries = []; return; }
644
- if (!entries.length && now - lastSendAt >= 250) keepalive(t);
648
+ const resting = net.resting();
649
+ if (!entries.length && now - lastSendAt >= 250 && !resting) keepalive(t);
645
650
  const marker = myKind()!.input.length + (myKind()!.owner ? 9 : 0);
646
651
  // A newer tick's acknowledgement cannot acknowledge a hole in the history.
647
- if (!entries.length && !sentHistory.some(e => !applications.has(e.row[marker + 1]))) return;
652
+ if (!entries.length && (resting || !sentHistory.some(e => !applications.has(e.row[marker + 1])))) return;
648
653
  const k = Math.min(entries[0]?.t ?? t, t - (t % sendEvery));
649
654
  for (const entry of entries) {
650
655
  const old = sentHistory.findIndex(e => e.t === entry.t);
@@ -710,7 +715,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
710
715
  const hidden = typeof document !== 'undefined' && document.hidden === true;
711
716
  // Effects play for everyone who draws the room: a watcher has no body and still sees them.
712
717
  fire();
713
- if (!mine || !base || net.seat === null || (!net.connected && !net.rulesHosting)) return;
718
+ if (!mine || net.seat === null || (!net.connected && !net.rulesHosting)) return;
719
+ if (!hidden && net.resting()) { restPump(now); return; }
720
+ if (!base) return;
714
721
  if (hidden) {
715
722
  // A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
716
723
  // one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
@@ -736,6 +743,28 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
736
743
  }
737
744
  }
738
745
 
746
+ /**
747
+ * A resting room (nothing is simulated until somebody plays): this page's input clock stops with it, so what it
748
+ * sends next is stamped on the tick the room rested on, never seconds ahead of it. Nothing is stepped or sent until
749
+ * a control is held or pressed; then that input goes at once, as one entry on the next tick (again a second later
750
+ * if the room has not woken yet), and it wakes the room. The first snapshot after the room wakes sets the clock
751
+ * again (applySnapshot: no base), keeping what is predicted.
752
+ */
753
+ function restPump(now: number): void {
754
+ const kind = myKind();
755
+ base = null; restHeld = true;
756
+ if (!kind || now - restWokeAt < 1000) return;
757
+ const engaged = pressed.size > 0 || pulseQueue.length > 0 || kind.input.some(([name, fd]) => fd.t !== 'press' && fd.t !== 'pulse' && Number(coerce(fd, fd.t === 'held' ? heldNumber(sample[name], fd.init) : sample[name] ?? fd.init, dims)) !== Number(coerce(fd, fd.init, dims)));
758
+ if (!engaged) return;
759
+ restWokeAt = now;
760
+ const t = stepped + 1;
761
+ const values = kind.input.map(([name, fd]) => fd.t === 'press' ? (pressed.get(name) ? 1 : 0) : fd.t === 'pulse' ? 0 : fd.t === 'held' ? Number(coerce(fd, heldNumber(sample[name], fd.init), dims)) : Number(coerce(fd, sample[name] ?? fd.init, dims)));
762
+ pressed = new Map();
763
+ const claim = kind.owner ? [mine!.pos.x, mine!.pos.y, mine!.pos.z, mine!.vel.x, mine!.vel.y, mine!.vel.z, mine!.heading.x, mine!.heading.y, mine!.heading.z] : [];
764
+ entries = [{ t, row: [...values, ...claim, -471, ++inputSequence, 0] }]; lastSent = JSON.stringify([values, claim]);
765
+ lastSendAt = 0; flush(now, t);
766
+ }
767
+
739
768
  function onSnapshot(s: Snapshot<unknown>): void {
740
769
  const wasPumping = pumping;
741
770
  pumping = true;
@@ -780,7 +809,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
780
809
  returningUntil = 0;
781
810
  }
782
811
  if (placed || !base) {
783
- adopt(me); rebase(f.k, changedEpoch ? 'epoch' : 'placement');
812
+ const rested = restHeld && !placed; restHeld = false;
813
+ adopt(me); rebase(f.k, changedEpoch ? 'epoch' : rested ? 'rest' : 'placement');
784
814
  if (placed) emit('placed', entityOf(me, true));
785
815
  } else if (kindOf.get(me.kind)?.owner) {
786
816
  mine!.motion = motionOf(me.kind, me.motion);
@@ -886,7 +916,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
886
916
  }
887
917
  }
888
918
  if (!latencyCalibrated && authorityRtt() > calibratedRtt + period && !Object.values(sample).some(Boolean) && !pending.some(e=>Object.values(e.input).some(Boolean))) { rebase(f.k, 'lead'); latencyCalibrated = true; }
889
- if (row && row[3] !== -128 && base && clock() >= leadAfter) {
919
+ // The furthest lead there is: this clock is more than a second ahead of the room (its input is being dropped).
920
+ if (row && row[3] >= 126 && base && clock() >= leadAfter) rebase(f.k, 'lead');
921
+ else if (row && row[3] !== -128 && base && clock() >= leadAfter) {
890
922
  const now = clock(), rtt = authorityRtt();
891
923
  // A reported lead describes an input sent roughly one authority round trip ago. Remove that
892
924
  // clock's phase before measuring jitter; project the control median onto today's phase. Otherwise
@@ -9,7 +9,8 @@
9
9
  "entry": "src/view.ts",
10
10
  "example": true,
11
11
  "room": {
12
- "host": "server"
12
+ "host": "server",
13
+ "rest": "idle"
13
14
  },
14
15
  "landing": {
15
16
  "pitch": "Run for the coins. First one there takes it.",
@@ -24,6 +24,7 @@
24
24
  }
25
25
  },
26
26
  "room": {
27
- "host": "server"
27
+ "host": "server",
28
+ "rest": "idle"
28
29
  }
29
30
  }
@@ -16,6 +16,7 @@
16
16
  }
17
17
  },
18
18
  "room": {
19
- "host": "server"
19
+ "host": "server",
20
+ "rest": "idle"
20
21
  }
21
22
  }
@@ -21,6 +21,7 @@
21
21
  }
22
22
  },
23
23
  "room": {
24
- "host": "server"
24
+ "host": "server",
25
+ "rest": "idle"
25
26
  }
26
27
  }
@@ -21,6 +21,7 @@
21
21
  }
22
22
  },
23
23
  "room": {
24
- "host": "server"
24
+ "host": "server",
25
+ "rest": "idle"
25
26
  }
26
27
  }
package/tools/tools.ts CHANGED
@@ -16,7 +16,7 @@ export interface ToolContext {
16
16
  };
17
17
  bookings: {
18
18
  availability(args: {app: string; collection: string; after?: string; quantity?: number; option?: string; resource?: string; addons?: Record<string,number>}): Promise<BookingAvailability>;
19
- calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
19
+ calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string;exceptions?:Record<string,null|{time?:string;capacity?:number;durationMinutes?:number}>}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
20
20
  hold(args: {app: string; collection: string; slot?: string; time?: string; option?: string; resource?: string; preferredResource?: string; addons?: string[] | Record<string,number>; addOns?: string[] | Record<string,number>; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
21
21
  get(args: {id: string; token?: string}): Promise<BookingHold>;
22
22
  confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
package/worker/agents.mjs CHANGED
@@ -363,6 +363,7 @@ export class HouseAgents {
363
363
  this.agents = [];
364
364
  this.decisions = [];
365
365
  this.alarmAt = null;
366
+ this.resting = false;
366
367
  this.lastAlarmAt = -Infinity;
367
368
  this.backoffUntil = 0;
368
369
  /** pass id → until: a guide the owner kicked stays out until its hold ends. */
@@ -423,7 +424,7 @@ export class HouseAgents {
423
424
  };
424
425
  this.agents.push(a);
425
426
  a.h = this.room.attach(a.conn);
426
- a.h.onMessage(JSON.stringify({ t: 'hello', v: 1, rev: 7, device: 'desk', want: 'play', canHost: false, agent: { hands: 'host', role: 'guide' } }));
427
+ a.h.onMessage(JSON.stringify({ t: 'hello', v: 1, rev: 7, device: 'desk', want: 'play', canHost: false, rest: 1, agent: { hands: 'host', role: 'guide' } }));
427
428
  return !a.closed;
428
429
  }
429
430
 
@@ -449,7 +450,8 @@ export class HouseAgents {
449
450
  try { m = JSON.parse(text); } catch { return; }
450
451
  const now = this.now();
451
452
  switch (m.t) {
452
- case 'welcome': a.seat = m.seat; if (typeof m.name === 'string') a.name = m.name; return;
453
+ case 'welcome': a.seat = m.seat; if (typeof m.name === 'string') a.name = m.name; if (m.rest === true) this.rest(true); return;
454
+ case 'rest': this.rest(m.on === true); return;
453
455
  case 'error':
454
456
  if (m.code === 'kicked') this.held.set(a.pass, Number(m.until) || now + 10 * 60_000);
455
457
  else if (['agents-off', 'agents-unsupported', 'agent-pass', 'room-closed', 'room-full', 'too-many'].includes(m.code)) this.backoffUntil = now + 60_000;
@@ -524,8 +526,19 @@ export class HouseAgents {
524
526
  return Math.max(a.lastCallAt + BRAIN_CADENCE.gapMs, cap);
525
527
  }
526
528
 
529
+ /**
530
+ * The room rested, or woke. While it rests, no guide thinks and none keeps an alarm: a think would be a command,
531
+ * and a command would wake the room. The next decision waits until somebody plays.
532
+ */
533
+ rest(on) {
534
+ this.resting = on === true;
535
+ if (this.resting) { this.alarmAt = null; return; }
536
+ this.schedule();
537
+ }
538
+
527
539
  /** One alarm for the room: the earliest due guide, never sooner than 3 s after the last alarm. */
528
540
  schedule() {
541
+ if (this.resting) return null;
529
542
  const now = this.now();
530
543
  let next = Infinity;
531
544
  for (const a of this.live()) if (a.seat !== null && a.dueAt !== Infinity && !a.inFlight) next = Math.min(next, Math.max(a.dueAt, this.readyAt(a, now)));
@@ -539,6 +552,7 @@ export class HouseAgents {
539
552
 
540
553
  /** The Durable Object's alarm: every guide due by now (or within 1.5 s) decides, then the next alarm is set. */
541
554
  async onAlarm() {
555
+ if (this.resting) { this.alarmAt = null; return 0; }
542
556
  const now = this.now();
543
557
  this.lastAlarmAt = now;
544
558
  this.alarmAt = null;
@@ -568,7 +582,7 @@ export class HouseAgents {
568
582
  const goalNow = a.view?.goal && typeof a.view.goal === 'object' ? a.view.goal : null;
569
583
  if (!asks.length && a.carry && now < a.carry.until && (!goalNow || (goalNow.goal === a.carry.goal && goalNow.state === 'active'))) {
570
584
  a.inFlight = false;
571
- if (this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
585
+ if (!this.resting && this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
572
586
  return null;
573
587
  }
574
588
  if (a.carry && (now >= a.carry.until || (goalNow && (goalNow.goal !== a.carry.goal || goalNow.state !== 'active')))) a.carry = null;
@@ -584,6 +598,8 @@ export class HouseAgents {
584
598
  const newHere = [...a.seen].filter(([seat, at]) => now - at < 20_000 && players.includes(seat)).map(([seat]) => seat);
585
599
  const ctx = { view: a.view, me: { seat: a.seat }, goal: a.view?.goal ?? null, asks, party: a.party, speech: this.room.policy.speech, kids: this.room.policy.kids, quiet, players, avoid, said, newHere };
586
600
  const t = await think(vocab, { mode, brain: p, ctx, ms: BRAIN_CADENCE.timeoutMs, now: this.now });
601
+ // The room rested while the brain was thinking: drop the decision. Sending it would wake the room.
602
+ if (this.resting || a.closed) { a.inFlight = false; return null; }
587
603
  const { decision, provider, why, ms, cost } = t;
588
604
  if (t.engine) {
589
605
  this.calls += 1;
@@ -606,7 +622,7 @@ export class HouseAgents {
606
622
  ...(t.own && /\+floor$/.test(provider) ? { own: { goal: t.own.goal, args: t.own.args } } : {}), ...(t.p ? { p: t.p } : {}), ...(t.model ? { model: t.model } : {}),
607
623
  }].slice(-LOG_MAX);
608
624
  a.inFlight = false;
609
- if (this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
625
+ if (!this.resting && this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
610
626
  return decision;
611
627
  }
612
628
 
@@ -11,15 +11,28 @@ function instant(day,time,format) {
11
11
  return matches[0];
12
12
  }
13
13
  /** Inclusive season dates; each returned slot may span several local calendar days. */
14
- export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot'}) {
14
+ export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot',exceptions={}}) {
15
15
  const start=date(startDate),end=date(endDate);
16
+ if(!exceptions||Array.isArray(exceptions)||typeof exceptions!=='object')throw new Error('Invalid calendar exceptions');
17
+ for(const [day,change] of Object.entries(exceptions)){
18
+ const at=date(day);
19
+ if(at<start||at>end||weekdays&&!weekdays.includes(new Date(at).getUTCDay()))throw new Error('Exception is outside the series');
20
+ if(change!==null&&(!change||typeof change!=='object'||Array.isArray(change)||Object.keys(change).some(k=>!['time','capacity','durationMinutes'].includes(k)||change[k]===undefined)))throw new Error('Invalid calendar exception');
21
+ }
16
22
  if(end<start||end-start>3660*dayMs||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)||!Number.isSafeInteger(capacity)||capacity<1||days!==undefined&&(!Number.isSafeInteger(days)||days<1)||!Number.isSafeInteger(durationMinutes)||durationMinutes<1)throw new Error('Invalid calendar range, time, duration or capacity');
17
23
  if(weekdays&&(!Array.isArray(weekdays)||weekdays.some(n=>!Number.isInteger(n)||n<0||n>6)))throw new Error('weekdays uses 0 (Sunday) through 6 (Saturday)');
18
24
  if(typeof timeZone!=='string'||!timeZone)throw new Error('Declare the place timeZone');
19
25
  const format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}),slots=[];
20
26
  for(let at=start;at<=end;at+=dayMs) {
21
27
  if(weekdays&&!weekdays.includes(new Date(at).getUTCDay()))continue;
22
- const day=new Date(at).toISOString().slice(0,10),from=instant(day,time,format),to=days?instant(new Date(at+days*dayMs).toISOString().slice(0,10),time,format):from+durationMinutes*60000;
28
+ const day=new Date(at).toISOString().slice(0,10);
29
+ if(Object.hasOwn(exceptions,day)){
30
+ const change=exceptions[day];if(change===null)continue;
31
+ const [slot]=calendarSlots({startDate:day,endDate:day,timeZone,time,days:Object.hasOwn(change,'durationMinutes')?undefined:days,durationMinutes,capacity,resources,options,prefix,...change});
32
+ // Identity follows the series occurrence, even when its wall time changes.
33
+ slots.push({...slot,id:`${prefix}-${day}-${time.replace(':','')}`});continue;
34
+ }
35
+ const from=instant(day,time,format),to=days?instant(new Date(at+days*dayMs).toISOString().slice(0,10),time,format):from+durationMinutes*60000;
23
36
  slots.push({id:`${prefix}-${day}-${time.replace(':','')}`,date:day,timeZone,start:new Date(from).toISOString(),end:new Date(to).toISOString(),capacity,...(resources?{resources}:{}),...(options?{options}:{})});
24
37
  }
25
38
  return slots;
@@ -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.57.0","sha256":"0a84d357448b7257ee97cac637957a6fe5bb46323de76625644d88e80a684a87"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.58.1","sha256":"b3944d94ae64b4e7755614a9801c54216f14fcb741e9598bd5c4eab117deae50"});
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');